From 9c0117c0f811ac0ac9884e69fe2e93298d5d8259 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 15:17:04 +0100 Subject: [PATCH 01/17] build: integrate Terrabuild locally and in GitHub CI --- .build/checks.sh | 25 ++++++ .build/run-terrabuild.sh | 16 ++++ .build/sdk-check.sh | 20 +++++ .build/toolchain/Dockerfile | 17 ++++ .build/toolchain/PROJECT | 13 +++ .build/workflow.fss | 12 +++ .github/actions/setup-tools/action.yml | 54 +++++++++++++ .github/scripts/test-terrabuild.sh | 79 +++++++++++++++++++ .github/scripts/verify-release-cli.sh | 4 +- .github/workflows/ci-main.yml | 59 ++++---------- .github/workflows/ci-pr.yml | 59 ++++---------- .gitignore | 4 + CHANGELOG.md | 2 + FScript.sln | 2 +- Makefile | 30 ++++++- WORKSPACE | 62 +++++++++++++++ benchmarks/FScript.DotNet.Comparison/PROJECT | 9 +++ benchmarks/FScript.DotNet.Reference/PROJECT | 9 +++ .../FScript.Evaluation.Benchmarks/PROJECT | 9 +++ .../FScript.FrontEnd.Benchmarks/PROJECT | 9 +++ .../FScript.Inference.Benchmarks/PROJECT | 9 +++ .../FScript.Interpreter.Benchmarks/PROJECT | 9 +++ docs/README.md | 4 + docs/architecture/assemblies-and-roles.md | 12 ++- docs/guides/compile-to-csharp.md | 2 +- docs/guides/compile-to-dotnet.md | 4 +- docs/guides/compiler-cli.md | 4 +- docs/guides/terrabuild.md | 48 +++++++++++ samples/ffi/{ => Provider}/FfiSample.fsproj | 4 +- samples/ffi/{ => Provider}/Graphics.fs | 0 samples/ffi/Provider/PROJECT | 10 +++ samples/ffi/README.md | 4 +- samples/ffi/Renderer/PROJECT | 8 ++ samples/ffi/Tests/FfiSample.Tests.fsproj | 2 +- samples/ffi/Tests/PROJECT | 14 ++++ samples/ffi/fscript.toml | 4 +- samples/task-sharing/PROJECT | 10 +++ src/FScript.Backend.CSharp/PROJECT | 9 +++ src/FScript.Backend.DotNet/PROJECT | 9 +++ src/FScript.CSharpInterop/PROJECT | 9 +++ src/FScript.Compiler/PROJECT | 9 +++ src/FScript.DebugAdapter/PROJECT | 9 +++ src/FScript.Interpreter.Lir/PROJECT | 9 +++ src/FScript.JavaScript/PROJECT | 17 ++++ src/FScript.Language/PROJECT | 9 +++ src/FScript.LanguageServer/PROJECT | 9 +++ src/FScript.Runtime/PROJECT | 11 +++ src/FScript/PROJECT | 9 +++ tests/FScript.Backend.DotNet.Tests/PROJECT | 14 ++++ .../PROJECT | 10 +++ .../PROJECT | 9 +++ .../PROJECT | 10 +++ .../FScript.Cli.Tests.Fixtures.Valid/PROJECT | 10 +++ tests/FScript.Cli.Tests/PROJECT | 14 ++++ tests/FScript.Compiler.Tests/PROJECT | 14 ++++ tests/FScript.Conformance.Tests/PROJECT | 14 ++++ tests/FScript.DebugAdapter.Tests/PROJECT | 26 ++++++ tests/FScript.Interpreter.Lir.Tests/PROJECT | 14 ++++ tests/FScript.Language.Tests/PROJECT | 14 ++++ tests/FScript.LanguageServer.Tests/PROJECT | 14 ++++ tests/FScript.Runtime.Tests/PROJECT | 14 ++++ vscode-fscript/PROJECT | 20 +++++ website/PROJECT | 21 +++++ website/docs/compiler/compile-to-csharp.md | 2 +- website/docs/compiler/compile-to-dotnet.md | 4 +- website/docs/examples/ffi-graphics.md | 10 +-- 66 files changed, 873 insertions(+), 112 deletions(-) create mode 100644 .build/checks.sh create mode 100755 .build/run-terrabuild.sh create mode 100644 .build/sdk-check.sh create mode 100644 .build/toolchain/Dockerfile create mode 100644 .build/toolchain/PROJECT create mode 100644 .build/workflow.fss create mode 100644 .github/actions/setup-tools/action.yml create mode 100644 .github/scripts/test-terrabuild.sh create mode 100644 WORKSPACE create mode 100644 benchmarks/FScript.DotNet.Comparison/PROJECT create mode 100644 benchmarks/FScript.DotNet.Reference/PROJECT create mode 100644 benchmarks/FScript.Evaluation.Benchmarks/PROJECT create mode 100644 benchmarks/FScript.FrontEnd.Benchmarks/PROJECT create mode 100644 benchmarks/FScript.Inference.Benchmarks/PROJECT create mode 100644 benchmarks/FScript.Interpreter.Benchmarks/PROJECT create mode 100644 docs/guides/terrabuild.md rename samples/ffi/{ => Provider}/FfiSample.fsproj (77%) rename samples/ffi/{ => Provider}/Graphics.fs (100%) create mode 100644 samples/ffi/Provider/PROJECT create mode 100644 samples/ffi/Renderer/PROJECT create mode 100644 samples/ffi/Tests/PROJECT create mode 100644 samples/task-sharing/PROJECT create mode 100644 src/FScript.Backend.CSharp/PROJECT create mode 100644 src/FScript.Backend.DotNet/PROJECT create mode 100644 src/FScript.CSharpInterop/PROJECT create mode 100644 src/FScript.Compiler/PROJECT create mode 100644 src/FScript.DebugAdapter/PROJECT create mode 100644 src/FScript.Interpreter.Lir/PROJECT create mode 100644 src/FScript.JavaScript/PROJECT create mode 100644 src/FScript.Language/PROJECT create mode 100644 src/FScript.LanguageServer/PROJECT create mode 100644 src/FScript.Runtime/PROJECT create mode 100644 src/FScript/PROJECT create mode 100644 tests/FScript.Backend.DotNet.Tests/PROJECT create mode 100644 tests/FScript.Cli.Tests.Fixtures.Conflict/PROJECT create mode 100644 tests/FScript.Cli.Tests.Fixtures.Consumer/PROJECT create mode 100644 tests/FScript.Cli.Tests.Fixtures.Invalid/PROJECT create mode 100644 tests/FScript.Cli.Tests.Fixtures.Valid/PROJECT create mode 100644 tests/FScript.Cli.Tests/PROJECT create mode 100644 tests/FScript.Compiler.Tests/PROJECT create mode 100644 tests/FScript.Conformance.Tests/PROJECT create mode 100644 tests/FScript.DebugAdapter.Tests/PROJECT create mode 100644 tests/FScript.Interpreter.Lir.Tests/PROJECT create mode 100644 tests/FScript.Language.Tests/PROJECT create mode 100644 tests/FScript.LanguageServer.Tests/PROJECT create mode 100644 tests/FScript.Runtime.Tests/PROJECT create mode 100644 vscode-fscript/PROJECT create mode 100644 website/PROJECT diff --git a/.build/checks.sh b/.build/checks.sh new file mode 100644 index 00000000..e5c5147b --- /dev/null +++ b/.build/checks.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +configuration="${FSCRIPT_BUILD_CONFIG:-Release}" +cli=(dotnet "src/FScript/bin/$configuration/net10.0/fscript.dll") +case "${1:?Expected debugger or smoke}" in + debugger) + mkdir -p tests/FScript.DebugAdapter.Tests/TestResults + node --test --test-reporter=junit --test-reporter-destination=tests/FScript.DebugAdapter.Tests/TestResults/debugger.xml tests/FScript.DebugAdapter.Tests/*Tests.js + ;; + smoke) + for script in samples/*.fss; do + echo "Running $script" + "${cli[@]}" "$script" > /dev/null + done + "${cli[@]}" samples/basic/basic.fss -- helloworld.bas > /dev/null + "${cli[@]}" samples/debugging/main.fss > /dev/null + "${cli[@]}" samples/ffi/main.fss -- --headless + for script in samples/task-sharing/*.fss; do "${cli[@]}" analyze "$script"; done + "${cli[@]}" samples/task-sharing/ordered.fss > /dev/null + "${cli[@]}" --no-result samples/task-sharing/thread-safe.fss > /dev/null + echo "All smoke tests passed." + ;; + *) echo "Unknown check: $1" >&2; exit 2 ;; +esac diff --git a/.build/run-terrabuild.sh b/.build/run-terrabuild.sh new file mode 100755 index 00000000..bbcbd852 --- /dev/null +++ b/.build/run-terrabuild.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +report=$(mktemp) +trap 'rm -f "$report"' EXIT +parallel_args=() +if [[ -n "${TB_PARALLEL:-}" ]]; then + parallel_args=(--parallel "$TB_PARALLEL") +fi +terrabuild run "$@" --variable "config=${FSCRIPT_BUILD_CONFIG:-Release}" ${parallel_args[@]+"${parallel_args[@]}"} --out "$report" +# Terrabuild 0.200.1 can exit zero after reporting a failed graph. Require the +# machine-readable completion status too, including for a fully cached run. +if ! grep -Eq '"status"[[:space:]]*:[[:space:]]*"success"' "$report"; then + echo "Terrabuild did not report a successful build; inspect the task logs above." >&2 + exit 1 +fi diff --git a/.build/sdk-check.sh b/.build/sdk-check.sh new file mode 100644 index 00000000..ef869882 --- /dev/null +++ b/.build/sdk-check.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Publishing different assembly versions must not mutate outputs used by tests. +set -euo pipefail +cd "$(dirname "$0")/.." +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +node - "$work" <<'JS' +const fs = require('node:fs'); +const path = require('node:path'); +const {execFileSync} = require('node:child_process'); +const destination = process.argv[2]; +const files = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], {maxBuffer: 16 * 1024 * 1024}).toString().split('\0').filter(Boolean); +for (const file of new Set(files)) { + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) continue; + const output = path.join(destination, file); + fs.mkdirSync(path.dirname(output), {recursive: true}); + fs.copyFileSync(file, output); +} +JS +bash "$work/.github/scripts/verify-sdk-provider.sh" diff --git a/.build/toolchain/Dockerfile b/.build/toolchain/Dockerfile new file mode 100644 index 00000000..f7a08656 --- /dev/null +++ b/.build/toolchain/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-bookworm-slim AS node +FROM mcr.microsoft.com/dotnet/sdk:10.0.401 + +# AOT integration tests invoke the native compiler. Node is also needed by the +# debugger tests, Fable package, VS Code extension, and documentation build. +RUN apt-get update \ + && apt-get install -y --no-install-recommends clang zlib1g-dev git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ + && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1 NPM_CONFIG_AUDIT=false NPM_CONFIG_FUND=false + +# Tests capture subprocess output. Persistent SDK/compiler servers inherit those +# pipes and can keep ReadToEnd waiting after the child command has exited. +ENV MSBUILDDISABLENODEREUSE=1 DOTNET_CLI_USE_MSBUILD_SERVER=0 UseSharedCompilation=false diff --git a/.build/toolchain/PROJECT b/.build/toolchain/PROJECT new file mode 100644 index 00000000..5f97425f --- /dev/null +++ b/.build/toolchain/PROJECT @@ -0,0 +1,13 @@ +project toolchain { + labels = [ "toolchain" ] + includes = [ "**/*" "Dockerfile" ] +} + +# Always check/build the local image. Docker reuses its layers. Phase barriers do +# not force cached application targets to execute, and no image is pushed. +target image { + phase = phase.toolchains + build = ~always + artifacts = ~none + @shell docker { args = "build --tag fscript-build:${terrabuild.version} ." } +} diff --git a/.build/workflow.fss b/.build/workflow.fss new file mode 100644 index 00000000..73317314 --- /dev/null +++ b/.build/workflow.fss @@ -0,0 +1,12 @@ +// Build/test scripts share a pinned container and can cache successful results. +// @shell is reserved for the host-side Docker image bootstrap. +export let run (context: Protocol.ActionContext) (script: string) (args: string option) = + let arguments = Option.defaultValue "" args + { PreserveProvenance = Broken + Batchable = false + Operations = + [ { Command = "bash" + Arguments = $"{script} {arguments}" + ErrorLevel = 0 } ] } + +{ [nameof run] = [Cache Remote] } diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml new file mode 100644 index 00000000..ef5cd76c --- /dev/null +++ b/.github/actions/setup-tools/action.yml @@ -0,0 +1,54 @@ +name: Setup Terrabuild Tools +description: Install Terrabuild and configure workspace caching. + +inputs: + terrabuild_version: + description: Terrabuild version to install + default: '0.205.1' + terrabuild_next: + description: Whether to install a Terrabuild prerelease + default: 'false' + terrabuild_workspace: + description: Terrabuild workspace to use + required: true + TERRABUILD_TOKEN: + description: Terrabuild token for authentication + required: false + TERRABUILD_MASTER_KEY: + description: Terrabuild master key for encryption + required: false + +outputs: + connected: + description: Whether shared caching was configured + value: ${{ steps.cache.outputs.connected }} + +runs: + using: composite + steps: + - name: Install Terrabuild + uses: magnusopera/action-install-gh-release@feature/prerelease + with: + repo: magnusopera/terrabuild + tag: ${{ inputs.terrabuild_version }} + prerelease: ${{ inputs.terrabuild_next }} + + - name: Configure Terrabuild caching + id: cache + shell: bash + env: + TERRABUILD_WORKSPACE: ${{ inputs.terrabuild_workspace }} + TERRABUILD_TOKEN: ${{ inputs.TERRABUILD_TOKEN }} + TERRABUILD_MASTER_KEY: ${{ inputs.TERRABUILD_MASTER_KEY }} + run: | + terrabuild version + if [[ -n "$TERRABUILD_TOKEN" && -n "$TERRABUILD_MASTER_KEY" ]]; then + terrabuild login \ + --workspace "$TERRABUILD_WORKSPACE" \ + --token "$TERRABUILD_TOKEN" \ + --masterkey "$TERRABUILD_MASTER_KEY" + echo "connected=true" >> "$GITHUB_OUTPUT" + else + echo "TB_FLAGS=--local-only" >> "$GITHUB_ENV" + echo "connected=false" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/scripts/test-terrabuild.sh b/.github/scripts/test-terrabuild.sh new file mode 100644 index 00000000..c4b27432 --- /dev/null +++ b/.github/scripts/test-terrabuild.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "$0")/../.." && pwd)" +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT +make_command=$(command -v make) +mkdir -p "$fixture/repo/.build" "$fixture/bin" +cp "$root/Makefile" "$fixture/repo/Makefile" +cp "$root/.build/run-terrabuild.sh" "$fixture/repo/.build/" +# Isolate PATH so an installed Terrabuild cannot hide the missing-tool case. +for tool in bash dirname mktemp rm grep; do + ln -s "$(command -v "$tool")" "$fixture/bin/$tool" +done +cat > "$fixture/bin/terrabuild" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" > "$TB_TEST_ARGS" +report= +while [[ $# -gt 0 ]]; do + if [[ "$1" == --out ]]; then report=$2; shift; fi + shift +done +case "$TB_TEST_RESULT" in + success) printf '{"status":"success"}\n' > "$report" ;; + failed-graph) printf '{"status":"failure"}\n' > "$report" ;; + failed-process) printf '{"status":"success"}\n' > "$report"; exit 7 ;; + missing-report) ;; +esac +STUB +cat > "$fixture/bin/dotnet" <<'STUB' +#!/usr/bin/env bash +printf 'called\n' > "$TB_TEST_DOTNET" +exit 1 +STUB +chmod +x "$fixture/bin/terrabuild" "$fixture/bin/dotnet" +export TB_TEST_ARGS="$fixture/args" TB_TEST_DOTNET="$fixture/dotnet-called" + +run_build() { + PATH="$fixture/bin" TB_TEST_RESULT="$1" "$make_command" --no-print-directory -C "$fixture/repo" \ + build config=Debug TB_PARALLEL="${2-2}" TB_FLAGS='--local-only --force' > "$fixture/log" 2>&1 +} + +if ! run_build success; then cat "$fixture/log"; exit 1; fi +cat > "$fixture/expected-args" <<'ARGS' +run +build +--local-only +--force +--variable +config=Debug +--parallel +2 +--out +ARGS +head -n 9 "$fixture/args" > "$fixture/actual-args" +diff -u "$fixture/expected-args" "$fixture/actual-args" +test "$(wc -l < "$fixture/args" | tr -d ' ')" = 10 + +if ! run_build success ''; then cat "$fixture/log"; exit 1; fi +if grep -q -- '--parallel' "$fixture/args"; then + echo "ERROR: make build overrides Terrabuild's default parallelism." >&2 + exit 1 +fi +test "$(wc -l < "$fixture/args" | tr -d ' ')" = 8 + +for result in failed-graph failed-process missing-report; do + if run_build "$result"; then + echo "ERROR: make build accepted $result." >&2 + exit 1 + fi +done +rm "$fixture/bin/terrabuild" +if run_build success; then + echo "ERROR: make build succeeded without Terrabuild on PATH." >&2 + exit 1 +fi +test ! -e "$TB_TEST_DOTNET" +echo "Terrabuild Makefile checks passed." diff --git a/.github/scripts/verify-release-cli.sh b/.github/scripts/verify-release-cli.sh index 27a976b5..4ece4a37 100755 --- a/.github/scripts/verify-release-cli.sh +++ b/.github/scripts/verify-release-cli.sh @@ -20,12 +20,12 @@ case "$reported_version" in esac printf 'Testing released CLI %s\n' "$reported_version" -dotnet build "$repo_root/samples/ffi/FfiSample.fsproj" -c Release +dotnet build "$repo_root/samples/ffi/Provider/FfiSample.fsproj" -c Release "$cli" analyze "$repo_root/samples/ffi/main.fss" mkdir -p "$test_root/source" printf "[runtime]\nextern-assemblies = ['%s']\n" \ - "$repo_root/samples/ffi/bin/Release/net10.0/FScript.Ffi.Sample.dll" > "$test_root/source/fscript.toml" + "$repo_root/samples/ffi/Provider/bin/Release/net10.0/FScript.Ffi.Sample.dll" > "$test_root/source/fscript.toml" printf 'Math.sqrt 81.0\n' > "$test_root/source/check.fss" for target in csharp dotnet; do "$cli" compile "$test_root/source/check.fss" --target "$target" --output "$test_root/$target" diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index ab86a07b..c33a0ffc 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -27,57 +27,32 @@ jobs: - name: Verify changelog release formatting run: make test-release-changelog - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - name: Setup Tools + id: setup-tools + uses: ./.github/actions/setup-tools with: - dotnet-version: 10.0.401 + terrabuild_workspace: "6ae115ba87040d459b6ccf40" + TERRABUILD_TOKEN: ${{ secrets.TERRABUILD_TOKEN }} + TERRABUILD_MASTER_KEY: ${{ secrets.TERRABUILD_MASTER_KEY }} - - name: Install Native AOT toolchain - run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev + - name: Verify Terrabuild Makefile integration + run: make test-terrabuild - - name: Restore - run: dotnet restore FScript.sln + - name: Build, test, smoke-test, and verify SDK compatibility + run: make tb-check - - name: Build - run: dotnet build FScript.sln --configuration Release --no-restore - - - name: Test - run: dotnet test FScript.sln --configuration Release --no-build --logger "trx" --results-directory TestResults - - - name: Verify independently versioned SDK provider - run: make verify-sdk-provider - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - cache-dependency-path: website/package-lock.json - - - name: Test CLI debugger adapter - run: node --test tests/FScript.DebugAdapter.Tests/*Tests.js - - - name: Test VS Code extension - working-directory: vscode-fscript - run: | - npm ci - npm test - - - name: Run Fable JS tests - run: make fable-test - - - name: Build website - run: | - cd website - npm ci - npm run build + - name: Logout from Terrabuild + if: always() && steps.setup-tools.outputs.connected == 'true' + run: terrabuild logout --space "6ae115ba87040d459b6ccf40" - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results-main - path: TestResults/**/*.trx + path: | + **/TestResults/**/*.trx + **/TestResults/**/*.xml if-no-files-found: warn - name: Publish test report @@ -86,5 +61,5 @@ jobs: uses: dorny/test-reporter@v2 with: name: Test Results (Main) - path: TestResults/**/*.trx + path: '**/TestResults/**/*.trx' reporter: dotnet-trx diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index c944a758..b7cbec29 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -33,57 +33,32 @@ jobs: - name: Verify changelog release formatting run: make test-release-changelog - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - name: Setup Tools + id: setup-tools + uses: ./.github/actions/setup-tools with: - dotnet-version: 10.0.401 + terrabuild_workspace: "6ae115ba87040d459b6ccf40" + TERRABUILD_TOKEN: ${{ secrets.TERRABUILD_TOKEN }} + TERRABUILD_MASTER_KEY: ${{ secrets.TERRABUILD_MASTER_KEY }} - - name: Install Native AOT toolchain - run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev + - name: Verify Terrabuild Makefile integration + run: make test-terrabuild - - name: Restore - run: dotnet restore FScript.sln + - name: Build, test, smoke-test, and verify SDK compatibility + run: make tb-check - - name: Build - run: dotnet build FScript.sln --configuration Release --no-restore - - - name: Test - run: dotnet test FScript.sln --configuration Release --no-build --logger "trx" --results-directory TestResults - - - name: Verify independently versioned SDK provider - run: make verify-sdk-provider - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - cache-dependency-path: website/package-lock.json - - - name: Test CLI debugger adapter - run: node --test tests/FScript.DebugAdapter.Tests/*Tests.js - - - name: Test VS Code extension - working-directory: vscode-fscript - run: | - npm ci - npm test - - - name: Run Fable JS tests - run: make fable-test - - - name: Build website - run: | - cd website - npm ci - npm run build + - name: Logout from Terrabuild + if: always() && steps.setup-tools.outputs.connected == 'true' + run: terrabuild logout --space "6ae115ba87040d459b6ccf40" - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results-pr - path: TestResults/**/*.trx + path: | + **/TestResults/**/*.trx + **/TestResults/**/*.xml if-no-files-found: warn - name: Publish test report @@ -92,5 +67,5 @@ jobs: uses: dorny/test-reporter@v2 with: name: Test Results (PR) - path: TestResults/**/*.trx + path: '**/TestResults/**/*.trx' reporter: dotnet-trx diff --git a/.gitignore b/.gitignore index 160ce827..0c75948f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ vscode-fscript/dist/ vscode-fscript/*.vsix src/FScript.JavaScript/dist/ + +# Terrabuild planning/batch files and local diagnostics +.terrabuild/ +terrabuild-debug.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e95562..51b5667f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Add container-backed Terrabuild builds and cached test suites, with separate FFI provider, renderer, and test projects. + ## [0.90.1] - Publish NuGet packages through trusted publishing without a stored API key. diff --git a/FScript.sln b/FScript.sln index 6e66d5c9..2e586719 100644 --- a/FScript.sln +++ b/FScript.sln @@ -35,7 +35,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ffi", "ffi", "{9690432B-97EE-2C44-DC05-BE42F5F4E82F}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FfiSample", "samples\ffi\FfiSample.fsproj", "{337DC273-3133-42EA-B2B8-71E8E56BF17F}" +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FfiSample", "samples\ffi\Provider\FfiSample.fsproj", "{337DC273-3133-42EA-B2B8-71E8E56BF17F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Renderer", "samples\ffi\Renderer\Renderer.csproj", "{F489A79F-CB10-4177-9228-B4B1CD5B7574}" EndProject diff --git a/Makefile b/Makefile index 9d5b49d4..8efef13c 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,8 @@ .PHONY: build test benchmark-interpreters benchmark-front-end benchmark-inference benchmark-evaluation benchmark-dotnet-comparison vscode-debug-smoke fable-build fable-test smoke-tests verify-changelog release-prepare clean publish publish-darwin publish-linux publish-windows pack-nuget publish-all website website-install website-build website-version website-typecheck -build: - dotnet build FScript.sln -c Release +build: tb-build -test: check-changelog-format test-release-changelog +test: check-changelog-format test-release-changelog test-terrabuild dotnet test FScript.sln -c Release node --test tests/FScript.DebugAdapter.Tests/*Tests.js @@ -42,7 +41,7 @@ smoke-tests: echo "Running samples/debugging/main.fss"; \ dotnet run --project src/FScript -- samples/debugging/main.fss > /dev/null; \ echo "Running samples/ffi (headless)"; \ - dotnet build samples/ffi/FfiSample.fsproj -c Release > /dev/null; \ + dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release > /dev/null; \ dotnet run --project src/FScript -- samples/ffi/main.fss -- --headless; \ echo "Analyzing samples/task-sharing"; \ dotnet build samples/task-sharing/TaskSharingSample.fsproj -c Release > /dev/null; \ @@ -120,3 +119,26 @@ check-changelog-format: test-release-changelog: ./.github/scripts/test-release-changelog.sh + +# Container-backed whole-repository checks use Terrabuild from PATH. +.PHONY: tb-build tb-test tb-smoke tb-sdk tb-check test-terrabuild +TB_FLAGS ?= +TB_PARALLEL ?= + +test-terrabuild: + bash .github/scripts/test-terrabuild.sh + +tb-build: + FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh build $(TB_FLAGS) + +tb-test: + FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh test $(TB_FLAGS) + +tb-smoke: + FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh smoke $(TB_FLAGS) + +tb-sdk: + FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh sdk $(TB_FLAGS) + +tb-check: + FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh build test smoke sdk $(TB_FLAGS) diff --git a/WORKSPACE b/WORKSPACE new file mode 100644 index 00000000..149ad2b9 --- /dev/null +++ b/WORKSPACE @@ -0,0 +1,62 @@ +workspace { + id = "6ae115ba87040d459b6ccf40" + engine = ~docker + ignores = [ "**/bin" "**/obj" "**/node_modules" "**/TestResults" + "**/.fscript" "**/.docusaurus" ".out" "out" ".terrabuild" ] +} + +phase toolchains { } +phase application { depends_on = [ phase.toolchains ] } + +variable config { default = "Release" } + +target install { + phase = phase.application + build = ~lazy + artifacts = ~workspace +} + +target build { + batch = ~never + phase = phase.application + artifacts = ~managed + depends_on = [ target.install target.^build ] +} + +target test { + batch = ~never + phase = phase.application + artifacts = ~managed + depends_on = [ target.build ] +} + +target smoke { + phase = phase.application + artifacts = ~managed + depends_on = [ target.build target.^build ] +} + +target sdk { + phase = phase.application + artifacts = ~managed + depends_on = [ target.build target.^build ] +} + +extension @dotnet { + image = "fscript-build:${project.toolchain.version}" + platform = "linux/${terrabuild.arch}" + defaults { configuration = var.config } +} + +extension @npm { + image = "fscript-build:${project.toolchain.version}" + platform = "linux/${terrabuild.arch}" + env { CI = "true" } +} + +extension workflow { + script = ".build/workflow.fss" + image = "fscript-build:${project.toolchain.version}" + platform = "linux/${terrabuild.arch}" + env { FSCRIPT_BUILD_CONFIG = var.config } +} diff --git a/benchmarks/FScript.DotNet.Comparison/PROJECT b/benchmarks/FScript.DotNet.Comparison/PROJECT new file mode 100644 index 00000000..65905a5b --- /dev/null +++ b/benchmarks/FScript.DotNet.Comparison/PROJECT @@ -0,0 +1,9 @@ +project fscript_dotnet_comparison { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/benchmarks/FScript.DotNet.Reference/PROJECT b/benchmarks/FScript.DotNet.Reference/PROJECT new file mode 100644 index 00000000..c314323a --- /dev/null +++ b/benchmarks/FScript.DotNet.Reference/PROJECT @@ -0,0 +1,9 @@ +project fscript_dotnet_reference { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/benchmarks/FScript.Evaluation.Benchmarks/PROJECT b/benchmarks/FScript.Evaluation.Benchmarks/PROJECT new file mode 100644 index 00000000..39fe014e --- /dev/null +++ b/benchmarks/FScript.Evaluation.Benchmarks/PROJECT @@ -0,0 +1,9 @@ +project fscript_evaluation_benchmarks { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/benchmarks/FScript.FrontEnd.Benchmarks/PROJECT b/benchmarks/FScript.FrontEnd.Benchmarks/PROJECT new file mode 100644 index 00000000..ba806631 --- /dev/null +++ b/benchmarks/FScript.FrontEnd.Benchmarks/PROJECT @@ -0,0 +1,9 @@ +project fscript_frontend_benchmarks { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/benchmarks/FScript.Inference.Benchmarks/PROJECT b/benchmarks/FScript.Inference.Benchmarks/PROJECT new file mode 100644 index 00000000..74b93f8a --- /dev/null +++ b/benchmarks/FScript.Inference.Benchmarks/PROJECT @@ -0,0 +1,9 @@ +project fscript_inference_benchmarks { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/benchmarks/FScript.Interpreter.Benchmarks/PROJECT b/benchmarks/FScript.Interpreter.Benchmarks/PROJECT new file mode 100644 index 00000000..ffa561e9 --- /dev/null +++ b/benchmarks/FScript.Interpreter.Benchmarks/PROJECT @@ -0,0 +1,9 @@ +project fscript_interpreter_benchmarks { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/docs/README.md b/docs/README.md index d15f6ab6..c7daa1d5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,10 @@ This folder is organized into three layers: - FScript vs F# / OCaml: [`guides/fsharp-ocaml-differences.md`](./guides/fsharp-ocaml-differences.md) - Language rationale: [`guides/language-choices.md`](./guides/language-choices.md) +## Build and test + +- [Container-backed Terrabuild workflow](./guides/terrabuild.md) + ## Compilation - [Choose a compilation target](./guides/compilation.md) diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index da7fee9f..3914922e 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -214,7 +214,7 @@ The lexer keeps `export` as an identifier. At a top-level statement boundary, th ## FFI graphics sample -`samples/ffi` separates script-owned geometry and scene decisions from the native renderer. Its F# provider library references the independent Extensibility SDK and a small C# GLFW/OpenGL backend; the backend has no Language or Runtime dependency. `teapot.fss` tessellates Bézier control points and supplies vertices/normals/indices; `scene.fss` supplies camera, light, material, and animation. `main.fss` starts execution through callback-based `Graphics.run`/`Graphics.runHeadless` externs; the provider invokes setup and frame closures through `ExternalCallContext.Apply` on the window thread. Static compiler companions instead bind typed CLR calls and unary setup/render delegates, allowing the same script to compile to Native AOT without SDK dispatch. The renderer calls its small set of GLFW/OpenGL entry points through unmanaged function pointers. GLFW runtime binaries and their license are embedded in the renderer assembly and extracted by content hash only on graphical startup; no managed assembly path or dynamic delegate generation is required. Geometry uses array-backed native records while preserving script list semantics. No sample executable project is required. Sample tests use a recording backend and require no display; graphical checks remain explicit. These sample-only dependencies do not enter the CLI, Runtime package, or JavaScript build. +`samples/ffi` separates script-owned geometry and scene decisions from the native renderer. Its F# provider library lives in `samples/ffi/Provider`, beside `Renderer` and `Tests`, so Terrabuild can discover each project separately. It references the independent Extensibility SDK and a small C# GLFW/OpenGL backend; the backend has no Language or Runtime dependency. `teapot.fss` tessellates Bézier control points and supplies vertices/normals/indices; `scene.fss` supplies camera, light, material, and animation. `main.fss` starts execution through callback-based `Graphics.run`/`Graphics.runHeadless` externs; the provider invokes setup and frame closures through `ExternalCallContext.Apply` on the window thread. Static compiler companions instead bind typed CLR calls and unary setup/render delegates, allowing the same script to compile to Native AOT without SDK dispatch. The renderer calls its small set of GLFW/OpenGL entry points through unmanaged function pointers. GLFW runtime binaries and their license are embedded in the renderer assembly and extracted by content hash only on graphical startup; no managed assembly path or dynamic delegate generation is required. Geometry uses array-backed native records while preserving script list semantics. No sample executable project is required. Sample tests use a recording backend and require no display; graphical checks remain explicit. These sample-only dependencies do not enter the CLI, Runtime package, or JavaScript build. The macOS release entitlement permits external native dependency signatures for explicitly loaded providers. The graphics backend resolves its bundled native assets relative to its provider assembly; the CLI installation directory and system search paths are not modified. @@ -264,3 +264,13 @@ the synchronous continuation driver and contains no debugger API. See the ## Interpreter host policy Language owns the permission enum, native descriptor requirements, registration grants, evaluator checks, and propagation through task/callback contexts and retained states. Runtime classifies standard externs, filters host registries, retains policy on loaded scripts, and gates provider loading. The Extensibility SDK retains its stable record layout: Runtime associates forwarded default implementations with their original requirements using weak implementation identity metadata; unknown SDK implementations require Native. Forwarding delegates also check the original requirement at execution, so wrapping or renaming cannot remove the check. Provider code cannot choose grants. CLI/analysis entry points explicitly authorize all capabilities; compiler inference and generated programs do not enforce the interpreter policy. Terrabuild supplies IO and Threading without Interactive or Native. + +## Container-backed build graph + +`make build` delegates to the Terrabuild executable on `PATH` through `.build/run-terrabuild.sh`, which checks both the process exit code and the JSON completion status. Missing Terrabuild or a failed graph fails the build without falling back to the host .NET SDK. + +GitHub PR and main workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then run the build, test, smoke, and SDK targets through `make tb-check`. Runs without cache credentials use local caching. + +The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. A toolchain phase creates the local image with the pinned .NET SDK, Node, Clang, and zlib headers. The image bootstrap uses Docker on the host and never publishes an image. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. + +Build outputs and test reports are declared per target. Tests depend on their build outputs and use `--no-build --no-restore`; build actions avoid rebuilding project references already represented by graph dependencies. The independent SDK check copies current source to a temporary directory before publishing different product versions, avoiding changes to outputs used by concurrent tests. Native AOT integration tests remain part of the normal graph. Graph execution and local cache reuse need no Insights account; shared caching is configured separately. See [Terrabuild workflow](../guides/terrabuild.md). diff --git a/docs/guides/compile-to-csharp.md b/docs/guides/compile-to-csharp.md index 3c512483..7af5f73e 100644 --- a/docs/guides/compile-to-csharp.md +++ b/docs/guides/compile-to-csharp.md @@ -55,7 +55,7 @@ Manifest assembly paths resolve from the manifest directory. `--config ` c For the teapot sample, build the provider first and use its existing manifest: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release fscript analyze samples/ffi/main.fss fscript compile samples/ffi/main.fss --target csharp --output out/ffi-csharp ``` diff --git a/docs/guides/compile-to-dotnet.md b/docs/guides/compile-to-dotnet.md index 92517711..5833aff5 100644 --- a/docs/guides/compile-to-dotnet.md +++ b/docs/guides/compile-to-dotnet.md @@ -86,13 +86,13 @@ For the teapot example, `samples/ffi/fscript.toml` supplies the provider path: ```toml [runtime] -extern-assemblies = ["bin/Release/net10.0/FScript.Ffi.Sample.dll"] +extern-assemblies = ["Provider/bin/Release/net10.0/FScript.Ffi.Sample.dll"] ``` Build the provider first, then analyze or compile without extra assembly flags: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release dotnet run --project src/FScript -- analyze samples/ffi/main.fss dotnet run --project src/FScript -- compile samples/ffi/main.fss --target dotnet --output out/graphics-dotnet dotnet out/graphics-dotnet/main.dll --headless diff --git a/docs/guides/compiler-cli.md b/docs/guides/compiler-cli.md index 093864be..900762dc 100644 --- a/docs/guides/compiler-cli.md +++ b/docs/guides/compiler-cli.md @@ -24,7 +24,7 @@ For example, `samples/ffi/fscript.toml` contains: ```toml [runtime] -extern-assemblies = ["bin/Release/net10.0/FScript.Ffi.Sample.dll"] +extern-assemblies = ["Provider/bin/Release/net10.0/FScript.Ffi.Sample.dll"] ``` Assembly paths in the manifest resolve from the manifest directory. Local provider projects must be built before either command loads their configured DLLs. For C# and .NET output, compilation automatically references the providers and their colocated managed dependencies; `--reference` adds other managed assemblies when needed. @@ -43,7 +43,7 @@ fscript compile program.fss --target dotnet --output out/program --no-config After building the teapot provider, both commands work with its automatically discovered configuration: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release fscript analyze samples/ffi/main.fss fscript compile samples/ffi/main.fss --target dotnet --output out/ffi dotnet out/ffi/main.dll --headless diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md new file mode 100644 index 00000000..08b11e7a --- /dev/null +++ b/docs/guides/terrabuild.md @@ -0,0 +1,48 @@ +# Container-backed builds with Terrabuild + +Install Terrabuild 0.205.1 and a working Docker engine. The graph builds its toolchain image automatically. .NET, Node, and Clang are supplied by that image; they need not be installed on the host for these targets. + +```sh +# Build every .NET project, the JavaScript package, extension bundle, and website. +make build TB_FLAGS=--local-only + +# Run all .NET, debugger-adapter, JavaScript, extension, and website type checks. +make tb-test TB_FLAGS=--local-only + +# Run everything, including sample smoke tests and the independent SDK check. +make tb-check TB_FLAGS=--local-only +``` + +`make build` always uses `terrabuild` from `PATH` through the same wrapper as `tb-build`; it fails if Terrabuild is unavailable. There is no host `dotnet build` fallback. For an explicit host build, run `dotnet build FScript.sln -c Release`. + +`tb-test` builds its prerequisites automatically. `tb-smoke` and `tb-sdk` select just those checks and their prerequisites. `config` defaults to Release; Terrabuild defaults to one concurrent task per processor; set `TB_PARALLEL` to override that limit. Existing `test` and release recipes remain available, and targets that depend on `build` now require Terrabuild and Docker. The full test graph currently expects Release because existing CLI integration tests launch Release binaries. + +The underlying command is: + +```sh +terrabuild run build test smoke sdk --local-only +``` + +The Make targets check Terrabuild's JSON completion status as well as its process exit code. Use them in automation with Terrabuild 0.200.1, which can return zero after reporting failed tasks. A missing or unsuccessful completion report fails the Make target. + +## Containers and project layout + +The toolchain phase builds `.build/toolchain/Dockerfile` with .NET SDK 10.0.401, Node 22, Clang, and zlib headers. Only this Docker image bootstrap runs on the host. `@dotnet`, `@npm`, and the custom `workflow` extension all specify the toolchain image and an explicit Linux architecture. No registry push or deployment occurs. Persistent MSBuild nodes and shared compiler servers are disabled in the image because CLI tests capture subprocess output; retained server processes can keep those pipes open after a command exits. + +Terrabuild stops discovery at a `PROJECT` file. The FFI sample therefore keeps scripts and `fscript.toml` at `samples/ffi`, with sibling `Provider`, `Renderer`, and `Tests` projects below it. Build the provider directly with `dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release` when using the existing host workflow. + +Project-reference dependencies build before their consumers. Tests run already-built binaries with `--no-build --no-restore`. Conditional sibling-SDK project references are declared explicitly in the graph; ordinary builds use the published SDK package. Non-.NET checks declare their inputs and upstream builds too. The SDK compatibility check runs in a temporary source copy because it deliberately publishes different assembly versions. + +Builds use the normal `bin`, `obj`, `node_modules`, and generated website directories on the mounted checkout. They contain Linux outputs after a container run. Rebuild through the host toolchain before relying on native host apphosts or platform-specific Node dependencies. Avoid running host and container builds concurrently in the same checkout. + +## Local and shared caching + +The workspace ID is configured in `WORKSPACE`. Local caching works without an Insights account; `--local-only` disables shared caching. Omit `TB_FLAGS=--local-only` after connecting the workspace to allow shared reuse. Do not put tokens or master keys in repository files. + +The PR and main GitHub workflows share `.github/actions/setup-tools` to install Terrabuild 0.205.1 and configure authentication. They run `make tb-check`, covering builds, all test targets, sample smoke tests, and independent SDK compatibility. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching. Test reports are collected from each project's `TestResults` directory. + +Managed build outputs and successful test results are cacheable separately. npm dependencies are kept in the local workspace cache. When application work must execute, the toolchain bootstrap checks the image using Docker layer caching. Fully cached requests skip execution; the phase barrier does not force application cache hits to rebuild. The configured Linux architecture separates ARM64 and AMD64 artifacts. + +Use `terrabuild explain build test --local-only` to inspect cache decisions. Repeating `make tb-check TB_FLAGS=--local-only` should reuse successful unchanged work. Use `TB_FLAGS='--local-only --force'` to execute deliberately, or `--retry` to rerun a failed cached task after fixing its environment. + +Cached test success is not a fresh execution. Force a run when investigating a flaky test or an external dependency. Deleting generated output and repeating a fully cached request is not a restoration test: Terrabuild restores prerequisite files when an executing consumer needs them. diff --git a/samples/ffi/FfiSample.fsproj b/samples/ffi/Provider/FfiSample.fsproj similarity index 77% rename from samples/ffi/FfiSample.fsproj rename to samples/ffi/Provider/FfiSample.fsproj index d59ff0ec..47c789ce 100644 --- a/samples/ffi/FfiSample.fsproj +++ b/samples/ffi/Provider/FfiSample.fsproj @@ -2,10 +2,10 @@ FScript.Ffi.Sampletrue - + - + diff --git a/samples/ffi/Graphics.fs b/samples/ffi/Provider/Graphics.fs similarity index 100% rename from samples/ffi/Graphics.fs rename to samples/ffi/Provider/Graphics.fs diff --git a/samples/ffi/Provider/PROJECT b/samples/ffi/Provider/PROJECT new file mode 100644 index 00000000..924520ef --- /dev/null +++ b/samples/ffi/Provider/PROJECT @@ -0,0 +1,10 @@ +project ffi_sample { + outputs = [ "bin/" "obj/" ] + depends_on = [ project.renderer ] + includes = [ "**/*" "../../../Directory.Build.props" "../*.fss" "../fscript.toml" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" ] +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/samples/ffi/README.md b/samples/ffi/README.md index 9f207277..ec1a113e 100644 --- a/samples/ffi/README.md +++ b/samples/ffi/README.md @@ -7,7 +7,7 @@ FScript builds a Utah teapot from 32 bicubic Bézier patches, computes surface n From the repository root, build the provider library once with the .NET 10 SDK: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release ``` Then use the installed FScript CLI (0.81.5 or later): @@ -77,7 +77,7 @@ On macOS ARM64 with SDK 10.0.403, the original 4,378,616-byte executable shrank 1. Read `scene.fss`: ordinary record literals create the camera, light, material, and rotation. `initialize` generates and uploads a mesh; `render` changes its rotation each frame. 2. Read `teapot.fss`: the control points and patch connectivity are script data. Cubic Bézier evaluation, derivatives, cross products, normalized normals, and indexed tessellation all run in FScript. Change `Teapot.generate 8` in the scene to change the resolution (clamped to 2–32 segments per patch edge). Re-run the host after editing; this sample does not hot reload. -3. Read `Graphics.fs`: attributes expose real F# records as script types. `HostTypes.describe` creates descriptors, and `HostFunctions.create` derives function schemes, arity, and conversions from typed handlers. +3. Read `Provider/Graphics.fs`: attributes expose real F# records as script types. `HostTypes.describe` creates descriptors, and `HostFunctions.create` derives function schemes, arity, and conversions from typed handlers. 4. Read `main.fss`: the entry script calls `Graphics.run` with a setup callback. That callback uploads the mesh and returns a render closure retaining it. The provider pumps native events and invokes the closure until the window closes. Interpreted execution uses `ExternalCallContext.Apply`; compiled execution calls typed delegates. 5. Read `Renderer/`: the C# backend contains shaders, view/projection matrix construction, GPU buffers, and window/input handling. It contains no teapot model or scene choices. diff --git a/samples/ffi/Renderer/PROJECT b/samples/ffi/Renderer/PROJECT new file mode 100644 index 00000000..7eccaf4d --- /dev/null +++ b/samples/ffi/Renderer/PROJECT @@ -0,0 +1,8 @@ +project renderer { + includes = [ "**/*" "../../../Directory.Build.props" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/samples/ffi/Tests/FfiSample.Tests.fsproj b/samples/ffi/Tests/FfiSample.Tests.fsproj index cc4fd885..0dd32894 100644 --- a/samples/ffi/Tests/FfiSample.Tests.fsproj +++ b/samples/ffi/Tests/FfiSample.Tests.fsproj @@ -6,5 +6,5 @@ - + diff --git a/samples/ffi/Tests/PROJECT b/samples/ffi/Tests/PROJECT new file mode 100644 index 00000000..d9e0fd7a --- /dev/null +++ b/samples/ffi/Tests/PROJECT @@ -0,0 +1,14 @@ +project ffisample_tests { + includes = [ "**/*" "../../../Directory.Build.props" "../../../tests/FScript.Runtime.Tests/SdkTestHost.fs" "../*.fss" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/samples/ffi/fscript.toml b/samples/ffi/fscript.toml index e3eb7f6d..b54b603b 100644 --- a/samples/ffi/fscript.toml +++ b/samples/ffi/fscript.toml @@ -1,7 +1,7 @@ -# Build first: dotnet build samples/ffi/FfiSample.fsproj -c Release +# Build first: dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release # Select this configuration in VS Code to get Graphics/Math functions and host types. [runtime] -extern-assemblies = ["bin/Release/net10.0/FScript.Ffi.Sample.dll"] +extern-assemblies = ["Provider/bin/Release/net10.0/FScript.Ffi.Sample.dll"] # fscript compile main.fss uses these defaults; explicit CLI options override them. [compile] diff --git a/samples/task-sharing/PROJECT b/samples/task-sharing/PROJECT new file mode 100644 index 00000000..a615ea5d --- /dev/null +++ b/samples/task-sharing/PROJECT @@ -0,0 +1,10 @@ +project task_sharing { + # Conditional sibling SDK references resolve through NuGet in this workspace. + outputs = [ "bin/" "obj/" ] + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.Backend.CSharp/PROJECT b/src/FScript.Backend.CSharp/PROJECT new file mode 100644 index 00000000..bb0a0d17 --- /dev/null +++ b/src/FScript.Backend.CSharp/PROJECT @@ -0,0 +1,9 @@ +project fscript_backend_csharp { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.Backend.DotNet/PROJECT b/src/FScript.Backend.DotNet/PROJECT new file mode 100644 index 00000000..4a691bf0 --- /dev/null +++ b/src/FScript.Backend.DotNet/PROJECT @@ -0,0 +1,9 @@ +project fscript_backend_dotnet { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.CSharpInterop/PROJECT b/src/FScript.CSharpInterop/PROJECT new file mode 100644 index 00000000..77fd2fc9 --- /dev/null +++ b/src/FScript.CSharpInterop/PROJECT @@ -0,0 +1,9 @@ +project fscript_csharpinterop { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.Compiler/PROJECT b/src/FScript.Compiler/PROJECT new file mode 100644 index 00000000..fc60aae9 --- /dev/null +++ b/src/FScript.Compiler/PROJECT @@ -0,0 +1,9 @@ +project fscript_compiler { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.DebugAdapter/PROJECT b/src/FScript.DebugAdapter/PROJECT new file mode 100644 index 00000000..924e84a4 --- /dev/null +++ b/src/FScript.DebugAdapter/PROJECT @@ -0,0 +1,9 @@ +project fscript_debugadapter { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.Interpreter.Lir/PROJECT b/src/FScript.Interpreter.Lir/PROJECT new file mode 100644 index 00000000..fe0d5fbc --- /dev/null +++ b/src/FScript.Interpreter.Lir/PROJECT @@ -0,0 +1,9 @@ +project fscript_interpreter_lir { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.JavaScript/PROJECT b/src/FScript.JavaScript/PROJECT new file mode 100644 index 00000000..77aa63c0 --- /dev/null +++ b/src/FScript.JavaScript/PROJECT @@ -0,0 +1,17 @@ +project javascript { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "dist/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + outputs = [ "bin/" "obj/" "dist/" ] + @dotnet build { dependencies = false } + @dotnet tool { args = "restore" } + @npm build { } +} + +target test { + outputs = [] + @npm test { } +} diff --git a/src/FScript.Language/PROJECT b/src/FScript.Language/PROJECT new file mode 100644 index 00000000..c864a842 --- /dev/null +++ b/src/FScript.Language/PROJECT @@ -0,0 +1,9 @@ +project fscript_language { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.LanguageServer/PROJECT b/src/FScript.LanguageServer/PROJECT new file mode 100644 index 00000000..aca56335 --- /dev/null +++ b/src/FScript.LanguageServer/PROJECT @@ -0,0 +1,9 @@ +project fscript_languageserver { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.Runtime/PROJECT b/src/FScript.Runtime/PROJECT new file mode 100644 index 00000000..d4dab435 --- /dev/null +++ b/src/FScript.Runtime/PROJECT @@ -0,0 +1,11 @@ +project fscript_runtime { + # Conditional sibling SDK references resolve through NuGet in this workspace. + outputs = [ "bin/" "obj/" ] + depends_on = [ project.fscript_language ] + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript/PROJECT b/src/FScript/PROJECT new file mode 100644 index 00000000..1e506908 --- /dev/null +++ b/src/FScript/PROJECT @@ -0,0 +1,9 @@ +project fscript { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/tests/FScript.Backend.DotNet.Tests/PROJECT b/tests/FScript.Backend.DotNet.Tests/PROJECT new file mode 100644 index 00000000..e99b3200 --- /dev/null +++ b/tests/FScript.Backend.DotNet.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_backend_dotnet_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.Cli.Tests.Fixtures.Conflict/PROJECT b/tests/FScript.Cli.Tests.Fixtures.Conflict/PROJECT new file mode 100644 index 00000000..fa6563b6 --- /dev/null +++ b/tests/FScript.Cli.Tests.Fixtures.Conflict/PROJECT @@ -0,0 +1,10 @@ +project fscript_cli_tests_fixtures_conflict { + # Conditional sibling SDK references resolve through NuGet in this workspace. + outputs = [ "bin/" "obj/" ] + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/tests/FScript.Cli.Tests.Fixtures.Consumer/PROJECT b/tests/FScript.Cli.Tests.Fixtures.Consumer/PROJECT new file mode 100644 index 00000000..deb4e74a --- /dev/null +++ b/tests/FScript.Cli.Tests.Fixtures.Consumer/PROJECT @@ -0,0 +1,9 @@ +project fscript_cli_tests_fixtures_consumer { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/tests/FScript.Cli.Tests.Fixtures.Invalid/PROJECT b/tests/FScript.Cli.Tests.Fixtures.Invalid/PROJECT new file mode 100644 index 00000000..b1aa3b51 --- /dev/null +++ b/tests/FScript.Cli.Tests.Fixtures.Invalid/PROJECT @@ -0,0 +1,10 @@ +project fscript_cli_tests_fixtures_invalid { + # Conditional sibling SDK references resolve through NuGet in this workspace. + outputs = [ "bin/" "obj/" ] + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/tests/FScript.Cli.Tests.Fixtures.Valid/PROJECT b/tests/FScript.Cli.Tests.Fixtures.Valid/PROJECT new file mode 100644 index 00000000..b5c69f3d --- /dev/null +++ b/tests/FScript.Cli.Tests.Fixtures.Valid/PROJECT @@ -0,0 +1,10 @@ +project fscript_cli_tests_fixtures_valid { + # Conditional sibling SDK references resolve through NuGet in this workspace. + outputs = [ "bin/" "obj/" ] + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/tests/FScript.Cli.Tests/PROJECT b/tests/FScript.Cli.Tests/PROJECT new file mode 100644 index 00000000..37bc0a95 --- /dev/null +++ b/tests/FScript.Cli.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_cli_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.Compiler.Tests/PROJECT b/tests/FScript.Compiler.Tests/PROJECT new file mode 100644 index 00000000..76d1b28c --- /dev/null +++ b/tests/FScript.Compiler.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_compiler_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.Conformance.Tests/PROJECT b/tests/FScript.Conformance.Tests/PROJECT new file mode 100644 index 00000000..8e568c2d --- /dev/null +++ b/tests/FScript.Conformance.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_conformance_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.DebugAdapter.Tests/PROJECT b/tests/FScript.DebugAdapter.Tests/PROJECT new file mode 100644 index 00000000..ad9dbd10 --- /dev/null +++ b/tests/FScript.DebugAdapter.Tests/PROJECT @@ -0,0 +1,26 @@ +project checks { + depends_on = [ project.fscript project.ffi_sample project.task_sharing project.fscript_cli_tests_fixtures_valid ] + includes = [ "**/*" "../../.build/checks.sh" "../../.build/sdk-check.sh" + "../../.github/scripts/verify-sdk-provider.sh" + "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" + "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" + "**/bin/**" "**/obj/**" "**/TestResults/**" + "**/node_modules/**" "**/.fscript/**" ] +} + +target test { + depends_on = [ target.^build ] + outputs = [ "TestResults/debugger.xml" ] + workflow run { script = "../../.build/checks.sh" args = "debugger" } +} + +target smoke { + outputs = [] + workflow run { script = "../../.build/checks.sh" args = "smoke" } +} + +target sdk { + outputs = [] + workflow run { script = "../../.build/sdk-check.sh" } +} diff --git a/tests/FScript.Interpreter.Lir.Tests/PROJECT b/tests/FScript.Interpreter.Lir.Tests/PROJECT new file mode 100644 index 00000000..e91efa69 --- /dev/null +++ b/tests/FScript.Interpreter.Lir.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_interpreter_lir_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.Language.Tests/PROJECT b/tests/FScript.Language.Tests/PROJECT new file mode 100644 index 00000000..ecc4fa2b --- /dev/null +++ b/tests/FScript.Language.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_language_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.LanguageServer.Tests/PROJECT b/tests/FScript.LanguageServer.Tests/PROJECT new file mode 100644 index 00000000..e9eeb797 --- /dev/null +++ b/tests/FScript.LanguageServer.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_languageserver_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.Runtime.Tests/PROJECT b/tests/FScript.Runtime.Tests/PROJECT new file mode 100644 index 00000000..c7d43a51 --- /dev/null +++ b/tests/FScript.Runtime.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_runtime_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/vscode-fscript/PROJECT b/vscode-fscript/PROJECT new file mode 100644 index 00000000..0aa443d8 --- /dev/null +++ b/vscode-fscript/PROJECT @@ -0,0 +1,20 @@ +project vscode { + includes = [ "**/*" ] + ignores = [ "node_modules/**" "dist/**" "server/**" "*.vsix" ] + @npm { } +} + +target install { + outputs = [ "node_modules/" ] + @npm install { } +} + +target build { + outputs = [ "dist/" ] + @npm run { target = "compile" } +} + +target test { + outputs = [] + @npm test { } +} diff --git a/website/PROJECT b/website/PROJECT new file mode 100644 index 00000000..892ab500 --- /dev/null +++ b/website/PROJECT @@ -0,0 +1,21 @@ +project website { + depends_on = [ project.javascript ] + includes = [ "**/*" "../.config/dotnet-tools.json" "../CHANGELOG.md" ] + ignores = [ "node_modules/**" "build/**" ".docusaurus/**" "src/generated/**" ] + @npm { } +} + +target install { + outputs = [ "node_modules/" ] + @npm install { } +} + +target build { + outputs = [ "build/" "src/generated/" ] + @npm build { } +} + +target test { + outputs = [] + @npm run { target = "typecheck" } +} diff --git a/website/docs/compiler/compile-to-csharp.md b/website/docs/compiler/compile-to-csharp.md index 5d9aa082..1c640711 100644 --- a/website/docs/compiler/compile-to-csharp.md +++ b/website/docs/compiler/compile-to-csharp.md @@ -59,7 +59,7 @@ Manifest assembly paths resolve from the manifest directory. `--config ` c For the teapot sample, build the provider first and use its existing manifest: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release fscript analyze samples/ffi/main.fss fscript compile samples/ffi/main.fss --target csharp --output out/ffi-csharp ``` diff --git a/website/docs/compiler/compile-to-dotnet.md b/website/docs/compiler/compile-to-dotnet.md index 65439e27..a615f644 100644 --- a/website/docs/compiler/compile-to-dotnet.md +++ b/website/docs/compiler/compile-to-dotnet.md @@ -143,13 +143,13 @@ For the teapot example, `samples/ffi/fscript.toml` supplies the provider path: ```toml [runtime] -extern-assemblies = ["bin/Release/net10.0/FScript.Ffi.Sample.dll"] +extern-assemblies = ["Provider/bin/Release/net10.0/FScript.Ffi.Sample.dll"] ``` Build the provider first, then analyze or compile without extra assembly flags: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release dotnet run --project src/FScript -- analyze samples/ffi/main.fss dotnet run --project src/FScript -- compile samples/ffi/main.fss --target dotnet --no-aot --output out/graphics-dotnet dotnet out/graphics-dotnet/main.dll --headless diff --git a/website/docs/examples/ffi-graphics.md b/website/docs/examples/ffi-graphics.md index 15a2f9ad..0cec4ffa 100644 --- a/website/docs/examples/ffi-graphics.md +++ b/website/docs/examples/ffi-graphics.md @@ -25,7 +25,7 @@ Execution starts in `main.fss`, which loads the scene and starts rendering throu Check out the [FScript repository](https://github.com/MagnusOpera/FScript) and build the provider library with the .NET 10 SDK before the first run: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release ``` This compiles the FFI library used by `fscript`. Rebuild it when changing provider code; script edits only require running the `fscript` command again. Version 0.81.3 includes the macOS signing entitlement required to load GLFW. @@ -77,7 +77,7 @@ All sample code lives under [`samples/ffi`](https://github.com/MagnusOpera/FScri | --- | --- | | `scene.fss` | Chooses scene data, uploads the generated mesh, and animates it. | | `teapot.fss` | Holds the Bézier patch data and generates vertices, normals, and triangle indices. | -| `Graphics.fs` | Declares the script-visible types and typed FFI functions. | +| `Provider/Graphics.fs` | Declares the script-visible types and typed FFI functions. | | `main.fss` | Starts the window loop and returns a render closure that retains the mesh. | | `Renderer/` | Implements the native window, GPU buffers, shaders, and rendering. | | `fscript.toml` | Makes the provider available to CLI and editor analysis. | @@ -176,7 +176,7 @@ The sample supplies `Math.toFloat`, `Math.sqrt`, and `Math.sin` as typed extern ## 3. Expose data and resources as host types -`Graphics.fs` uses attributes on real F# types. Here are two representative definitions: +`Provider/Graphics.fs` uses attributes on real F# types. Here are two representative definitions: ```fsharp [] @@ -212,7 +212,7 @@ A `Window` descriptor instead has `HostManaged = true` and only a visible `Title ## 4. Turn typed handlers into function descriptors -The upload handler has the concrete F# type `Window -> Geometry -> Mesh`. It validates geometry, uploads it through `window.Backend`, and returns a mesh retaining that backend and its resource handle. The complete implementation is in `Graphics.fs`. +The upload handler has the concrete F# type `Window -> Geometry -> Mesh`. It validates geometry, uploads it through `window.Backend`, and returns a mesh retaining that backend and its resource handle. The complete implementation is in `Provider/Graphics.fs`. Register it with a registration that already contains the referenced type descriptors: @@ -249,7 +249,7 @@ Build the sample in Release, then run **FScript: Select Configuration** in VS Co ```toml [runtime] -extern-assemblies = ["bin/Release/net10.0/FScript.Ffi.Sample.dll"] +extern-assemblies = ["Provider/bin/Release/net10.0/FScript.Ffi.Sample.dll"] ``` The path is relative to the manifest. In a trusted workspace, the editor loads the provider's descriptors and schemes for completion, hover, and diagnostics. Provider discovery creates no window or GPU resources. If several manifests exist in the workspace, select this one explicitly. From 683cd1f4b359bdd0c61385698dba03a9eefc82ce Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 15:22:14 +0100 Subject: [PATCH 02/17] ci: run Terrabuild checks on every branch push --- .github/workflows/ci-main.yml | 8 ++++---- CHANGELOG.md | 1 + docs/architecture/assemblies-and-roles.md | 2 +- docs/guides/terrabuild.md | 4 +++- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index c33a0ffc..d136e053 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -1,9 +1,9 @@ -name: CI Main +name: CI Branches on: push: branches: - - main + - '**' workflow_dispatch: permissions: @@ -49,7 +49,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: test-results-main + name: test-results-branch path: | **/TestResults/**/*.trx **/TestResults/**/*.xml @@ -60,6 +60,6 @@ jobs: continue-on-error: true uses: dorny/test-reporter@v2 with: - name: Test Results (Main) + name: Test Results (Branch) path: '**/TestResults/**/*.trx' reporter: dotnet-trx diff --git a/CHANGELOG.md b/CHANGELOG.md index 51b5667f..857a05ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Run Terrabuild CI on pushes to every branch. - Add container-backed Terrabuild builds and cached test suites, with separate FFI provider, renderer, and test projects. ## [0.90.1] diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index 3914922e..3e43025a 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -269,7 +269,7 @@ Language owns the permission enum, native descriptor requirements, registration `make build` delegates to the Terrabuild executable on `PATH` through `.build/run-terrabuild.sh`, which checks both the process exit code and the JSON completion status. Missing Terrabuild or a failed graph fails the build without falling back to the host .NET SDK. -GitHub PR and main workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then run the build, test, smoke, and SDK targets through `make tb-check`. Runs without cache credentials use local caching. +GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then run the build, test, smoke, and SDK targets through `make tb-check`. Runs without cache credentials use local caching. The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. A toolchain phase creates the local image with the pinned .NET SDK, Node, Clang, and zlib headers. The image bootstrap uses Docker on the host and never publishes an image. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index 08b11e7a..7e36fd4b 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -39,10 +39,12 @@ Builds use the normal `bin`, `obj`, `node_modules`, and generated website direct The workspace ID is configured in `WORKSPACE`. Local caching works without an Insights account; `--local-only` disables shared caching. Omit `TB_FLAGS=--local-only` after connecting the workspace to allow shared reuse. Do not put tokens or master keys in repository files. -The PR and main GitHub workflows share `.github/actions/setup-tools` to install Terrabuild 0.205.1 and configure authentication. They run `make tb-check`, covering builds, all test targets, sample smoke tests, and independent SDK compatibility. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching. Test reports are collected from each project's `TestResults` directory. +The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install Terrabuild 0.205.1 and configure authentication. They run `make tb-check`, covering builds, all test targets, sample smoke tests, and independent SDK compatibility. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching. Test reports are collected from each project's `TestResults` directory. Managed build outputs and successful test results are cacheable separately. npm dependencies are kept in the local workspace cache. When application work must execute, the toolchain bootstrap checks the image using Docker layer caching. Fully cached requests skip execution; the phase barrier does not force application cache hits to rebuild. The configured Linux architecture separates ARM64 and AMD64 artifacts. Use `terrabuild explain build test --local-only` to inspect cache decisions. Repeating `make tb-check TB_FLAGS=--local-only` should reuse successful unchanged work. Use `TB_FLAGS='--local-only --force'` to execute deliberately, or `--retry` to rerun a failed cached task after fixing its environment. Cached test success is not a fresh execution. Force a run when investigating a flaky test or an external dependency. Deleting generated output and repeating a fully cached request is not a restoration test: Terrabuild restores prerequisite files when an executing consumer needs them. + +`ci-main.yml` runs on pushes to every branch, including names containing `/`, and supports manual dispatch. Tag pushes remain handled by the release workflows. `ci-pr.yml` separately validates pull requests targeting `main`, so a push to an open PR triggers both branch and PR CI. From e6b598fdb11fd97c65afd8fde93da2a4be2d647a Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 16:05:16 +0100 Subject: [PATCH 03/17] ci: use the latest stable Terrabuild release --- .github/actions/setup-tools/action.yml | 2 +- CHANGELOG.md | 1 + docs/guides/terrabuild.md | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml index ef5cd76c..7c25a13b 100644 --- a/.github/actions/setup-tools/action.yml +++ b/.github/actions/setup-tools/action.yml @@ -4,7 +4,7 @@ description: Install Terrabuild and configure workspace caching. inputs: terrabuild_version: description: Terrabuild version to install - default: '0.205.1' + default: 'latest' terrabuild_next: description: Whether to install a Terrabuild prerelease default: 'false' diff --git a/CHANGELOG.md b/CHANGELOG.md index 857a05ad..8540dd46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Install the latest stable Terrabuild release in CI. - Run Terrabuild CI on pushes to every branch. - Add container-backed Terrabuild builds and cached test suites, with separate FFI provider, renderer, and test projects. diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index 7e36fd4b..fce1ab51 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -1,6 +1,6 @@ # Container-backed builds with Terrabuild -Install Terrabuild 0.205.1 and a working Docker engine. The graph builds its toolchain image automatically. .NET, Node, and Clang are supplied by that image; they need not be installed on the host for these targets. +Install the latest stable Terrabuild release and a working Docker engine. The graph builds its toolchain image automatically. .NET, Node, and Clang are supplied by that image; they need not be installed on the host for these targets. ```sh # Build every .NET project, the JavaScript package, extension bundle, and website. @@ -39,7 +39,7 @@ Builds use the normal `bin`, `obj`, `node_modules`, and generated website direct The workspace ID is configured in `WORKSPACE`. Local caching works without an Insights account; `--local-only` disables shared caching. Omit `TB_FLAGS=--local-only` after connecting the workspace to allow shared reuse. Do not put tokens or master keys in repository files. -The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install Terrabuild 0.205.1 and configure authentication. They run `make tb-check`, covering builds, all test targets, sample smoke tests, and independent SDK compatibility. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching. Test reports are collected from each project's `TestResults` directory. +The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install the latest stable Terrabuild release and configure authentication. They run `make tb-check`, covering builds, all test targets, sample smoke tests, and independent SDK compatibility. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching. Test reports are collected from each project's `TestResults` directory. Managed build outputs and successful test results are cacheable separately. npm dependencies are kept in the local workspace cache. When application work must execute, the toolchain bootstrap checks the image using Docker layer caching. Fully cached requests skip execution; the phase barrier does not force application cache hits to rebuild. The configured Linux architecture separates ARM64 and AMD64 artifacts. From 69ed96e43d738c6c054fb937227b10a98c7b58cb Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 16:29:49 +0100 Subject: [PATCH 04/17] ci: publish test reports and upgrade the .NET toolchain --- .build/checks.sh | 25 ++++++- .build/reports/PROJECT | 13 ++++ .build/run-terrabuild.sh | 7 +- .build/toolchain/Dockerfile | 2 +- .../actions/collect-build-results/action.yml | 65 +++++++++++++++++++ .github/scripts/collect-test-results.mjs | 40 ++++++++++++ .github/scripts/test-collect-results.mjs | 32 +++++++++ .github/scripts/test-node-reports.sh | 43 ++++++++++++ .github/scripts/test-terrabuild.sh | 6 +- .github/workflows/ci-main.yml | 21 ++---- .github/workflows/ci-pr.yml | 21 ++---- .github/workflows/on-push-tag.yml | 4 +- .github/workflows/on-release-published.yml | 2 +- .github/workflows/publish-website.yml | 2 +- CHANGELOG.md | 2 + Makefile | 4 +- WORKSPACE | 7 ++ docs/architecture/assemblies-and-roles.md | 2 +- docs/guides/terrabuild.md | 8 +-- src/FScript.JavaScript/PROJECT | 6 +- vscode-fscript/PROJECT | 8 +-- 21 files changed, 261 insertions(+), 59 deletions(-) create mode 100644 .build/reports/PROJECT create mode 100644 .github/actions/collect-build-results/action.yml create mode 100644 .github/scripts/collect-test-results.mjs create mode 100644 .github/scripts/test-collect-results.mjs create mode 100644 .github/scripts/test-node-reports.sh diff --git a/.build/checks.sh b/.build/checks.sh index e5c5147b..fac520aa 100644 --- a/.build/checks.sh +++ b/.build/checks.sh @@ -3,10 +3,29 @@ set -euo pipefail cd "$(dirname "$0")/.." configuration="${FSCRIPT_BUILD_CONFIG:-Release}" cli=(dotnet "src/FScript/bin/$configuration/net10.0/fscript.dll") -case "${1:?Expected debugger or smoke}" in +run_node_tests() { + local report=$1 + shift + mkdir -p "$(dirname "$report")" + node --test --test-reporter=spec --test-reporter-destination=stdout \ + --test-reporter=junit --test-reporter-destination="$report" "$@" +} +case "${1:?Expected debugger, javascript, vscode, reports, or smoke}" in debugger) - mkdir -p tests/FScript.DebugAdapter.Tests/TestResults - node --test --test-reporter=junit --test-reporter-destination=tests/FScript.DebugAdapter.Tests/TestResults/debugger.xml tests/FScript.DebugAdapter.Tests/*Tests.js + run_node_tests tests/FScript.DebugAdapter.Tests/TestResults/debugger.xml tests/FScript.DebugAdapter.Tests/*Tests.js + ;; + javascript) + cd src/FScript.JavaScript + run_node_tests TestResults/javascript.xml test/*.test.mjs + ;; + vscode) + cd vscode-fscript + run_node_tests TestResults/vscode.xml ../tests/FScript.LanguageServer.Tests/*Tests.js + ;; + reports) + find src tests samples vscode-fscript \ + -type d \( -name bin -o -name obj -o -name node_modules \) -prune -o \ + -type f -path '*/TestResults/*' \( -name '*.trx' -o -name '*.xml' \) -print ;; smoke) for script in samples/*.fss; do diff --git a/.build/reports/PROJECT b/.build/reports/PROJECT new file mode 100644 index 00000000..b801d2a2 --- /dev/null +++ b/.build/reports/PROJECT @@ -0,0 +1,13 @@ +project test_reports { + depends_on = [ project.fscript_language_tests project.fscript_runtime_tests + project.fscript_compiler_tests project.fscript_conformance_tests + project.fscript_interpreter_lir_tests project.fscript_backend_dotnet_tests + project.fscript_cli_tests project.fscript_languageserver_tests + project.ffisample_tests project.javascript project.vscode project.checks ] + includes = [ "../checks.sh" ] +} + +# An executing consumer restores cached test outputs before GitHub collects them. +target reports { + workflow run { script = "../checks.sh" args = "reports" } +} diff --git a/.build/run-terrabuild.sh b/.build/run-terrabuild.sh index bbcbd852..778aba30 100755 --- a/.build/run-terrabuild.sh +++ b/.build/run-terrabuild.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")/.." -report=$(mktemp) -trap 'rm -f "$report"' EXIT +mkdir -p .terrabuild +report=.terrabuild/result.json +rm -f "$report" parallel_args=() if [[ -n "${TB_PARALLEL:-}" ]]; then parallel_args=(--parallel "$TB_PARALLEL") fi -terrabuild run "$@" --variable "config=${FSCRIPT_BUILD_CONFIG:-Release}" ${parallel_args[@]+"${parallel_args[@]}"} --out "$report" +terrabuild run "$@" --variable "config=${FSCRIPT_BUILD_CONFIG:-Release}" ${parallel_args[@]+"${parallel_args[@]}"} --out "$report" 2>&1 | tee .terrabuild/run.log # Terrabuild 0.200.1 can exit zero after reporting a failed graph. Require the # machine-readable completion status too, including for a fully cached run. if ! grep -Eq '"status"[[:space:]]*:[[:space:]]*"success"' "$report"; then diff --git a/.build/toolchain/Dockerfile b/.build/toolchain/Dockerfile index f7a08656..b16f0edf 100644 --- a/.build/toolchain/Dockerfile +++ b/.build/toolchain/Dockerfile @@ -1,5 +1,5 @@ FROM node:22-bookworm-slim AS node -FROM mcr.microsoft.com/dotnet/sdk:10.0.401 +FROM mcr.microsoft.com/dotnet/sdk:10.0.403 # AOT integration tests invoke the native compiler. Node is also needed by the # debugger tests, Fable package, VS Code extension, and documentation build. diff --git a/.github/actions/collect-build-results/action.yml b/.github/actions/collect-build-results/action.yml new file mode 100644 index 00000000..63d747b4 --- /dev/null +++ b/.github/actions/collect-build-results/action.yml @@ -0,0 +1,65 @@ +name: Collect build results +description: Upload test results and Terrabuild logs, and publish GitHub test summaries. + +inputs: + suffix: + description: Artifact and report suffix for this workflow + required: true + +runs: + using: composite + steps: + - name: Collect current test results + if: always() + shell: bash + run: node .github/scripts/collect-test-results.mjs + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ inputs.suffix }} + path: | + .out/test-results/**/*.trx + .out/test-results/**/*.xml + include-hidden-files: true + if-no-files-found: warn + + - name: Upload Terrabuild logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: terrabuild-logs-${{ inputs.suffix }} + path: | + .terrabuild/result.json + .terrabuild/run.log + terrabuild-debug.* + ~/.terrabuild/cache/**/status + ~/.terrabuild/cache/**/summary.json + ~/.terrabuild/cache/**/*.log + include-hidden-files: true + if-no-files-found: warn + + - name: Publish .NET test report + if: always() + continue-on-error: true + uses: magnusopera/test-reporter@main + with: + name: .NET Tests (${{ inputs.suffix }}) + path: '.out/test-results/**/*.trx' + reporter: dotnet-trx + fail-on-error: false + fail-on-empty: false + use-actions-summary: true + + - name: Publish JavaScript and debugger test report + if: always() + continue-on-error: true + uses: magnusopera/test-reporter@main + with: + name: JavaScript and Debugger Tests (${{ inputs.suffix }}) + path: '.out/test-results/**/*.xml' + reporter: java-junit + fail-on-error: false + fail-on-empty: false + use-actions-summary: true diff --git a/.github/scripts/collect-test-results.mjs b/.github/scripts/collect-test-results.mjs new file mode 100644 index 00000000..b74f5417 --- /dev/null +++ b/.github/scripts/collect-test-results.mjs @@ -0,0 +1,40 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const destination = path.join(root, '.out/test-results'); +const latestTrx = new Map(); +const reports = []; +const ignored = new Set(['bin', 'obj', 'node_modules', '.git', '.fscript']); + +function visit(directory, inResults = false) { + if (!fs.existsSync(directory)) return; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory() && !ignored.has(entry.name)) { + visit(file, inResults || entry.name === 'TestResults'); + } else if (entry.isFile() && inResults) { + if (entry.name.endsWith('.xml')) reports.push(file); + if (!entry.name.endsWith('.trx')) continue; + const finish = fs.readFileSync(file, 'utf8').match(/]*\bfinish="([^"]+)"/); + const parsed = finish ? Date.parse(finish[1]) : NaN; + const timestamp = Number.isFinite(parsed) ? parsed : fs.statSync(file).mtimeMs; + const previous = latestTrx.get(directory); + if (!previous || timestamp > previous.timestamp) { + latestTrx.set(directory, { file, timestamp }); + } + } + } +} + +for (const directory of ['src', 'tests', 'samples', 'vscode-fscript']) { + visit(path.join(root, directory)); +} +reports.push(...Array.from(latestTrx.values(), result => result.file)); +fs.rmSync(destination, { recursive: true, force: true }); +for (const file of reports) { + const output = path.join(destination, path.relative(root, file)); + fs.mkdirSync(path.dirname(output), { recursive: true }); + fs.copyFileSync(file, output); +} +console.log(`Collected ${latestTrx.size} .NET reports and ${reports.length - latestTrx.size} JUnit reports.`); diff --git a/.github/scripts/test-collect-results.mjs b/.github/scripts/test-collect-results.mjs new file mode 100644 index 00000000..b6ebdd87 --- /dev/null +++ b/.github/scripts/test-collect-results.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +test('collection selects current TRX results, retains JUnit, and replaces stale artifacts', () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-report-collection-')); + try { + const reports = path.join(fixture, 'tests/Suite/TestResults'); + fs.mkdirSync(reports, { recursive: true }); + fs.writeFileSync(path.join(reports, 'old.trx'), ''); + fs.writeFileSync(path.join(reports, 'current.trx'), ''); + // Cache extraction time must not make an older test result look current. + fs.utimesSync(path.join(reports, 'current.trx'), 0, 0); + fs.writeFileSync(path.join(reports, 'node.xml'), ''); + const destination = path.join(fixture, '.out/test-results'); + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync(path.join(destination, 'stale.trx'), 'stale'); + + const script = fileURLToPath(new URL('./collect-test-results.mjs', import.meta.url)); + const output = execFileSync(process.execPath, [script], { cwd: fixture, encoding: 'utf8' }); + assert.match(output, /Collected 1 .NET reports and 1 JUnit reports/); + assert.deepEqual(fs.readdirSync(path.join(destination, 'tests/Suite/TestResults')).sort(), ['current.trx', 'node.xml']); + assert.equal(fs.existsSync(path.join(destination, 'stale.trx')), false); + assert.equal(fs.existsSync(path.join(reports, 'old.trx')), true); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); diff --git a/.github/scripts/test-node-reports.sh b/.github/scripts/test-node-reports.sh new file mode 100644 index 00000000..6a3f3ee5 --- /dev/null +++ b/.github/scripts/test-node-reports.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "$0")/../.." && pwd)" +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT +mkdir -p "$fixture/.build" "$fixture/vscode-fscript" "$fixture/src/FScript.JavaScript/test" \ + "$fixture/tests/FScript.DebugAdapter.Tests" "$fixture/tests/FScript.LanguageServer.Tests" +cp "$root/.build/checks.sh" "$fixture/.build/" + +for suite in debugger javascript vscode; do + case "$suite" in + debugger) + test_file=tests/FScript.DebugAdapter.Tests/FixtureTests.js + report=tests/FScript.DebugAdapter.Tests/TestResults/debugger.xml + import="const test = require('node:test');" ;; + javascript) + test_file=src/FScript.JavaScript/test/fixture.test.mjs + report=src/FScript.JavaScript/TestResults/javascript.xml + import="import test from 'node:test';" ;; + vscode) + test_file=tests/FScript.LanguageServer.Tests/FixtureTests.js + report=vscode-fscript/TestResults/vscode.xml + import="const test = require('node:test');" ;; + esac + printf '%s\ntest("passing report fixture", () => {});\n' "$import" > "$fixture/$test_file" + bash "$fixture/.build/checks.sh" "$suite" > "$fixture/console.log" 2>&1 + grep -q '&2 + exit 1 + fi + + printf '%s\ntest("failing report fixture", () => { throw new Error("expected failure"); });\n' "$import" > "$fixture/$test_file" + if bash "$fixture/.build/checks.sh" "$suite" > "$fixture/console.log" 2>&1; then + echo "ERROR: $suite accepted a failing test." >&2 + exit 1 + fi + grep -q ' "$fixture/bin/terrabuild" <<'STUB' #!/usr/bin/env bash set -euo pipefail printf '%s\n' "$@" > "$TB_TEST_ARGS" +echo 'Terrabuild test output' report= while [[ $# -gt 0 ]]; do if [[ "$1" == --out ]]; then report=$2; shift; fi @@ -42,6 +43,8 @@ run_build() { } if ! run_build success; then cat "$fixture/log"; exit 1; fi +grep -qx 'Terrabuild test output' "$fixture/repo/.terrabuild/run.log" +grep -qx '{"status":"success"}' "$fixture/repo/.terrabuild/result.json" cat > "$fixture/expected-args" <<'ARGS' run build @@ -69,6 +72,7 @@ for result in failed-graph failed-process missing-report; do echo "ERROR: make build accepted $result." >&2 exit 1 fi + grep -qx 'Terrabuild test output' "$fixture/repo/.terrabuild/run.log" done rm "$fixture/bin/terrabuild" if run_build success; then diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index d136e053..c7d401ae 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -39,27 +39,14 @@ jobs: run: make test-terrabuild - name: Build, test, smoke-test, and verify SDK compatibility - run: make tb-check + run: make tb-check TB_FLAGS="${TB_FLAGS:-} --debug --log" - name: Logout from Terrabuild if: always() && steps.setup-tools.outputs.connected == 'true' run: terrabuild logout --space "6ae115ba87040d459b6ccf40" - - name: Upload test results + - name: Collect build results if: always() - uses: actions/upload-artifact@v4 + uses: ./.github/actions/collect-build-results with: - name: test-results-branch - path: | - **/TestResults/**/*.trx - **/TestResults/**/*.xml - if-no-files-found: warn - - - name: Publish test report - if: always() - continue-on-error: true - uses: dorny/test-reporter@v2 - with: - name: Test Results (Branch) - path: '**/TestResults/**/*.trx' - reporter: dotnet-trx + suffix: branch diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index b7cbec29..0d799c5c 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -45,27 +45,14 @@ jobs: run: make test-terrabuild - name: Build, test, smoke-test, and verify SDK compatibility - run: make tb-check + run: make tb-check TB_FLAGS="${TB_FLAGS:-} --debug --log" - name: Logout from Terrabuild if: always() && steps.setup-tools.outputs.connected == 'true' run: terrabuild logout --space "6ae115ba87040d459b6ccf40" - - name: Upload test results + - name: Collect build results if: always() - uses: actions/upload-artifact@v4 + uses: ./.github/actions/collect-build-results with: - name: test-results-pr - path: | - **/TestResults/**/*.trx - **/TestResults/**/*.xml - if-no-files-found: warn - - - name: Publish test report - if: always() - continue-on-error: true - uses: dorny/test-reporter@v2 - with: - name: Test Results (PR) - path: '**/TestResults/**/*.trx' - reporter: dotnet-trx + suffix: pr diff --git a/.github/workflows/on-push-tag.yml b/.github/workflows/on-push-tag.yml index 4f42ecce..679a63a3 100644 --- a/.github/workflows/on-push-tag.yml +++ b/.github/workflows/on-push-tag.yml @@ -23,7 +23,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.401 + dotnet-version: 10.0.403 - name: Setup Node.js uses: actions/setup-node@v4 @@ -124,7 +124,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.401 + dotnet-version: 10.0.403 - name: Download cross-built macOS CLI env: diff --git a/.github/workflows/on-release-published.yml b/.github/workflows/on-release-published.yml index def4ba3f..01864ce4 100644 --- a/.github/workflows/on-release-published.yml +++ b/.github/workflows/on-release-published.yml @@ -25,7 +25,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.401 + dotnet-version: 10.0.403 - name: Download GitHub release artifacts uses: robinraju/release-downloader@v1.11 diff --git a/.github/workflows/publish-website.yml b/.github/workflows/publish-website.yml index ac3247ad..5dc3d124 100644 --- a/.github/workflows/publish-website.yml +++ b/.github/workflows/publish-website.yml @@ -29,7 +29,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.401 + dotnet-version: 10.0.403 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8540dd46..4f68db65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Update the .NET build toolchain to SDK 10.0.403 so Native AOT publishing uses the patched compiler. +- Publish .NET, JavaScript, and debugger test summaries and downloadable Terrabuild logs in CI. - Install the latest stable Terrabuild release in CI. - Run Terrabuild CI on pushes to every branch. - Add container-backed Terrabuild builds and cached test suites, with separate FFI provider, renderer, and test projects. diff --git a/Makefile b/Makefile index 8efef13c..41dd480c 100644 --- a/Makefile +++ b/Makefile @@ -127,6 +127,8 @@ TB_PARALLEL ?= test-terrabuild: bash .github/scripts/test-terrabuild.sh + bash .github/scripts/test-node-reports.sh + node --test .github/scripts/test-collect-results.mjs tb-build: FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh build $(TB_FLAGS) @@ -141,4 +143,4 @@ tb-sdk: FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh sdk $(TB_FLAGS) tb-check: - FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh build test smoke sdk $(TB_FLAGS) + FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh build test smoke sdk reports $(TB_FLAGS) diff --git a/WORKSPACE b/WORKSPACE index 149ad2b9..1024ed33 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -42,6 +42,13 @@ target sdk { depends_on = [ target.build target.^build ] } +target reports { + phase = phase.application + build = ~always + artifacts = ~none + depends_on = [ target.^test ] +} + extension @dotnet { image = "fscript-build:${project.toolchain.version}" platform = "linux/${terrabuild.arch}" diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index 3e43025a..8bc24138 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -269,7 +269,7 @@ Language owns the permission enum, native descriptor requirements, registration `make build` delegates to the Terrabuild executable on `PATH` through `.build/run-terrabuild.sh`, which checks both the process exit code and the JSON completion status. Missing Terrabuild or a failed graph fails the build without falling back to the host .NET SDK. -GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then run the build, test, smoke, and SDK targets through `make tb-check`. Runs without cache credentials use local caching. +GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then run the build, test, smoke, and SDK targets through `make tb-check`. Runs without cache credentials use local caching. The `reports` target consumes test outputs to restore cached TRX and JUnit files. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. A toolchain phase creates the local image with the pinned .NET SDK, Node, Clang, and zlib headers. The image bootstrap uses Docker on the host and never publishes an image. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index fce1ab51..82102653 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -20,14 +20,14 @@ make tb-check TB_FLAGS=--local-only The underlying command is: ```sh -terrabuild run build test smoke sdk --local-only +terrabuild run build test smoke sdk reports --local-only ``` -The Make targets check Terrabuild's JSON completion status as well as its process exit code. Use them in automation with Terrabuild 0.200.1, which can return zero after reporting failed tasks. A missing or unsuccessful completion report fails the Make target. +The Make targets check Terrabuild's JSON completion status as well as its process exit code. Use them in automation with Terrabuild 0.200.1, which can return zero after reporting failed tasks. A missing or unsuccessful completion report fails the Make target. The wrapper retains the report as `.terrabuild/result.json` and console output as `.terrabuild/run.log`, including when a task fails. ## Containers and project layout -The toolchain phase builds `.build/toolchain/Dockerfile` with .NET SDK 10.0.401, Node 22, Clang, and zlib headers. Only this Docker image bootstrap runs on the host. `@dotnet`, `@npm`, and the custom `workflow` extension all specify the toolchain image and an explicit Linux architecture. No registry push or deployment occurs. Persistent MSBuild nodes and shared compiler servers are disabled in the image because CLI tests capture subprocess output; retained server processes can keep those pipes open after a command exits. +The toolchain phase builds `.build/toolchain/Dockerfile` with .NET SDK 10.0.403, Node 22, Clang, and zlib headers. Only this Docker image bootstrap runs on the host. `@dotnet`, `@npm`, and the custom `workflow` extension all specify the toolchain image and an explicit Linux architecture. No registry push or deployment occurs. Persistent MSBuild nodes and shared compiler servers are disabled in the image because CLI tests capture subprocess output; retained server processes can keep those pipes open after a command exits. Terrabuild stops discovery at a `PROJECT` file. The FFI sample therefore keeps scripts and `fscript.toml` at `samples/ffi`, with sibling `Provider`, `Renderer`, and `Tests` projects below it. Build the provider directly with `dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release` when using the existing host workflow. @@ -39,7 +39,7 @@ Builds use the normal `bin`, `obj`, `node_modules`, and generated website direct The workspace ID is configured in `WORKSPACE`. Local caching works without an Insights account; `--local-only` disables shared caching. Omit `TB_FLAGS=--local-only` after connecting the workspace to allow shared reuse. Do not put tokens or master keys in repository files. -The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install the latest stable Terrabuild release and configure authentication. They run `make tb-check`, covering builds, all test targets, sample smoke tests, and independent SDK compatibility. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching. Test reports are collected from each project's `TestResults` directory. +The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install the latest stable Terrabuild release and configure authentication. They run `make tb-check`, covering builds, all test targets, sample smoke tests, and independent SDK compatibility. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching. The shared `collect-build-results` action collects the latest TRX result from each test output directory and all Node JUnit files into `.out/test-results`, uploads them and publishes both report formats in the GitHub build summary, including after a failed build. Fable, VS Code, and debugger tests retain console output while also writing JUnit XML. A reporting target consumes test outputs so cached reports are restored before collection. Terrabuild diagnostics, its JSON completion report, and captured console output are uploaded as a separate logs artifact. Managed build outputs and successful test results are cacheable separately. npm dependencies are kept in the local workspace cache. When application work must execute, the toolchain bootstrap checks the image using Docker layer caching. Fully cached requests skip execution; the phase barrier does not force application cache hits to rebuild. The configured Linux architecture separates ARM64 and AMD64 artifacts. diff --git a/src/FScript.JavaScript/PROJECT b/src/FScript.JavaScript/PROJECT index 77aa63c0..0adf1581 100644 --- a/src/FScript.JavaScript/PROJECT +++ b/src/FScript.JavaScript/PROJECT @@ -1,5 +1,5 @@ project javascript { - includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../.build/checks.sh" ] ignores = [ "dist/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] @dotnet { } } @@ -12,6 +12,6 @@ target build { } target test { - outputs = [] - @npm test { } + outputs = [ "TestResults/" ] + workflow run { script = "../../.build/checks.sh" args = "javascript" } } diff --git a/vscode-fscript/PROJECT b/vscode-fscript/PROJECT index 0aa443d8..e5d43f0c 100644 --- a/vscode-fscript/PROJECT +++ b/vscode-fscript/PROJECT @@ -1,6 +1,6 @@ project vscode { - includes = [ "**/*" ] - ignores = [ "node_modules/**" "dist/**" "server/**" "*.vsix" ] + includes = [ "**/*" "../.build/checks.sh" "../tests/FScript.LanguageServer.Tests/**/*.js" ] + ignores = [ "node_modules/**" "dist/**" "server/**" "*.vsix" "TestResults/**" ] @npm { } } @@ -15,6 +15,6 @@ target build { } target test { - outputs = [] - @npm test { } + outputs = [ "TestResults/" ] + workflow run { script = "../.build/checks.sh" args = "vscode" } } From c0a9e337b2aca10f8ad285da6dcc461a9355d5ff Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 16:42:00 +0100 Subject: [PATCH 05/17] build: centralize tool versions in WORKSPACE --- .build/read-tool-versions.mjs | 25 +++++++ .build/toolchain/Dockerfile | 8 ++- .build/toolchain/PROJECT | 6 +- .github/actions/setup-tools/action.yml | 10 ++- .github/actions/workspace-versions/action.yml | 27 ++++++++ .github/scripts/test-workspace-versions.mjs | 67 +++++++++++++++++++ .github/workflows/on-push-tag.yml | 18 +++-- .github/workflows/on-release-published.yml | 53 +++++++++++++-- .github/workflows/publish-website.yml | 8 ++- CHANGELOG.md | 1 + Makefile | 1 + WORKSPACE | 10 +++ docs/architecture/assemblies-and-roles.md | 2 +- docs/guides/terrabuild.md | 10 ++- 14 files changed, 223 insertions(+), 23 deletions(-) create mode 100644 .build/read-tool-versions.mjs create mode 100644 .github/actions/workspace-versions/action.yml create mode 100644 .github/scripts/test-workspace-versions.mjs diff --git a/.build/read-tool-versions.mjs b/.build/read-tool-versions.mjs new file mode 100644 index 00000000..1d894b86 --- /dev/null +++ b/.build/read-tool-versions.mjs @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +export function readToolVersions(source) { + const blocks = [...source.matchAll(/\bversions\s*=\s*\{([^}]*)\}/g)]; + if (blocks.length !== 1) throw new Error('WORKSPACE must define one locals.versions block.'); + const versions = {}; + for (const line of blocks[0][1].split('\n')) { + if (/^\s*(?:#.*)?$/.test(line)) continue; + const match = line.match(/^\s*([a-z][a-z0-9_]*)\s*:\s*"([a-zA-Z0-9][a-zA-Z0-9.+_-]*)"\s*(?:#.*)?$/); + if (!match) throw new Error(`Tool versions must be quoted literal values: ${line.trim()}`); + const [, name, value] = match; + if (Object.hasOwn(versions, name)) throw new Error(`Duplicate tool version: ${name}`); + versions[name] = value; + } + for (const name of ['dotnet_sdk', 'node', 'terrabuild', 'vsce', 'ovsx']) { + if (!versions[name]) throw new Error(`Missing tool version: ${name}`); + } + return versions; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const versions = readToolVersions(fs.readFileSync('WORKSPACE', 'utf8')); + for (const [name, value] of Object.entries(versions)) console.log(`${name}=${value}`); +} diff --git a/.build/toolchain/Dockerfile b/.build/toolchain/Dockerfile index b16f0edf..7579023b 100644 --- a/.build/toolchain/Dockerfile +++ b/.build/toolchain/Dockerfile @@ -1,5 +1,9 @@ -FROM node:22-bookworm-slim AS node -FROM mcr.microsoft.com/dotnet/sdk:10.0.403 +# check=skip=InvalidDefaultArgInFrom +# Versions are required build arguments supplied by WORKSPACE. +ARG NODE_VERSION +ARG DOTNET_SDK_VERSION +FROM node:${NODE_VERSION}-bookworm-slim AS node +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_SDK_VERSION} # AOT integration tests invoke the native compiler. Node is also needed by the # debugger tests, Fable package, VS Code extension, and documentation build. diff --git a/.build/toolchain/PROJECT b/.build/toolchain/PROJECT index 5f97425f..435d44ed 100644 --- a/.build/toolchain/PROJECT +++ b/.build/toolchain/PROJECT @@ -1,6 +1,6 @@ project toolchain { labels = [ "toolchain" ] - includes = [ "**/*" "Dockerfile" ] + includes = [ "**/*" "Dockerfile" "../../WORKSPACE" ] } # Always check/build the local image. Docker reuses its layers. Phase barriers do @@ -9,5 +9,7 @@ target image { phase = phase.toolchains build = ~always artifacts = ~none - @shell docker { args = "build --tag fscript-build:${terrabuild.version} ." } + @shell docker { + args = "build --build-arg DOTNET_SDK_VERSION=${local.versions.dotnet_sdk} --build-arg NODE_VERSION=${local.versions.node} --tag fscript-build:${terrabuild.version} ." + } } diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml index 7c25a13b..5f0cf670 100644 --- a/.github/actions/setup-tools/action.yml +++ b/.github/actions/setup-tools/action.yml @@ -3,8 +3,8 @@ description: Install Terrabuild and configure workspace caching. inputs: terrabuild_version: - description: Terrabuild version to install - default: 'latest' + description: Optional Terrabuild version override + default: '' terrabuild_next: description: Whether to install a Terrabuild prerelease default: 'false' @@ -26,11 +26,15 @@ outputs: runs: using: composite steps: + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Install Terrabuild uses: magnusopera/action-install-gh-release@feature/prerelease with: repo: magnusopera/terrabuild - tag: ${{ inputs.terrabuild_version }} + tag: ${{ inputs.terrabuild_version || steps.tool-versions.outputs.terrabuild }} prerelease: ${{ inputs.terrabuild_next }} - name: Configure Terrabuild caching diff --git a/.github/actions/workspace-versions/action.yml b/.github/actions/workspace-versions/action.yml new file mode 100644 index 00000000..e0f63146 --- /dev/null +++ b/.github/actions/workspace-versions/action.yml @@ -0,0 +1,27 @@ +name: Read workspace tool versions +description: Read shared build-tool versions from WORKSPACE before installing tools. + +outputs: + dotnet_sdk: + description: .NET SDK version + value: ${{ steps.versions.outputs.dotnet_sdk }} + node: + description: Node.js version + value: ${{ steps.versions.outputs.node }} + terrabuild: + description: Terrabuild release selector + value: ${{ steps.versions.outputs.terrabuild }} + vsce: + description: VS Code packaging and publishing tool version + value: ${{ steps.versions.outputs.vsce }} + ovsx: + description: Open VSX publishing tool version + value: ${{ steps.versions.outputs.ovsx }} + +runs: + using: composite + steps: + - name: Read tool versions + id: versions + shell: bash + run: node .build/read-tool-versions.mjs >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/test-workspace-versions.mjs b/.github/scripts/test-workspace-versions.mjs new file mode 100644 index 00000000..78b30dbb --- /dev/null +++ b/.github/scripts/test-workspace-versions.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { readToolVersions } from '../../.build/read-tool-versions.mjs'; + +const source = `locals { + versions = { + dotnet_sdk: "10.0.999" # SDK used by Docker and Actions + node: "99" + terrabuild: "latest" + vsce: "9.8.7" + ovsx: "9.6" + } +}`; + +test('workflow outputs follow the checked-out WORKSPACE without version fallbacks', () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-tool-versions-')); + try { + fs.writeFileSync(path.join(fixture, 'WORKSPACE'), source); + const script = fileURLToPath(new URL('../../.build/read-tool-versions.mjs', import.meta.url)); + const output = execFileSync(process.execPath, [script], { cwd: fixture, encoding: 'utf8' }); + assert.equal(output, 'dotnet_sdk=10.0.999\nnode=99\nterrabuild=latest\nvsce=9.8.7\novsx=9.6\n'); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); + +test('missing, duplicate, or computed versions fail before tool installation', () => { + assert.throws(() => readToolVersions('locals {}'), /one locals.versions block/); + assert.throws(() => readToolVersions(source.replace('node: "99"', '')), /Missing tool version: node/); + assert.throws(() => readToolVersions(source.replace('node: "99"', 'node: "99"\nnode: "98"')), /Duplicate tool version/); + assert.throws(() => readToolVersions(source.replace('node: "99"', 'node: var.node')), /quoted literal values/); + assert.throws(() => readToolVersions(source.replace('node: "99"', 'node: "$(whoami)"')), /quoted literal values/); +}); + +test('changing a workspace tool version changes the container cache identity', () => { + const root = fileURLToPath(new URL('../../', import.meta.url)); + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-toolchain-identity-')); + try { + fs.mkdirSync(path.join(fixture, '.build/toolchain'), { recursive: true }); + for (const file of ['WORKSPACE', '.build/toolchain/PROJECT', '.build/toolchain/Dockerfile']) { + fs.copyFileSync(path.join(root, file), path.join(fixture, file)); + } + fs.mkdirSync(path.join(fixture, 'app')); + fs.writeFileSync(path.join(fixture, 'app/App.csproj'), 'net10.0'); + fs.writeFileSync(path.join(fixture, 'app/PROJECT'), 'project fixture { @dotnet { } }\ntarget build { @dotnet build { dependencies = false } }\n'); + execFileSync('git', ['init', '--quiet'], { cwd: fixture }); + execFileSync('git', ['add', '.'], { cwd: fixture }); + execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--quiet', '-m', 'fixture'], { cwd: fixture }); + const image = () => { + const output = execFileSync('terrabuild', ['explain', 'build', '--local-only'], { cwd: fixture, encoding: 'utf8', timeout: 30000 }); + const match = output.match(/container: (fscript-build:[A-F0-9]+)/); + assert.ok(match, 'The build must use the workspace toolchain image.'); + return match[1]; + }; + const original = image(); + const workspace = path.join(fixture, 'WORKSPACE'); + fs.writeFileSync(workspace, fs.readFileSync(workspace, 'utf8').replace(/dotnet_sdk: "[^"]+"/, 'dotnet_sdk: "10.0.999"')); + assert.notEqual(image(), original); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); diff --git a/.github/workflows/on-push-tag.yml b/.github/workflows/on-push-tag.yml index 679a63a3..14ec1bb7 100644 --- a/.github/workflows/on-push-tag.yml +++ b/.github/workflows/on-push-tag.yml @@ -20,15 +20,19 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.403 + dotnet-version: ${{ steps.tool-versions.outputs.dotnet_sdk }} - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ steps.tool-versions.outputs.node }} - name: Install extension dependencies working-directory: vscode-fscript @@ -58,7 +62,9 @@ jobs: run: dotnet test tests/FScript.LanguageServer.Tests/FScript.LanguageServer.Tests.csproj -c Release --filter "FullyQualifiedName~ReleasePackagingTests|FullyQualifiedName~EnvironmentTests" - name: Install extension packaging tool - run: npm install -g @vscode/vsce@4.0.0 + env: + VSCE_VERSION: ${{ steps.tool-versions.outputs.vsce }} + run: npm install -g "@vscode/vsce@$VSCE_VERSION" - name: Package extension working-directory: vscode-fscript @@ -121,10 +127,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.403 + dotnet-version: ${{ steps.tool-versions.outputs.dotnet_sdk }} - name: Download cross-built macOS CLI env: diff --git a/.github/workflows/on-release-published.yml b/.github/workflows/on-release-published.yml index 01864ce4..cd94d332 100644 --- a/.github/workflows/on-release-published.yml +++ b/.github/workflows/on-release-published.yml @@ -22,10 +22,19 @@ jobs: contents: read id-token: write steps: + - name: Checkout release + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.403 + dotnet-version: ${{ steps.tool-versions.outputs.dotnet_sdk }} - name: Download GitHub release artifacts uses: robinraju/release-downloader@v1.11 @@ -57,10 +66,19 @@ jobs: RELEASE_VERSION: ${{ github.event.release.tag_name }} PACKAGE_NAME: '@magnusopera/fscript' steps: + - name: Checkout release + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ steps.tool-versions.outputs.node }} registry-url: 'https://registry.npmjs.org' - name: Download npm package artifact @@ -99,10 +117,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ steps.tool-versions.outputs.node }} - name: Download VSIX artifact uses: robinraju/release-downloader@v1.11 @@ -111,7 +133,9 @@ jobs: fileName: '*.vsix' - name: Install VS Code publishing tool - run: npm install -g @vscode/vsce@4.0.0 + env: + VSCE_VERSION: ${{ steps.tool-versions.outputs.vsce }} + run: npm install -g "@vscode/vsce@$VSCE_VERSION" - name: Publish to VS Code Marketplace shell: bash @@ -126,10 +150,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ steps.tool-versions.outputs.node }} - name: Download VSIX artifact uses: robinraju/release-downloader@v1.11 @@ -138,7 +166,9 @@ jobs: fileName: '*.vsix' - name: Install Open VSX publishing tool - run: npm install -g ovsx@0.9 + env: + OVSX_VERSION: ${{ steps.tool-versions.outputs.ovsx }} + run: npm install -g "ovsx@$OVSX_VERSION" - name: Publish to Open VSX shell: bash @@ -149,10 +179,19 @@ jobs: sign-and-notarize: runs-on: macos-latest steps: + - name: Checkout release + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ steps.tool-versions.outputs.node }} - name: Download unsigned macOS artifacts uses: robinraju/release-downloader@v1.11 diff --git a/.github/workflows/publish-website.yml b/.github/workflows/publish-website.yml index 5dc3d124..495f510a 100644 --- a/.github/workflows/publish-website.yml +++ b/.github/workflows/publish-website.yml @@ -26,15 +26,19 @@ jobs: ref: main persist-credentials: false + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.403 + dotnet-version: ${{ steps.tool-versions.outputs.dotnet_sdk }} - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ steps.tool-versions.outputs.node }} cache: npm cache-dependency-path: website/package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f68db65..c2bddf06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Define shared build-tool versions in WORKSPACE for container builds and GitHub workflows. - Update the .NET build toolchain to SDK 10.0.403 so Native AOT publishing uses the patched compiler. - Publish .NET, JavaScript, and debugger test summaries and downloadable Terrabuild logs in CI. - Install the latest stable Terrabuild release in CI. diff --git a/Makefile b/Makefile index 41dd480c..1106e0c1 100644 --- a/Makefile +++ b/Makefile @@ -129,6 +129,7 @@ test-terrabuild: bash .github/scripts/test-terrabuild.sh bash .github/scripts/test-node-reports.sh node --test .github/scripts/test-collect-results.mjs + node --test .github/scripts/test-workspace-versions.mjs tb-build: FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh build $(TB_FLAGS) diff --git a/WORKSPACE b/WORKSPACE index 1024ed33..bf485281 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -10,6 +10,16 @@ phase application { depends_on = [ phase.toolchains ] } variable config { default = "Release" } +locals { + versions = { + dotnet_sdk: "10.0.403" + node: "22" + terrabuild: "latest" + vsce: "4.0.0" + ovsx: "0.9" + } +} + target install { phase = phase.application build = ~lazy diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index 8bc24138..d4af0034 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -271,6 +271,6 @@ Language owns the permission enum, native descriptor requirements, registration GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then run the build, test, smoke, and SDK targets through `make tb-check`. Runs without cache credentials use local caching. The `reports` target consumes test outputs to restore cached TRX and JUnit files. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. -The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. A toolchain phase creates the local image with the pinned .NET SDK, Node, Clang, and zlib headers. The image bootstrap uses Docker on the host and never publishes an image. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. +The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. Build-tool versions live in `WORKSPACE` under `locals.versions`. A toolchain phase passes the SDK and Node versions to Docker to create the local image with Clang and zlib headers. The toolchain includes `WORKSPACE` in its inputs so version changes invalidate its image identity. GitHub Actions reads the same version literals through the shared `workspace-versions` action before installing tools. The image bootstrap uses Docker on the host and never publishes an image. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. Build outputs and test reports are declared per target. Tests depend on their build outputs and use `--no-build --no-restore`; build actions avoid rebuilding project references already represented by graph dependencies. The independent SDK check copies current source to a temporary directory before publishing different product versions, avoiding changes to outputs used by concurrent tests. Native AOT integration tests remain part of the normal graph. Graph execution and local cache reuse need no Insights account; shared caching is configured separately. See [Terrabuild workflow](../guides/terrabuild.md). diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index 82102653..8af6debc 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -25,9 +25,15 @@ terrabuild run build test smoke sdk reports --local-only The Make targets check Terrabuild's JSON completion status as well as its process exit code. Use them in automation with Terrabuild 0.200.1, which can return zero after reporting failed tasks. A missing or unsuccessful completion report fails the Make target. The wrapper retains the report as `.terrabuild/result.json` and console output as `.terrabuild/run.log`, including when a task fails. +## Shared tool versions + +`locals.versions` in `WORKSPACE` defines the .NET SDK, Node, Terrabuild release selector, VS Code packaging tool, and Open VSX publishing tool. Change these quoted literal values in one place. Terrabuild defaults to `latest`. + +The toolchain project passes the SDK and Node versions as Docker build arguments. `WORKSPACE` is an explicit toolchain input, so changing a version also changes the image cache identity. The shared `workspace-versions` GitHub action reads the same values before tool installation; release jobs read them from the checked-out release tag. The reader fails on missing, duplicate, or computed version values rather than choosing a fallback. + ## Containers and project layout -The toolchain phase builds `.build/toolchain/Dockerfile` with .NET SDK 10.0.403, Node 22, Clang, and zlib headers. Only this Docker image bootstrap runs on the host. `@dotnet`, `@npm`, and the custom `workflow` extension all specify the toolchain image and an explicit Linux architecture. No registry push or deployment occurs. Persistent MSBuild nodes and shared compiler servers are disabled in the image because CLI tests capture subprocess output; retained server processes can keep those pipes open after a command exits. +The toolchain phase builds `.build/toolchain/Dockerfile` with the .NET SDK and Node versions from `WORKSPACE`, plus Clang and zlib headers. Only this Docker image bootstrap runs on the host. `@dotnet`, `@npm`, and the custom `workflow` extension all specify the toolchain image and an explicit Linux architecture. No registry push or deployment occurs. Persistent MSBuild nodes and shared compiler servers are disabled in the image because CLI tests capture subprocess output; retained server processes can keep those pipes open after a command exits. Terrabuild stops discovery at a `PROJECT` file. The FFI sample therefore keeps scripts and `fscript.toml` at `samples/ffi`, with sibling `Provider`, `Renderer`, and `Tests` projects below it. Build the provider directly with `dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release` when using the existing host workflow. @@ -39,7 +45,7 @@ Builds use the normal `bin`, `obj`, `node_modules`, and generated website direct The workspace ID is configured in `WORKSPACE`. Local caching works without an Insights account; `--local-only` disables shared caching. Omit `TB_FLAGS=--local-only` after connecting the workspace to allow shared reuse. Do not put tokens or master keys in repository files. -The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install the latest stable Terrabuild release and configure authentication. They run `make tb-check`, covering builds, all test targets, sample smoke tests, and independent SDK compatibility. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching. The shared `collect-build-results` action collects the latest TRX result from each test output directory and all Node JUnit files into `.out/test-results`, uploads them and publishes both report formats in the GitHub build summary, including after a failed build. Fable, VS Code, and debugger tests retain console output while also writing JUnit XML. A reporting target consumes test outputs so cached reports are restored before collection. Terrabuild diagnostics, its JSON completion report, and captured console output are uploaded as a separate logs artifact. +The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install the latest stable Terrabuild release and configure authentication. They run `make tb-check`, covering builds, all test targets, sample smoke tests, and independent SDK compatibility. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching. The shared `collect-build-results` action collects the latest TRX result from each test output directory and all Node JUnit files into `.out/test-results`, uploads them and publishes both report formats in the GitHub build summary, including after a failed build. Fable, VS Code, and debugger tests retain console output while also writing JUnit XML. A reporting target consumes test outputs so cached reports are restored before collection. Terrabuild diagnostics, its JSON completion report, and captured console output are uploaded as a separate `terrabuild-logs-*` artifact. Its `terrabuild-debug.json` includes performance timings, slowest tasks, the critical chain, and FScript performance data for build optimization. Managed build outputs and successful test results are cacheable separately. npm dependencies are kept in the local workspace cache. When application work must execute, the toolchain bootstrap checks the image using Docker layer caching. Fully cached requests skip execution; the phase barrier does not force application cache hits to rebuild. The configured Linux architecture separates ARM64 and AMD64 artifacts. From 151550ceaf85cad09e2fb9ed710d2eec9d31bff8 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 16:49:43 +0100 Subject: [PATCH 06/17] fix: allow teapot debugger startup time under CI load --- CHANGELOG.md | 1 + .../AdapterTests.js | 18 ++++----- tests/FScript.DebugAdapter.Tests/EventWait.js | 16 ++++++++ .../EventWaitTests.js | 39 +++++++++++++++++++ 4 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 tests/FScript.DebugAdapter.Tests/EventWait.js create mode 100644 tests/FScript.DebugAdapter.Tests/EventWaitTests.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c2bddf06..1382e6fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Allow the native teapot debugger test to finish mesh generation on busy CI runners and report clearer event timeouts. - Define shared build-tool versions in WORKSPACE for container builds and GitHub workflows. - Update the .NET build toolchain to SDK 10.0.403 so Native AOT publishing uses the patched compiler. - Publish .NET, JavaScript, and debugger test summaries and downloadable Terrabuild logs in CI. diff --git a/tests/FScript.DebugAdapter.Tests/AdapterTests.js b/tests/FScript.DebugAdapter.Tests/AdapterTests.js index 8d234723..683bc18a 100644 --- a/tests/FScript.DebugAdapter.Tests/AdapterTests.js +++ b/tests/FScript.DebugAdapter.Tests/AdapterTests.js @@ -4,6 +4,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); +const { waitForMessage } = require('./EventWait'); const root = path.resolve(__dirname, '../..'); const cli = process.env.FSCRIPT_DEBUG_CLI || path.join(root, 'src/FScript/bin/Release/net10.0/fscript.dll'); @@ -54,13 +55,8 @@ class Client { this.pending.set(seq, { resolve, timer }); }); } - wait(predicate) { - const i = this.messages.findIndex(predicate); - if (i >= 0) return Promise.resolve(this.messages.splice(i,1)[0]); - return new Promise((resolve,reject) => { - const timer = setTimeout(() => reject(new Error(`Timed out waiting for event: ${this.errors}`)),10000); - this.waits.push({ predicate, resolve, timer }); - }); + wait(predicate, options) { + return waitForMessage(this, predicate, options); } async launch(program, extras = {}) { const initialized = await this.request('initialize', { adapterID: 'fscript', supportsRunInTerminalRequest: true, linesStartAt1: true, columnsStartAt1: true }); @@ -148,7 +144,11 @@ test('native teapot callbacks can pause and resume on the CLI main thread', asyn const points = await client.request('setBreakpoints',{source:{path:scene},breakpoints:[{line:23}]}); assert.equal(points.body.breakpoints[0].verified,true); await client.request('configurationDone'); - const stop = await client.wait(message => message.event === 'stopped'); + // The real sample generates the full mesh before entering its render callback. + // Concurrent builds on a CI runner can make that startup exceed ten seconds. + const stop = await client.wait(message => message.event === 'stopped', { + timeoutMs: 60000, label: 'first teapot render breakpoint after mesh generation' + }); const stack = await client.request('stackTrace',{threadId:stop.body.threadId}); assert.equal(stack.body.stackFrames[0].name,'render'); const scopes = await client.request('scopes',{frameId:stack.body.stackFrames[0].id}); @@ -159,7 +159,7 @@ test('native teapot callbacks can pause and resume on the CLI main thread', asyn assert.ok(values.body.variables.some(value => value.name === 'seconds')); await client.request('setBreakpoints',{source:{path:scene},breakpoints:[]}); await client.request('continue',{threadId:stop.body.threadId,singleThread:true}); - await client.wait(message => message.event === 'terminated'); + await client.wait(message => message.event === 'terminated', { label: 'teapot termination after continue' }); }); test('DAP zero-based source positions are normalized without changing runtime locations', async t => { diff --git a/tests/FScript.DebugAdapter.Tests/EventWait.js b/tests/FScript.DebugAdapter.Tests/EventWait.js new file mode 100644 index 00000000..dae7c0c3 --- /dev/null +++ b/tests/FScript.DebugAdapter.Tests/EventWait.js @@ -0,0 +1,16 @@ +function waitForMessage(client, predicate, { timeoutMs = 10000, label = 'matching event' } = {}) { + const index = client.messages.findIndex(predicate); + if (index >= 0) return Promise.resolve(client.messages.splice(index, 1)[0]); + return new Promise((resolve, reject) => { + const wait = { predicate, resolve }; + wait.timer = setTimeout(() => { + const index = client.waits.indexOf(wait); + if (index >= 0) client.waits.splice(index, 1); + const received = client.messages.map(message => message.event || message.command || message.type).join(', '); + reject(new Error(`Timed out after ${timeoutMs}ms waiting for ${label}. Received: ${received || 'none'}. Stderr: ${client.errors}`)); + }, timeoutMs); + client.waits.push(wait); + }); +} + +module.exports = { waitForMessage }; diff --git a/tests/FScript.DebugAdapter.Tests/EventWaitTests.js b/tests/FScript.DebugAdapter.Tests/EventWaitTests.js new file mode 100644 index 00000000..181e38ea --- /dev/null +++ b/tests/FScript.DebugAdapter.Tests/EventWaitTests.js @@ -0,0 +1,39 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { waitForMessage } = require('./EventWait'); + +test('expensive startup may use a longer event deadline without losing a delayed breakpoint', async () => { + const client = { messages: [], waits: [], errors: '' }; + const stopped = { event: 'stopped' }; + let timer; + try { + const waiting = waitForMessage(client, message => message.event === 'stopped', { timeoutMs: 1000, label: 'first render breakpoint' }); + timer = setTimeout(() => { + const wait = client.waits.shift(); + clearTimeout(wait.timer); + wait.resolve(stopped); + }, 30); + assert.equal(await waiting, stopped); + assert.equal(client.waits.length, 0); + } finally { + clearTimeout(timer); + for (const wait of client.waits) clearTimeout(wait.timer); + } +}); + +test('an expired event wait is removed and identifies the missing event', async () => { + const client = { messages: [{ event: 'output' }], waits: [], errors: 'child diagnostic' }; + await assert.rejects( + waitForMessage(client, message => message.event === 'stopped', { timeoutMs: 5, label: 'first render breakpoint' }), + /waiting for first render breakpoint\. Received: output\. Stderr: child diagnostic/ + ); + assert.equal(client.waits.length, 0); +}); + +test('an already buffered event is consumed without creating a timer', async () => { + const stopped = { event: 'stopped' }; + const client = { messages: [stopped], waits: [], errors: '' }; + assert.equal(await waitForMessage(client, message => message.event === 'stopped'), stopped); + assert.equal(client.messages.length, 0); + assert.equal(client.waits.length, 0); +}); From be3c35bb4d2efb6f4afb7201a4eb7ad4f7dcf288 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 17:06:28 +0100 Subject: [PATCH 07/17] ci: scatter test shards after the cached build and gather reports --- .build/ci-shards.json | 11 ++ .build/ci/backend/PROJECT | 9 ++ .build/ci/cli/PROJECT | 9 ++ .build/ci/core/PROJECT | 9 ++ .build/ci/ffi-default/PROJECT | 9 ++ .build/ci/ffi-managed/PROJECT | 9 ++ .build/ci/ffi-size/PROJECT | 9 ++ .build/ci/integration/PROJECT | 9 ++ .build/ci/web/PROJECT | 9 ++ .../actions/collect-build-results/action.yml | 30 ++--- .../actions/publish-test-results/action.yml | 34 +++++ .github/scripts/run-ci-shard.mjs | 12 ++ .github/scripts/test-ci-shards.mjs | 81 ++++++++++++ .github/workflows/ci-main.yml | 45 +------ .github/workflows/ci-pr.yml | 47 +------ .github/workflows/ci.yml | 124 ++++++++++++++++++ CHANGELOG.md | 1 + WORKSPACE | 26 ++++ docs/architecture/assemblies-and-roles.md | 2 +- docs/guides/terrabuild.md | 20 ++- samples/ffi/Tests/GraphicsTests.fs | 4 +- samples/ffi/Tests/PROJECT | 25 ++++ 22 files changed, 428 insertions(+), 106 deletions(-) create mode 100644 .build/ci-shards.json create mode 100644 .build/ci/backend/PROJECT create mode 100644 .build/ci/cli/PROJECT create mode 100644 .build/ci/core/PROJECT create mode 100644 .build/ci/ffi-default/PROJECT create mode 100644 .build/ci/ffi-managed/PROJECT create mode 100644 .build/ci/ffi-size/PROJECT create mode 100644 .build/ci/integration/PROJECT create mode 100644 .build/ci/web/PROJECT create mode 100644 .github/actions/publish-test-results/action.yml create mode 100644 .github/scripts/run-ci-shard.mjs create mode 100644 .github/scripts/test-ci-shards.mjs create mode 100644 .github/workflows/ci.yml diff --git a/.build/ci-shards.json b/.build/ci-shards.json new file mode 100644 index 00000000..17cc6355 --- /dev/null +++ b/.build/ci-shards.json @@ -0,0 +1,11 @@ +{ + "include": [ + { "name": "core", "projects": ["ci_core", "ci_ffi_managed"] }, + { "name": "backend", "projects": ["ci_backend"] }, + { "name": "cli", "projects": ["ci_cli"] }, + { "name": "ffi-default", "projects": ["ci_ffi_default"] }, + { "name": "ffi-size", "projects": ["ci_ffi_size"] }, + { "name": "web", "projects": ["ci_web"] }, + { "name": "integration", "projects": ["ci_integration"] } + ] +} diff --git a/.build/ci/backend/PROJECT b/.build/ci/backend/PROJECT new file mode 100644 index 00000000..0181f1ee --- /dev/null +++ b/.build/ci/backend/PROJECT @@ -0,0 +1,9 @@ +project ci_backend { + depends_on = [ project.fscript_backend_dotnet_tests ] + includes = [ "../../checks.sh" ] +} + +target ci { + depends_on = [ target.^test ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/cli/PROJECT b/.build/ci/cli/PROJECT new file mode 100644 index 00000000..111364c4 --- /dev/null +++ b/.build/ci/cli/PROJECT @@ -0,0 +1,9 @@ +project ci_cli { + depends_on = [ project.fscript_cli_tests ] + includes = [ "../../checks.sh" ] +} + +target ci { + depends_on = [ target.^test ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/core/PROJECT b/.build/ci/core/PROJECT new file mode 100644 index 00000000..0d5f3dfa --- /dev/null +++ b/.build/ci/core/PROJECT @@ -0,0 +1,9 @@ +project ci_core { + depends_on = [ project.fscript_language_tests project.fscript_runtime_tests project.fscript_compiler_tests project.fscript_conformance_tests project.fscript_interpreter_lir_tests project.fscript_languageserver_tests ] + includes = [ "../../checks.sh" ] +} + +target ci { + depends_on = [ target.^test ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/ffi-default/PROJECT b/.build/ci/ffi-default/PROJECT new file mode 100644 index 00000000..b7b91595 --- /dev/null +++ b/.build/ci/ffi-default/PROJECT @@ -0,0 +1,9 @@ +project ci_ffi_default { + depends_on = [ project.ffisample_tests ] + includes = [ "../../checks.sh" ] +} + +target ci { + depends_on = [ target.^test_ffi_default ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/ffi-managed/PROJECT b/.build/ci/ffi-managed/PROJECT new file mode 100644 index 00000000..1ba80880 --- /dev/null +++ b/.build/ci/ffi-managed/PROJECT @@ -0,0 +1,9 @@ +project ci_ffi_managed { + depends_on = [ project.ffisample_tests ] + includes = [ "../../checks.sh" ] +} + +target ci { + depends_on = [ target.^test_ffi_managed ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/ffi-size/PROJECT b/.build/ci/ffi-size/PROJECT new file mode 100644 index 00000000..7f90cbf9 --- /dev/null +++ b/.build/ci/ffi-size/PROJECT @@ -0,0 +1,9 @@ +project ci_ffi_size { + depends_on = [ project.ffisample_tests ] + includes = [ "../../checks.sh" ] +} + +target ci { + depends_on = [ target.^test_ffi_size ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/integration/PROJECT b/.build/ci/integration/PROJECT new file mode 100644 index 00000000..e6f495e5 --- /dev/null +++ b/.build/ci/integration/PROJECT @@ -0,0 +1,9 @@ +project ci_integration { + depends_on = [ project.checks ] + includes = [ "../../checks.sh" ] +} + +target ci { + depends_on = [ target.^test target.^smoke target.^sdk ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/web/PROJECT b/.build/ci/web/PROJECT new file mode 100644 index 00000000..6902b6cd --- /dev/null +++ b/.build/ci/web/PROJECT @@ -0,0 +1,9 @@ +project ci_web { + depends_on = [ project.javascript project.vscode project.website ] + includes = [ "../../checks.sh" ] +} + +target ci { + depends_on = [ target.^test ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.github/actions/collect-build-results/action.yml b/.github/actions/collect-build-results/action.yml index 63d747b4..5ec68c23 100644 --- a/.github/actions/collect-build-results/action.yml +++ b/.github/actions/collect-build-results/action.yml @@ -6,6 +6,10 @@ inputs: description: Artifact and report suffix for this workflow required: true + publish: + description: Publish reports immediately instead of deferring to the CI join job + default: 'true' + runs: using: composite steps: @@ -40,26 +44,8 @@ runs: include-hidden-files: true if-no-files-found: warn - - name: Publish .NET test report - if: always() - continue-on-error: true - uses: magnusopera/test-reporter@main - with: - name: .NET Tests (${{ inputs.suffix }}) - path: '.out/test-results/**/*.trx' - reporter: dotnet-trx - fail-on-error: false - fail-on-empty: false - use-actions-summary: true - - - name: Publish JavaScript and debugger test report - if: always() - continue-on-error: true - uses: magnusopera/test-reporter@main + - name: Publish test summaries + if: always() && inputs.publish == 'true' + uses: ./.github/actions/publish-test-results with: - name: JavaScript and Debugger Tests (${{ inputs.suffix }}) - path: '.out/test-results/**/*.xml' - reporter: java-junit - fail-on-error: false - fail-on-empty: false - use-actions-summary: true + suffix: ${{ inputs.suffix }} diff --git a/.github/actions/publish-test-results/action.yml b/.github/actions/publish-test-results/action.yml new file mode 100644 index 00000000..6e1c7b9e --- /dev/null +++ b/.github/actions/publish-test-results/action.yml @@ -0,0 +1,34 @@ +name: Publish test results +description: Publish .NET and Node test summaries from the selected report directory. +inputs: + suffix: + description: Names used for GitHub check reports + required: true + path: + description: Root containing TRX and JUnit reports + default: .out/test-results +runs: + using: composite + steps: + - name: Publish .NET test report + if: always() + continue-on-error: true + uses: magnusopera/test-reporter@main + with: + name: .NET Tests (${{ inputs.suffix }}) + path: '${{ inputs.path }}/**/*.trx' + reporter: dotnet-trx + fail-on-error: false + fail-on-empty: false + use-actions-summary: true + - name: Publish JavaScript and debugger test report + if: always() + continue-on-error: true + uses: magnusopera/test-reporter@main + with: + name: JavaScript and Debugger Tests (${{ inputs.suffix }}) + path: '${{ inputs.path }}/**/*.xml' + reporter: java-junit + fail-on-error: false + fail-on-empty: false + use-actions-summary: true diff --git a/.github/scripts/run-ci-shard.mjs b/.github/scripts/run-ci-shard.mjs new file mode 100644 index 00000000..3a8f0123 --- /dev/null +++ b/.github/scripts/run-ci-shard.mjs @@ -0,0 +1,12 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; + +const matrix = JSON.parse(fs.readFileSync('.build/ci-shards.json', 'utf8')); +const shard = matrix.include.find(shard => shard.name === process.argv[2]); +if (!shard) throw new Error(`Unknown CI shard: ${process.argv[2]}`); +const args = ['ci', '--project', ...shard.projects, '--debug', '--log']; +// setup-tools sets this only for a runner without shared-cache credentials. +if (process.env.TB_FLAGS === '--local-only') args.push('--local-only'); +const result = spawnSync('bash', ['.build/run-terrabuild.sh', ...args], { stdio: 'inherit' }); +if (result.error) throw result.error; +process.exit(result.status ?? 1); diff --git a/.github/scripts/test-ci-shards.mjs b/.github/scripts/test-ci-shards.mjs new file mode 100644 index 00000000..d58f6ff5 --- /dev/null +++ b/.github/scripts/test-ci-shards.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const matrix = JSON.parse(fs.readFileSync('.build/ci-shards.json', 'utf8')).include; +const expected = { + core: ['tests/fscript.language.tests:test', 'tests/fscript.runtime.tests:test', 'tests/fscript.compiler.tests:test', + 'tests/fscript.conformance.tests:test', 'tests/fscript.interpreter.lir.tests:test', + 'tests/fscript.languageserver.tests:test', 'samples/ffi/tests:test_ffi_managed'], + backend: ['tests/fscript.backend.dotnet.tests:test'], + cli: ['tests/fscript.cli.tests:test'], + 'ffi-default': ['samples/ffi/tests:test_ffi_default'], + 'ffi-size': ['samples/ffi/tests:test_ffi_size'], + web: ['src/fscript.javascript:test', 'vscode-fscript:test', 'website:test'], + integration: ['tests/fscript.debugadapter.tests:test', 'tests/fscript.debugadapter.tests:smoke', + 'tests/fscript.debugadapter.tests:sdk'] +}; + +test('CI runner forwards the exact project selection and propagates failures', () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-ci-shard-')); + try { + fs.mkdirSync(path.join(fixture, '.build')); + fs.copyFileSync('.build/ci-shards.json', path.join(fixture, '.build/ci-shards.json')); + fs.writeFileSync(path.join(fixture, '.build/run-terrabuild.sh'), + 'printf "%s\\n" "$@"\nexit "${CI_FIXTURE_EXIT:-0}"\n'); + const script = fileURLToPath(new URL('./run-ci-shard.mjs', import.meta.url)); + const run = (name, extra = {}) => spawnSync(process.execPath, [script, name], { + cwd: fixture, encoding: 'utf8', env: { ...process.env, TB_FLAGS: '--local-only', ...extra } + }); + const core = run('core'); + assert.equal(core.status, 0, core.stderr); + assert.deepEqual(core.stdout.trim().split('\n'), + ['ci', '--project', 'ci_core', 'ci_ffi_managed', '--debug', '--log', '--local-only']); + assert.equal(run('ffi-default', { CI_FIXTURE_EXIT: '7' }).status, 7); + assert.notEqual(run('missing').status, 0); + const shared = run('ffi-size', { TB_FLAGS: '' }); + assert.equal(shared.status, 0); + assert.ok(!shared.stdout.includes('--local-only')); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); + +test('static CI shards select exactly their checks, restore reports, and cover every test project', () => { + assert.deepEqual(matrix.map(shard => shard.name).sort(), Object.keys(expected).sort()); + const covered = []; + for (const shard of matrix) { + execFileSync('terrabuild', ['run', 'ci', '--project', ...shard.projects, + '--dry-run', '--local-only', '--debug'], { stdio: 'pipe', timeout: 30000 }); + const graph = JSON.parse(fs.readFileSync('terrabuild-debug.json', 'utf8')); + const selected = graph.nodes.filter(node => node.selected); + const checks = selected.filter(node => /^(test(?:_ffi_.*)?|smoke|sdk)$/.test(node.target)); + const names = checks.map(node => `${node.projectDirectory.toLowerCase()}:${node.target}`).sort(); + assert.deepEqual(names, expected[shard.name].toSorted(), shard.name); + assert.ok(selected.some(node => node.target === 'ci' && node.build === 'always'), shard.name); + for (const consumer of selected.filter(node => node.target === 'ci')) { + assert.ok(consumer.resolvedOperations.length > 0, 'Cached reports need an executing consumer.'); + } + covered.push(...checks.filter(node => node.target.startsWith('test')).map(node => + `${node.projectDirectory.toLowerCase()}:test`)); + } + const allTests = []; + function scan(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (['node_modules', 'bin', 'obj', '.git', '.terrabuild', 'TestResults', '.out'].includes(entry.name)) continue; + const file = path.join(directory, entry.name); + if (entry.isDirectory()) scan(file); + else if (entry.name === 'PROJECT' && /target test\s*\{/.test(fs.readFileSync(file, 'utf8'))) { + allTests.push(`${directory.replace(/^\.\//, '').toLowerCase()}:test`); + } + } + } + scan('.'); + assert.deepEqual([...new Set(covered)].sort(), allTests.sort()); + const duplicates = covered.filter((name, index) => covered.indexOf(name) !== index); + assert.deepEqual(duplicates, ['samples/ffi/tests:test', 'samples/ffi/tests:test']); +}); diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index c7d401ae..ef98e564 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -11,42 +11,9 @@ permissions: checks: write jobs: - build-and-test: - runs-on: ubuntu-latest - timeout-minutes: 25 - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Enforce changelog updates for functional changes - run: .github/scripts/check-unreleased-changelog.sh - - - name: Verify changelog release formatting - run: make test-release-changelog - - - name: Setup Tools - id: setup-tools - uses: ./.github/actions/setup-tools - with: - terrabuild_workspace: "6ae115ba87040d459b6ccf40" - TERRABUILD_TOKEN: ${{ secrets.TERRABUILD_TOKEN }} - TERRABUILD_MASTER_KEY: ${{ secrets.TERRABUILD_MASTER_KEY }} - - - name: Verify Terrabuild Makefile integration - run: make test-terrabuild - - - name: Build, test, smoke-test, and verify SDK compatibility - run: make tb-check TB_FLAGS="${TB_FLAGS:-} --debug --log" - - - name: Logout from Terrabuild - if: always() && steps.setup-tools.outputs.connected == 'true' - run: terrabuild logout --space "6ae115ba87040d459b6ccf40" - - - name: Collect build results - if: always() - uses: ./.github/actions/collect-build-results - with: - suffix: branch + ci: + uses: ./.github/workflows/ci.yml + with: + suffix: branch + require-changelog: false + secrets: inherit diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 0d799c5c..513b742e 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -15,44 +15,9 @@ permissions: checks: write jobs: - build-and-test: - runs-on: ubuntu-latest - timeout-minutes: 25 - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Enforce unreleased changelog updates - env: - REQUIRE_CHANGELOG_ALWAYS: "true" - run: .github/scripts/check-unreleased-changelog.sh - - - name: Verify changelog release formatting - run: make test-release-changelog - - - name: Setup Tools - id: setup-tools - uses: ./.github/actions/setup-tools - with: - terrabuild_workspace: "6ae115ba87040d459b6ccf40" - TERRABUILD_TOKEN: ${{ secrets.TERRABUILD_TOKEN }} - TERRABUILD_MASTER_KEY: ${{ secrets.TERRABUILD_MASTER_KEY }} - - - name: Verify Terrabuild Makefile integration - run: make test-terrabuild - - - name: Build, test, smoke-test, and verify SDK compatibility - run: make tb-check TB_FLAGS="${TB_FLAGS:-} --debug --log" - - - name: Logout from Terrabuild - if: always() && steps.setup-tools.outputs.connected == 'true' - run: terrabuild logout --space "6ae115ba87040d459b6ccf40" - - - name: Collect build results - if: always() - uses: ./.github/actions/collect-build-results - with: - suffix: pr + ci: + uses: ./.github/workflows/ci.yml + with: + suffix: pr + require-changelog: true + secrets: inherit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..364c9709 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,124 @@ +name: Terrabuild CI + +on: + workflow_call: + inputs: + suffix: + type: string + required: true + require-changelog: + type: boolean + default: false + secrets: + TERRABUILD_TOKEN: + required: false + TERRABUILD_MASTER_KEY: + required: false + +permissions: + contents: read + checks: write + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 25 + outputs: + matrix: ${{ steps.shards.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Enforce changelog updates + env: + REQUIRE_CHANGELOG_ALWAYS: ${{ inputs.require-changelog }} + run: .github/scripts/check-unreleased-changelog.sh + - name: Verify release formatting + run: make test-release-changelog + - name: Setup Tools + id: setup-tools + uses: ./.github/actions/setup-tools + with: + terrabuild_workspace: "6ae115ba87040d459b6ccf40" + TERRABUILD_TOKEN: ${{ secrets.TERRABUILD_TOKEN }} + TERRABUILD_MASTER_KEY: ${{ secrets.TERRABUILD_MASTER_KEY }} + - name: Verify CI integration and shard coverage + run: | + make test-terrabuild + node --test .github/scripts/test-ci-shards.mjs + - name: Read static test matrix + id: shards + run: node -e 'console.log("matrix=" + JSON.stringify(require("./.build/ci-shards.json")))' >> "$GITHUB_OUTPUT" + - name: Build and populate the Terrabuild cache + run: make build TB_FLAGS="${TB_FLAGS:-} --debug --log" + - name: Logout from Terrabuild + if: always() && steps.setup-tools.outputs.connected == 'true' + run: terrabuild logout --space "6ae115ba87040d459b6ccf40" + - name: Collect build diagnostics + if: always() + uses: ./.github/actions/collect-build-results + with: + suffix: ${{ inputs.suffix }}-build + publish: 'false' + + tests: + needs: build + name: Tests (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.build.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Tools + id: setup-tools + uses: ./.github/actions/setup-tools + with: + terrabuild_workspace: "6ae115ba87040d459b6ccf40" + TERRABUILD_TOKEN: ${{ secrets.TERRABUILD_TOKEN }} + TERRABUILD_MASTER_KEY: ${{ secrets.TERRABUILD_MASTER_KEY }} + - name: Run selected tests and restore their reports + env: + CI_SHARD: ${{ matrix.name }} + run: node .github/scripts/run-ci-shard.mjs "$CI_SHARD" + - name: Logout from Terrabuild + if: always() && steps.setup-tools.outputs.connected == 'true' + run: terrabuild logout --space "6ae115ba87040d459b6ccf40" + - name: Collect shard results and diagnostics + if: always() + uses: ./.github/actions/collect-build-results + with: + suffix: ${{ inputs.suffix }}-${{ matrix.name }} + publish: 'false' + + build-and-test: + name: Build and test + needs: [build, tests] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Gather shard test results + if: needs.tests.result != 'skipped' + uses: actions/download-artifact@v4 + with: + pattern: test-results-${{ inputs.suffix }}-* + path: .out/ci-results + merge-multiple: false + - name: Publish combined test reports + if: always() && needs.tests.result != 'skipped' + uses: ./.github/actions/publish-test-results + with: + suffix: ${{ inputs.suffix }} + path: .out/ci-results + - name: Require the build and every shard to pass + if: always() + env: + BUILD_RESULT: ${{ needs.build.result }} + TEST_RESULT: ${{ needs.tests.result }} + run: | + test "$BUILD_RESULT" = success + test "$TEST_RESULT" = success diff --git a/CHANGELOG.md b/CHANGELOG.md index 1382e6fa..f4ded0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Split GitHub CI into a cached build, parallel test shards, and combined test reports, including separate Native AOT variants. - Allow the native teapot debugger test to finish mesh generation on busy CI runners and report clearer event timeouts. - Define shared build-tool versions in WORKSPACE for container builds and GitHub workflows. - Update the .NET build toolchain to SDK 10.0.403 so Native AOT publishing uses the patched compiler. diff --git a/WORKSPACE b/WORKSPACE index bf485281..271a2360 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -59,6 +59,32 @@ target reports { depends_on = [ target.^test ] } +# Static CI entry points consume only their selected test outputs. +target ci { + phase = phase.application + build = ~always + artifacts = ~none + depends_on = [] +} + +target test_ffi_managed { + phase = phase.application + artifacts = ~managed + depends_on = [ target.build ] +} + +target test_ffi_default { + phase = phase.application + artifacts = ~managed + depends_on = [ target.build ] +} + +target test_ffi_size { + phase = phase.application + artifacts = ~managed + depends_on = [ target.build ] +} + extension @dotnet { image = "fscript-build:${project.toolchain.version}" platform = "linux/${terrabuild.arch}" diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index d4af0034..51616cd4 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -269,7 +269,7 @@ Language owns the permission enum, native descriptor requirements, registration `make build` delegates to the Terrabuild executable on `PATH` through `.build/run-terrabuild.sh`, which checks both the process exit code and the JSON completion status. Missing Terrabuild or a failed graph fails the build without falling back to the host .NET SDK. -GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then run the build, test, smoke, and SDK targets through `make tb-check`. Runs without cache credentials use local caching. The `reports` target consumes test outputs to restore cached TRX and JUnit files. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. +GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. Shards select GitHub-only `ci` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. Build-tool versions live in `WORKSPACE` under `locals.versions`. A toolchain phase passes the SDK and Node versions to Docker to create the local image with Clang and zlib headers. The toolchain includes `WORKSPACE` in its inputs so version changes invalidate its image identity. GitHub Actions reads the same version literals through the shared `workspace-versions` action before installing tools. The image bootstrap uses Docker on the host and never publishes an image. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index 8af6debc..4ccc03a4 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -45,7 +45,7 @@ Builds use the normal `bin`, `obj`, `node_modules`, and generated website direct The workspace ID is configured in `WORKSPACE`. Local caching works without an Insights account; `--local-only` disables shared caching. Omit `TB_FLAGS=--local-only` after connecting the workspace to allow shared reuse. Do not put tokens or master keys in repository files. -The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install the latest stable Terrabuild release and configure authentication. They run `make tb-check`, covering builds, all test targets, sample smoke tests, and independent SDK compatibility. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching. The shared `collect-build-results` action collects the latest TRX result from each test output directory and all Node JUnit files into `.out/test-results`, uploads them and publishes both report formats in the GitHub build summary, including after a failed build. Fable, VS Code, and debugger tests retain console output while also writing JUnit XML. A reporting target consumes test outputs so cached reports are restored before collection. Terrabuild diagnostics, its JSON completion report, and captured console output are uploaded as a separate `terrabuild-logs-*` artifact. Its `terrabuild-debug.json` includes performance timings, slowest tasks, the critical chain, and FScript performance data for build optimization. +The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install the latest stable Terrabuild release and configure authentication. Both call the shared `ci.yml` workflow, which builds once, scatters tests across seven runners, and joins the results. The build job runs `make build`; each test runner requests only its shard's `ci` target and lets Terrabuild restore prerequisite outputs from the shared cache. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching and rebuilds prerequisites on each test runner; cache sharing across jobs requires both credentials. The shared `collect-build-results` action collects the latest TRX result from each test output directory and all Node JUnit files into `.out/test-results`, uploads them and publishes both report formats in the GitHub build summary, including after a failed build. Fable, VS Code, and debugger tests retain console output while also writing JUnit XML. A reporting target consumes test outputs so cached reports are restored before collection. Terrabuild diagnostics, its JSON completion report, and captured console output are uploaded as a separate `terrabuild-logs-*` artifact. Its `terrabuild-debug.json` includes performance timings, slowest tasks, the critical chain, and FScript performance data for build optimization. Managed build outputs and successful test results are cacheable separately. npm dependencies are kept in the local workspace cache. When application work must execute, the toolchain bootstrap checks the image using Docker layer caching. Fully cached requests skip execution; the phase barrier does not force application cache hits to rebuild. The configured Linux architecture separates ARM64 and AMD64 artifacts. @@ -54,3 +54,21 @@ Use `terrabuild explain build test --local-only` to inspect cache decisions. Rep Cached test success is not a fresh execution. Force a run when investigating a flaky test or an external dependency. Deleting generated output and repeating a fully cached request is not a restoration test: Terrabuild restores prerequisite files when an executing consumer needs them. `ci-main.yml` runs on pushes to every branch, including names containing `/`, and supports manual dispatch. Tag pushes remain handled by the release workflows. `ci-pr.yml` separately validates pull requests targeting `main`, so a push to an open PR triggers both branch and PR CI. + +## GitHub test matrix + +The GitHub-only `.build/ci-shards.json` lists a static matrix. `.build/ci/*/PROJECT` defines each shard's dependency selection. The build job validates that the union covers every test project without selecting unrelated tests. Local `make build`, `make test`, and `make tb-check` retain their existing behavior; local full FFI tests still run both Native AOT variants. + +| Shard | Checks | +| --- | --- | +| core | Language, runtime, compiler, conformance, interpreter, language server, and managed FFI tests | +| backend | .NET backend tests | +| cli | CLI tests | +| ffi-default | Native AOT teapot without size optimization | +| ffi-size | Native AOT teapot with size optimization | +| web | Fable, VS Code, and website tests | +| integration | Debugger adapter, all sample smoke tests, and SDK compatibility | + +The FFI categories partition its cases into managed tests and the two Native AOT variants; their results and cache entries are separate. Each shard's executing `ci` target consumes just its test outputs, restoring cached reports before artifact upload. Every runner retains its own diagnostics and test artifact. The join downloads the artifacts into separate directories, publishes combined TRX and JUnit summaries, and requires the build and every shard to succeed. A failed shard does not cancel the others. + +The baseline [successful run](https://github.com/MagnusOpera/FScript/actions/runs/42246670574) took 7m34s in Terrabuild. Its trace identified FFI tests at 5m32s, SDK compatibility at 1m59s, and the website build at 1m36s. The matrix separates those workloads and splits the sequential AOT pair. Runner setup, cache transfers, and the build barrier add overhead; compare subsequent GitHub timings and `terrabuild-logs-*` artifacts before claiming a speedup. diff --git a/samples/ffi/Tests/GraphicsTests.fs b/samples/ffi/Tests/GraphicsTests.fs index 3415ef3b..2cc433de 100644 --- a/samples/ffi/Tests/GraphicsTests.fs +++ b/samples/ffi/Tests/GraphicsTests.fs @@ -180,8 +180,8 @@ Graphics.runHeadless 3 (fun window -> fun seconds -> Graphics.clear window { R = Assert.That(output.GetAwaiter().GetResult(), Does.Contain "1 mesh uploads; 3 frames") else Assert.That(errors.GetAwaiter().GetResult(), Does.Contain "frame limit") - [] - [] + [] + [] member _.``native executable compiles the real teapot with automatic FFI configuration and runs alone``(minimizeSize: bool) = let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "../../..")) #if DEBUG diff --git a/samples/ffi/Tests/PROJECT b/samples/ffi/Tests/PROJECT index d9e0fd7a..ef30bf57 100644 --- a/samples/ffi/Tests/PROJECT +++ b/samples/ffi/Tests/PROJECT @@ -12,3 +12,28 @@ target test { outputs = [ "TestResults/" ] @dotnet test { build = false restore = false args = "--results-directory TestResults" } } + +target test_ffi_managed { + outputs = [ "TestResults/managed/" ] + @dotnet test { + build = false restore = false + filter = "TestCategory!=NativeAotDefault&TestCategory!=NativeAotSize" + args = "--results-directory TestResults/managed" + } +} + +target test_ffi_default { + outputs = [ "TestResults/aot-default/" ] + @dotnet test { + build = false restore = false filter = "TestCategory=NativeAotDefault" + args = "--results-directory TestResults/aot-default" + } +} + +target test_ffi_size { + outputs = [ "TestResults/aot-size/" ] + @dotnet test { + build = false restore = false filter = "TestCategory=NativeAotSize" + args = "--results-directory TestResults/aot-size" + } +} From 06f722cf792f804e2cf4a720198a332f15e7db78 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 17:14:50 +0100 Subject: [PATCH 08/17] ci: correlate the build and test shards in one Insights group --- .build/run-terrabuild.sh | 6 +++++- .github/scripts/test-terrabuild.sh | 19 +++++++++++++++++++ .github/workflows/ci.yml | 3 +++ CHANGELOG.md | 1 + docs/architecture/assemblies-and-roles.md | 2 +- docs/guides/terrabuild.md | 2 +- 6 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.build/run-terrabuild.sh b/.build/run-terrabuild.sh index 778aba30..4644ff4b 100755 --- a/.build/run-terrabuild.sh +++ b/.build/run-terrabuild.sh @@ -8,7 +8,11 @@ parallel_args=() if [[ -n "${TB_PARALLEL:-}" ]]; then parallel_args=(--parallel "$TB_PARALLEL") fi -terrabuild run "$@" --variable "config=${FSCRIPT_BUILD_CONFIG:-Release}" ${parallel_args[@]+"${parallel_args[@]}"} --out "$report" 2>&1 | tee .terrabuild/run.log +group_args=() +if [[ -n "${TERRABUILD_GROUP:-}" ]]; then + group_args=(--group "$TERRABUILD_GROUP") +fi +terrabuild run "$@" --variable "config=${FSCRIPT_BUILD_CONFIG:-Release}" ${parallel_args[@]+"${parallel_args[@]}"} ${group_args[@]+"${group_args[@]}"} --out "$report" 2>&1 | tee .terrabuild/run.log # Terrabuild 0.200.1 can exit zero after reporting a failed graph. Require the # machine-readable completion status too, including for a fully cached run. if ! grep -Eq '"status"[[:space:]]*:[[:space:]]*"success"' "$report"; then diff --git a/.github/scripts/test-terrabuild.sh b/.github/scripts/test-terrabuild.sh index d13b7566..890365ff 100644 --- a/.github/scripts/test-terrabuild.sh +++ b/.github/scripts/test-terrabuild.sh @@ -36,6 +36,8 @@ exit 1 STUB chmod +x "$fixture/bin/terrabuild" "$fixture/bin/dotnet" export TB_TEST_ARGS="$fixture/args" TB_TEST_DOTNET="$fixture/dotnet-called" +# The ungrouped cases model normal local builds, even when tested inside CI. +unset TERRABUILD_GROUP run_build() { PATH="$fixture/bin" TB_TEST_RESULT="$1" "$make_command" --no-print-directory -C "$fixture/repo" \ @@ -67,6 +69,23 @@ if grep -q -- '--parallel' "$fixture/args"; then fi test "$(wc -l < "$fixture/args" | tr -d ' ')" = 8 +export TERRABUILD_GROUP='MagnusOpera/FScript:12345' +if ! run_build success ''; then cat "$fixture/log"; exit 1; fi +cat > "$fixture/expected-group-args" <<'ARGS' +run +build +--local-only +--force +--variable +config=Debug +--group +MagnusOpera/FScript:12345 +--out +.terrabuild/result.json +ARGS +diff -u "$fixture/expected-group-args" "$fixture/args" +unset TERRABUILD_GROUP + for result in failed-graph failed-process missing-report; do if run_build "$result"; then echo "ERROR: make build accepted $result." >&2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 364c9709..412e7c0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,9 @@ permissions: contents: read checks: write +env: + TERRABUILD_GROUP: ${{ github.repository }}:${{ github.run_id }} + jobs: build: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ded0ec..ad18e737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Report the GitHub build and all test shards as one Insights build group, including reruns. - Split GitHub CI into a cached build, parallel test shards, and combined test reports, including separate Native AOT variants. - Allow the native teapot debugger test to finish mesh generation on busy CI runners and report clearer event timeouts. - Define shared build-tool versions in WORKSPACE for container builds and GitHub workflows. diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index 51616cd4..fb26256d 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -269,7 +269,7 @@ Language owns the permission enum, native descriptor requirements, registration `make build` delegates to the Terrabuild executable on `PATH` through `.build/run-terrabuild.sh`, which checks both the process exit code and the JSON completion status. Missing Terrabuild or a failed graph fails the build without falling back to the host .NET SDK. -GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. Shards select GitHub-only `ci` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. +GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. Shards select GitHub-only `ci` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID group to Terrabuild, including reruns, so Insights correlates the workflow as one build group. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. Build-tool versions live in `WORKSPACE` under `locals.versions`. A toolchain phase passes the SDK and Node versions to Docker to create the local image with Clang and zlib headers. The toolchain includes `WORKSPACE` in its inputs so version changes invalidate its image identity. GitHub Actions reads the same version literals through the shared `workspace-versions` action before installing tools. The image bootstrap uses Docker on the host and never publishes an image. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index 4ccc03a4..f7f3fca8 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -69,6 +69,6 @@ The GitHub-only `.build/ci-shards.json` lists a static matrix. `.build/ci/*/PROJ | web | Fable, VS Code, and website tests | | integration | Debugger adapter, all sample smoke tests, and SDK compatibility | -The FFI categories partition its cases into managed tests and the two Native AOT variants; their results and cache entries are separate. Each shard's executing `ci` target consumes just its test outputs, restoring cached reports before artifact upload. Every runner retains its own diagnostics and test artifact. The join downloads the artifacts into separate directories, publishes combined TRX and JUnit summaries, and requires the build and every shard to succeed. A failed shard does not cancel the others. +The FFI categories partition its cases into managed tests and the two Native AOT variants; their results and cache entries are separate. Each shard's executing `ci` target consumes just its test outputs, restoring cached reports before artifact upload. Every runner retains its own diagnostics and test artifact. The join downloads the artifacts into separate directories, publishes combined TRX and JUnit summaries, and requires the build and every shard to succeed. A failed shard does not cancel the others. The shared workflow sets `TERRABUILD_GROUP` to `:`, following `matis-next`; the wrapper passes it explicitly with `--group` for the build and every shard. Reruns retain the same group so Insights shows one build group for the workflow. Local commands do not set a group by default. The baseline [successful run](https://github.com/MagnusOpera/FScript/actions/runs/42246670574) took 7m34s in Terrabuild. Its trace identified FFI tests at 5m32s, SDK compatibility at 1m59s, and the website build at 1m36s. The matrix separates those workloads and splits the sequential AOT pair. Runner setup, cache transfers, and the build barrier add overhead; compare subsequent GitHub timings and `terrabuild-logs-*` artifacts before claiming a speedup. From d3aed827d3623821adcbc882c688f964515148a3 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 17:22:57 +0100 Subject: [PATCH 09/17] fix: restore CI shard prerequisites on fresh runners --- .build/ci-shards.json | 14 +++++++------- .build/ci/web/PROJECT | 2 +- .build/ci/website/PROJECT | 9 +++++++++ .github/scripts/test-ci-shards.mjs | 17 +++++++++++++++-- CHANGELOG.md | 1 + WORKSPACE | 6 ++++++ docs/architecture/assemblies-and-roles.md | 2 +- docs/guides/terrabuild.md | 2 +- samples/ffi/Tests/PROJECT | 4 ++++ website/PROJECT | 8 ++++++++ 10 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 .build/ci/website/PROJECT diff --git a/.build/ci-shards.json b/.build/ci-shards.json index 17cc6355..82eabb2d 100644 --- a/.build/ci-shards.json +++ b/.build/ci-shards.json @@ -1,11 +1,11 @@ { "include": [ - { "name": "core", "projects": ["ci_core", "ci_ffi_managed"] }, - { "name": "backend", "projects": ["ci_backend"] }, - { "name": "cli", "projects": ["ci_cli"] }, - { "name": "ffi-default", "projects": ["ci_ffi_default"] }, - { "name": "ffi-size", "projects": ["ci_ffi_size"] }, - { "name": "web", "projects": ["ci_web"] }, - { "name": "integration", "projects": ["ci_integration"] } + {"name": "core", "projects": ["ci_core", "ci_ffi_managed"]}, + {"name": "backend", "projects": ["ci_backend"]}, + {"name": "cli", "projects": ["ci_cli"]}, + {"name": "ffi-default", "projects": ["ci_ffi_default"]}, + {"name": "ffi-size", "projects": ["ci_ffi_size"]}, + {"name": "web", "projects": ["ci_web", "ci_website"]}, + {"name": "integration", "projects": ["ci_integration"]} ] } diff --git a/.build/ci/web/PROJECT b/.build/ci/web/PROJECT index 6902b6cd..61df3bd2 100644 --- a/.build/ci/web/PROJECT +++ b/.build/ci/web/PROJECT @@ -1,5 +1,5 @@ project ci_web { - depends_on = [ project.javascript project.vscode project.website ] + depends_on = [ project.javascript project.vscode ] includes = [ "../../checks.sh" ] } diff --git a/.build/ci/website/PROJECT b/.build/ci/website/PROJECT new file mode 100644 index 00000000..5c4ef09f --- /dev/null +++ b/.build/ci/website/PROJECT @@ -0,0 +1,9 @@ +project ci_website { + depends_on = [ project.website ] + includes = [ "../../checks.sh" ] +} + +target ci { + depends_on = [ target.^test_ci ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.github/scripts/test-ci-shards.mjs b/.github/scripts/test-ci-shards.mjs index d58f6ff5..c48206aa 100644 --- a/.github/scripts/test-ci-shards.mjs +++ b/.github/scripts/test-ci-shards.mjs @@ -15,7 +15,7 @@ const expected = { cli: ['tests/fscript.cli.tests:test'], 'ffi-default': ['samples/ffi/tests:test_ffi_default'], 'ffi-size': ['samples/ffi/tests:test_ffi_size'], - web: ['src/fscript.javascript:test', 'vscode-fscript:test', 'website:test'], + web: ['src/fscript.javascript:test', 'vscode-fscript:test', 'website:test_ci'], integration: ['tests/fscript.debugadapter.tests:test', 'tests/fscript.debugadapter.tests:smoke', 'tests/fscript.debugadapter.tests:sdk'] }; @@ -53,13 +53,26 @@ test('static CI shards select exactly their checks, restore reports, and cover e '--dry-run', '--local-only', '--debug'], { stdio: 'pipe', timeout: 30000 }); const graph = JSON.parse(fs.readFileSync('terrabuild-debug.json', 'utf8')); const selected = graph.nodes.filter(node => node.selected); - const checks = selected.filter(node => /^(test(?:_ffi_.*)?|smoke|sdk)$/.test(node.target)); + const checks = selected.filter(node => /^(test(?:_ffi_.*|_ci)?|smoke|sdk)$/.test(node.target)); const names = checks.map(node => `${node.projectDirectory.toLowerCase()}:${node.target}`).sort(); assert.deepEqual(names, expected[shard.name].toSorted(), shard.name); assert.ok(selected.some(node => node.target === 'ci' && node.build === 'always'), shard.name); for (const consumer of selected.filter(node => node.target === 'ci')) { assert.ok(consumer.resolvedOperations.length > 0, 'Cached reports need an executing consumer.'); } + for (const check of checks) { + const direct = check.dependencies.map(id => graph.nodes.find(node => node.id === id)); + if (check.projectName === 'ffisample_tests') { + assert.ok(direct.some(node => node?.projectName === 'fscript' && node.target === 'build'), + `${check.target} must directly restore the CLI even when its own build is cached.`); + } + if (check.projectName === 'website') { + assert.equal(check.target, 'test_ci'); + assert.deepEqual(check.resolvedOperations.map(operation => operation.metaCommand), ['@npm install', '@npm run'], + 'The CI website target must recreate npm launchers before typechecking.'); + assert.ok(direct.some(node => node?.projectName === 'javascript' && node.target === 'build')); + } + } covered.push(...checks.filter(node => node.target.startsWith('test')).map(node => `${node.projectDirectory.toLowerCase()}:test`)); } diff --git a/CHANGELOG.md b/CHANGELOG.md index ad18e737..66b4a5c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Restore the CLI and website test dependencies when CI shards start with cached builds on fresh runners. - Report the GitHub build and all test shards as one Insights build group, including reruns. - Split GitHub CI into a cached build, parallel test shards, and combined test reports, including separate Native AOT variants. - Allow the native teapot debugger test to finish mesh generation on busy CI runners and report clearer event timeouts. diff --git a/WORKSPACE b/WORKSPACE index 271a2360..d81023fd 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -85,6 +85,12 @@ target test_ffi_size { depends_on = [ target.build ] } +target test_ci { + phase = phase.application + artifacts = ~managed + depends_on = [ target.build target.^build ] +} + extension @dotnet { image = "fscript-build:${project.toolchain.version}" platform = "linux/${terrabuild.arch}" diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index fb26256d..ccc12396 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -269,7 +269,7 @@ Language owns the permission enum, native descriptor requirements, registration `make build` delegates to the Terrabuild executable on `PATH` through `.build/run-terrabuild.sh`, which checks both the process exit code and the JSON completion status. Missing Terrabuild or a failed graph fails the build without falling back to the host .NET SDK. -GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. Shards select GitHub-only `ci` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID group to Terrabuild, including reruns, so Insights correlates the workflow as one build group. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. +GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. Shards select GitHub-only `ci` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. FFI test targets directly consume referenced builds to restore the CLI on fresh runners; the GitHub-only website test target consumes referenced builds and reinstalls Node dependencies before typechecking to recreate launcher symlinks. The local website test target is unchanged. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID group to Terrabuild, including reruns, so Insights correlates the workflow as one build group. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. Build-tool versions live in `WORKSPACE` under `locals.versions`. A toolchain phase passes the SDK and Node versions to Docker to create the local image with Clang and zlib headers. The toolchain includes `WORKSPACE` in its inputs so version changes invalidate its image identity. GitHub Actions reads the same version literals through the shared `workspace-versions` action before installing tools. The image bootstrap uses Docker on the host and never publishes an image. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index f7f3fca8..20b8d46f 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -37,7 +37,7 @@ The toolchain phase builds `.build/toolchain/Dockerfile` with the .NET SDK and N Terrabuild stops discovery at a `PROJECT` file. The FFI sample therefore keeps scripts and `fscript.toml` at `samples/ffi`, with sibling `Provider`, `Renderer`, and `Tests` projects below it. Build the provider directly with `dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release` when using the existing host workflow. -Project-reference dependencies build before their consumers. Tests run already-built binaries with `--no-build --no-restore`. Conditional sibling-SDK project references are declared explicitly in the graph; ordinary builds use the published SDK package. Non-.NET checks declare their inputs and upstream builds too. The SDK compatibility check runs in a temporary source copy because it deliberately publishes different assembly versions. +Project-reference dependencies build before their consumers. Tests run already-built binaries with `--no-build --no-restore`. Tests that launch another project's executable must directly depend on that project's build target, not just their own cached build. FFI test targets consume `target.^build` to restore the CLI. The GitHub-only website `test_ci` target directly consumes `target.^build` for generated JavaScript and runs `npm ci` before typechecking to recreate package launcher symlinks on a fresh runner. Its normal local `test` target is unchanged. Conditional sibling-SDK project references are declared explicitly in the graph; ordinary builds use the published SDK package. Non-.NET checks declare their inputs and upstream builds too. The SDK compatibility check runs in a temporary source copy because it deliberately publishes different assembly versions. Builds use the normal `bin`, `obj`, `node_modules`, and generated website directories on the mounted checkout. They contain Linux outputs after a container run. Rebuild through the host toolchain before relying on native host apphosts or platform-specific Node dependencies. Avoid running host and container builds concurrently in the same checkout. diff --git a/samples/ffi/Tests/PROJECT b/samples/ffi/Tests/PROJECT index ef30bf57..cf14952a 100644 --- a/samples/ffi/Tests/PROJECT +++ b/samples/ffi/Tests/PROJECT @@ -9,11 +9,13 @@ target build { } target test { + depends_on = [ target.^build ] outputs = [ "TestResults/" ] @dotnet test { build = false restore = false args = "--results-directory TestResults" } } target test_ffi_managed { + depends_on = [ target.^build ] outputs = [ "TestResults/managed/" ] @dotnet test { build = false restore = false @@ -23,6 +25,7 @@ target test_ffi_managed { } target test_ffi_default { + depends_on = [ target.^build ] outputs = [ "TestResults/aot-default/" ] @dotnet test { build = false restore = false filter = "TestCategory=NativeAotDefault" @@ -31,6 +34,7 @@ target test_ffi_default { } target test_ffi_size { + depends_on = [ target.^build ] outputs = [ "TestResults/aot-size/" ] @dotnet test { build = false restore = false filter = "TestCategory=NativeAotSize" diff --git a/website/PROJECT b/website/PROJECT index 892ab500..6b4be808 100644 --- a/website/PROJECT +++ b/website/PROJECT @@ -19,3 +19,11 @@ target test { outputs = [] @npm run { target = "typecheck" } } + +# A restored node_modules tree may not retain package launcher symlinks. +# Recreate it on the fresh CI runner before invoking the compiler. +target test_ci { + outputs = [] + @npm install { } + @npm run { target = "typecheck" } +} From 6012a4531a3afae9cc97d3eb15573fdce8235db2 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 17:44:23 +0100 Subject: [PATCH 10/17] ci: share toolchain images through GitHub Container Registry --- .build/toolchain/Dockerfile | 2 + .build/toolchain/PROJECT | 9 ++- .build/toolchain/image.sh | 28 +++++++++ .github/actions/setup-tools/action.yml | 11 ++++ .github/scripts/test-toolchain-image.mjs | 63 +++++++++++++++++++++ .github/scripts/test-workspace-versions.mjs | 15 ++++- .github/workflows/ci-main.yml | 1 + .github/workflows/ci-pr.yml | 1 + .github/workflows/ci.yml | 11 ++++ CHANGELOG.md | 1 + Makefile | 1 + WORKSPACE | 6 +- docs/architecture/assemblies-and-roles.md | 2 +- docs/guides/terrabuild.md | 10 ++-- 14 files changed, 145 insertions(+), 16 deletions(-) create mode 100644 .build/toolchain/image.sh create mode 100644 .github/scripts/test-toolchain-image.mjs diff --git a/.build/toolchain/Dockerfile b/.build/toolchain/Dockerfile index 7579023b..f0180a8b 100644 --- a/.build/toolchain/Dockerfile +++ b/.build/toolchain/Dockerfile @@ -5,6 +5,8 @@ ARG DOTNET_SDK_VERSION FROM node:${NODE_VERSION}-bookworm-slim AS node FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_SDK_VERSION} +LABEL org.opencontainers.image.source="https://github.com/MagnusOpera/FScript" + # AOT integration tests invoke the native compiler. Node is also needed by the # debugger tests, Fable package, VS Code extension, and documentation build. RUN apt-get update \ diff --git a/.build/toolchain/PROJECT b/.build/toolchain/PROJECT index 435d44ed..357773c2 100644 --- a/.build/toolchain/PROJECT +++ b/.build/toolchain/PROJECT @@ -1,15 +1,14 @@ project toolchain { labels = [ "toolchain" ] - includes = [ "**/*" "Dockerfile" "../../WORKSPACE" ] + includes = [ "Dockerfile" "PROJECT" "image.sh" ] } -# Always check/build the local image. Docker reuses its layers. Phase barriers do -# not force cached application targets to execute, and no image is pushed. +# CI builds publish missing images; shards only pull. Local runs build locally. target image { phase = phase.toolchains build = ~always artifacts = ~none - @shell docker { - args = "build --build-arg DOTNET_SDK_VERSION=${local.versions.dotnet_sdk} --build-arg NODE_VERSION=${local.versions.node} --tag fscript-build:${terrabuild.version} ." + @shell bash { + args = "image.sh ghcr.io/magnusopera/fscript-toolchain:${terrabuild.version}-sdk${local.versions.dotnet_sdk}-node${local.versions.node}-${terrabuild.arch} ${local.versions.dotnet_sdk} ${local.versions.node} linux/${terrabuild.arch}" } } diff --git a/.build/toolchain/image.sh b/.build/toolchain/image.sh new file mode 100644 index 00000000..816338c1 --- /dev/null +++ b/.build/toolchain/image.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +image=${1:?Expected image reference} +sdk=${2:?Expected .NET SDK version} +node=${3:?Expected Node version} +platform=${4:?Expected target platform} +mode=${FSCRIPT_TOOLCHAIN_MODE:-local} +case "$mode" in + local|publish|pull) ;; + *) echo "Unknown toolchain mode: $mode" >&2; exit 2 ;; +esac + +if docker image inspect "$image" > /dev/null 2>&1; then + if [[ "$mode" == publish ]]; then docker push "$image"; fi + exit 0 +fi + +if [[ "$mode" != local ]]; then + if docker pull "$image"; then exit 0; fi + if [[ "$mode" == pull ]]; then + echo "The build job must publish toolchain image $image before test shards start." >&2 + exit 1 + fi +fi + +docker build --platform "$platform" --build-arg "DOTNET_SDK_VERSION=$sdk" --build-arg "NODE_VERSION=$node" --tag "$image" . +if [[ "$mode" == publish ]]; then docker push "$image"; fi diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml index 5f0cf670..8ad422ac 100644 --- a/.github/actions/setup-tools/action.yml +++ b/.github/actions/setup-tools/action.yml @@ -17,6 +17,9 @@ inputs: TERRABUILD_MASTER_KEY: description: Terrabuild master key for encryption required: false + GITHUB_TOKEN: + description: GitHub token for pulling and publishing toolchain images + required: false outputs: connected: @@ -37,6 +40,14 @@ runs: tag: ${{ inputs.terrabuild_version || steps.tool-versions.outputs.terrabuild }} prerelease: ${{ inputs.terrabuild_next }} + - name: Login to GitHub Container Registry + if: inputs.GITHUB_TOKEN != '' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ inputs.GITHUB_TOKEN }} + - name: Configure Terrabuild caching id: cache shell: bash diff --git a/.github/scripts/test-toolchain-image.mjs b/.github/scripts/test-toolchain-image.mjs new file mode 100644 index 00000000..e16d0c2e --- /dev/null +++ b/.github/scripts/test-toolchain-image.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +test('toolchain publishing, pulling, local fallback, and registry failures', () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-toolchain-image-')); + try { + fs.mkdirSync(path.join(fixture, 'bin')); + const calls = path.join(fixture, 'calls'); + fs.writeFileSync(path.join(fixture, 'bin/docker'), `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$DOCKER_FIXTURE_CALLS" +case "$1" in + image) exit "\${DOCKER_FIXTURE_LOCAL:-1}" ;; + pull) exit "\${DOCKER_FIXTURE_PULL:-1}" ;; + push) exit "\${DOCKER_FIXTURE_PUSH:-0}" ;; + build) exit "\${DOCKER_FIXTURE_BUILD:-0}" ;; + *) exit 2 ;; +esac +`, { mode: 0o755 }); + const script = fileURLToPath(new URL('../../.build/toolchain/image.sh', import.meta.url)); + const image = 'ghcr.io/example/toolchain:content-sdk10-node22-amd64'; + function run(mode, extra = {}) { + fs.writeFileSync(calls, ''); + const result = spawnSync('bash', [script, image, '10.0.403', '22', 'linux/amd64'], { + cwd: fixture, encoding: 'utf8', + env: { ...process.env, PATH: `${fixture}/bin:${process.env.PATH}`, DOCKER_FIXTURE_CALLS: calls, + FSCRIPT_TOOLCHAIN_MODE: mode, ...extra } + }); + return { ...result, calls: fs.readFileSync(calls, 'utf8').trim().split('\n') }; + } + const published = run('publish'); + assert.equal(published.status, 0, published.stderr); + assert.deepEqual(published.calls, [ + `image inspect ${image}`, `pull ${image}`, + `build --platform linux/amd64 --build-arg DOTNET_SDK_VERSION=10.0.403 --build-arg NODE_VERSION=22 --tag ${image} .`, + `push ${image}` + ]); + const reused = run('publish', { DOCKER_FIXTURE_PULL: '0' }); + assert.equal(reused.status, 0); + assert.deepEqual(reused.calls, [`image inspect ${image}`, `pull ${image}`]); + const pulled = run('pull', { DOCKER_FIXTURE_PULL: '0' }); + assert.equal(pulled.status, 0); + assert.deepEqual(pulled.calls, reused.calls); + const missing = run('pull'); + assert.notEqual(missing.status, 0); + assert.deepEqual(missing.calls, reused.calls, 'A test runner must never rebuild or push a missing image.'); + const local = run('local'); + assert.equal(local.status, 0); + assert.ok(!local.calls.some(command => command.startsWith('push ') || command.startsWith('pull '))); + const alreadyLocal = run('local', { DOCKER_FIXTURE_LOCAL: '0' }); + assert.equal(alreadyLocal.status, 0); + assert.deepEqual(alreadyLocal.calls, [`image inspect ${image}`]); + assert.equal(run('publish', { DOCKER_FIXTURE_BUILD: '7' }).status, 7); + assert.equal(run('publish', { DOCKER_FIXTURE_PUSH: '8' }).status, 8); + assert.notEqual(run('unknown').status, 0); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); diff --git a/.github/scripts/test-workspace-versions.mjs b/.github/scripts/test-workspace-versions.mjs index 78b30dbb..c1a35ec5 100644 --- a/.github/scripts/test-workspace-versions.mjs +++ b/.github/scripts/test-workspace-versions.mjs @@ -37,12 +37,12 @@ test('missing, duplicate, or computed versions fail before tool installation', ( assert.throws(() => readToolVersions(source.replace('node: "99"', 'node: "$(whoami)"')), /quoted literal values/); }); -test('changing a workspace tool version changes the container cache identity', () => { +test('toolchain identity follows tool inputs without following unrelated workspace edits', () => { const root = fileURLToPath(new URL('../../', import.meta.url)); const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-toolchain-identity-')); try { fs.mkdirSync(path.join(fixture, '.build/toolchain'), { recursive: true }); - for (const file of ['WORKSPACE', '.build/toolchain/PROJECT', '.build/toolchain/Dockerfile']) { + for (const file of ['WORKSPACE', '.build/toolchain/PROJECT', '.build/toolchain/Dockerfile', '.build/toolchain/image.sh']) { fs.copyFileSync(path.join(root, file), path.join(fixture, file)); } fs.mkdirSync(path.join(fixture, 'app')); @@ -53,14 +53,23 @@ test('changing a workspace tool version changes the container cache identity', ( execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--quiet', '-m', 'fixture'], { cwd: fixture }); const image = () => { const output = execFileSync('terrabuild', ['explain', 'build', '--local-only'], { cwd: fixture, encoding: 'utf8', timeout: 30000 }); - const match = output.match(/container: (fscript-build:[A-F0-9]+)/); + const match = output.match(/container: (ghcr\.io\/magnusopera\/fscript-toolchain:[^\s]+)/); assert.ok(match, 'The build must use the workspace toolchain image.'); return match[1]; }; const original = image(); const workspace = path.join(fixture, 'WORKSPACE'); + const contents = fs.readFileSync(workspace, 'utf8'); + fs.writeFileSync(workspace, contents + '\ntarget unrelated_ci_check { depends_on = [] }\n'); + assert.equal(image(), original, 'An unrelated target must not invalidate every build.'); fs.writeFileSync(workspace, fs.readFileSync(workspace, 'utf8').replace(/dotnet_sdk: "[^"]+"/, 'dotnet_sdk: "10.0.999"')); assert.notEqual(image(), original); + fs.writeFileSync(workspace, contents.replace(/node: "[^"]+"/, 'node: "99"')); + assert.notEqual(image(), original); + fs.writeFileSync(workspace, contents); + const dockerfile = path.join(fixture, '.build/toolchain/Dockerfile'); + fs.appendFileSync(dockerfile, '\nENV TOOLCHAIN_IDENTITY_REGRESSION=1\n'); + assert.notEqual(image(), original); } finally { fs.rmSync(fixture, { recursive: true, force: true }); } diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index ef98e564..71caee9d 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -9,6 +9,7 @@ on: permissions: contents: read checks: write + packages: write jobs: ci: diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 513b742e..43516092 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -13,6 +13,7 @@ on: permissions: contents: read checks: write + packages: write jobs: ci: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 412e7c0c..2a65effe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,12 +18,19 @@ on: permissions: contents: read checks: write + packages: read env: TERRABUILD_GROUP: ${{ github.repository }}:${{ github.run_id }} jobs: build: + permissions: + contents: read + checks: write + packages: write + env: + FSCRIPT_TOOLCHAIN_MODE: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'local' || 'publish' }} runs-on: ubuntu-latest timeout-minutes: 25 outputs: @@ -45,6 +52,7 @@ jobs: terrabuild_workspace: "6ae115ba87040d459b6ccf40" TERRABUILD_TOKEN: ${{ secrets.TERRABUILD_TOKEN }} TERRABUILD_MASTER_KEY: ${{ secrets.TERRABUILD_MASTER_KEY }} + GITHUB_TOKEN: ${{ github.token }} - name: Verify CI integration and shard coverage run: | make test-terrabuild @@ -69,6 +77,8 @@ jobs: name: Tests (${{ matrix.name }}) runs-on: ubuntu-latest timeout-minutes: 25 + env: + FSCRIPT_TOOLCHAIN_MODE: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'local' || 'pull' }} strategy: fail-fast: false matrix: ${{ fromJSON(needs.build.outputs.matrix) }} @@ -83,6 +93,7 @@ jobs: terrabuild_workspace: "6ae115ba87040d459b6ccf40" TERRABUILD_TOKEN: ${{ secrets.TERRABUILD_TOKEN }} TERRABUILD_MASTER_KEY: ${{ secrets.TERRABUILD_MASTER_KEY }} + GITHUB_TOKEN: ${{ github.token }} - name: Run selected tests and restore their reports env: CI_SHARD: ${{ matrix.name }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b4a5c3..e61ace0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Reuse GHCR toolchain images across CI runners and avoid invalidating builds for unrelated workspace edits. - Restore the CLI and website test dependencies when CI shards start with cached builds on fresh runners. - Report the GitHub build and all test shards as one Insights build group, including reruns. - Split GitHub CI into a cached build, parallel test shards, and combined test reports, including separate Native AOT variants. diff --git a/Makefile b/Makefile index 1106e0c1..2423f26c 100644 --- a/Makefile +++ b/Makefile @@ -130,6 +130,7 @@ test-terrabuild: bash .github/scripts/test-node-reports.sh node --test .github/scripts/test-collect-results.mjs node --test .github/scripts/test-workspace-versions.mjs + node --test .github/scripts/test-toolchain-image.mjs tb-build: FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh build $(TB_FLAGS) diff --git a/WORKSPACE b/WORKSPACE index d81023fd..5f3b19e0 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -92,20 +92,20 @@ target test_ci { } extension @dotnet { - image = "fscript-build:${project.toolchain.version}" + image = "ghcr.io/magnusopera/fscript-toolchain:${project.toolchain.version}-sdk${local.versions.dotnet_sdk}-node${local.versions.node}-${terrabuild.arch}" platform = "linux/${terrabuild.arch}" defaults { configuration = var.config } } extension @npm { - image = "fscript-build:${project.toolchain.version}" + image = "ghcr.io/magnusopera/fscript-toolchain:${project.toolchain.version}-sdk${local.versions.dotnet_sdk}-node${local.versions.node}-${terrabuild.arch}" platform = "linux/${terrabuild.arch}" env { CI = "true" } } extension workflow { script = ".build/workflow.fss" - image = "fscript-build:${project.toolchain.version}" + image = "ghcr.io/magnusopera/fscript-toolchain:${project.toolchain.version}-sdk${local.versions.dotnet_sdk}-node${local.versions.node}-${terrabuild.arch}" platform = "linux/${terrabuild.arch}" env { FSCRIPT_BUILD_CONFIG = var.config } } diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index ccc12396..a7064259 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -271,6 +271,6 @@ Language owns the permission enum, native descriptor requirements, registration GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. Shards select GitHub-only `ci` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. FFI test targets directly consume referenced builds to restore the CLI on fresh runners; the GitHub-only website test target consumes referenced builds and reinstalls Node dependencies before typechecking to recreate launcher symlinks. The local website test target is unchanged. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID group to Terrabuild, including reruns, so Insights correlates the workflow as one build group. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. -The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. Build-tool versions live in `WORKSPACE` under `locals.versions`. A toolchain phase passes the SDK and Node versions to Docker to create the local image with Clang and zlib headers. The toolchain includes `WORKSPACE` in its inputs so version changes invalidate its image identity. GitHub Actions reads the same version literals through the shared `workspace-versions` action before installing tools. The image bootstrap uses Docker on the host and never publishes an image. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. +The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. Build-tool versions live in `WORKSPACE` under `locals.versions`. A toolchain phase passes the SDK and Node versions to Docker to create the local image with Clang and zlib headers. The toolchain source hash plus explicit SDK, Node, and architecture tag components determine image identity, so unrelated workspace target changes do not invalidate application builds. GitHub Actions reads the same version literals through the shared `workspace-versions` action before installing tools. The image bootstrap uses Docker on the host. Trusted GitHub build jobs publish missing toolchain images to GHCR, and test shards pull them with read-only package access. Local and fork-PR builds retain local image preparation without publishing. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. Build outputs and test reports are declared per target. Tests depend on their build outputs and use `--no-build --no-restore`; build actions avoid rebuilding project references already represented by graph dependencies. The independent SDK check copies current source to a temporary directory before publishing different product versions, avoiding changes to outputs used by concurrent tests. Native AOT integration tests remain part of the normal graph. Graph execution and local cache reuse need no Insights account; shared caching is configured separately. See [Terrabuild workflow](../guides/terrabuild.md). diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index 20b8d46f..133fc243 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -1,6 +1,6 @@ # Container-backed builds with Terrabuild -Install the latest stable Terrabuild release and a working Docker engine. The graph builds its toolchain image automatically. .NET, Node, and Clang are supplied by that image; they need not be installed on the host for these targets. +Install the latest stable Terrabuild release and a working Docker engine. The graph prepares its toolchain image automatically. Trusted GitHub builds publish missing images to GHCR, and test shards pull them. .NET, Node, and Clang are supplied by that image; they need not be installed on the host for these targets. ```sh # Build every .NET project, the JavaScript package, extension bundle, and website. @@ -29,11 +29,11 @@ The Make targets check Terrabuild's JSON completion status as well as its proces `locals.versions` in `WORKSPACE` defines the .NET SDK, Node, Terrabuild release selector, VS Code packaging tool, and Open VSX publishing tool. Change these quoted literal values in one place. Terrabuild defaults to `latest`. -The toolchain project passes the SDK and Node versions as Docker build arguments. `WORKSPACE` is an explicit toolchain input, so changing a version also changes the image cache identity. The shared `workspace-versions` GitHub action reads the same values before tool installation; release jobs read them from the checked-out release tag. The reader fails on missing, duplicate, or computed version values rather than choosing a fallback. +The toolchain project passes the SDK and Node versions as Docker build arguments. The image tag combines the toolchain source hash, SDK version, Node version, and architecture. Version changes invalidate the image, while unrelated `WORKSPACE` target edits do not. The shared `workspace-versions` GitHub action reads the same values before tool installation; release jobs read them from the checked-out release tag. The reader fails on missing, duplicate, or computed version values rather than choosing a fallback. ## Containers and project layout -The toolchain phase builds `.build/toolchain/Dockerfile` with the .NET SDK and Node versions from `WORKSPACE`, plus Clang and zlib headers. Only this Docker image bootstrap runs on the host. `@dotnet`, `@npm`, and the custom `workflow` extension all specify the toolchain image and an explicit Linux architecture. No registry push or deployment occurs. Persistent MSBuild nodes and shared compiler servers are disabled in the image because CLI tests capture subprocess output; retained server processes can keep those pipes open after a command exits. +The toolchain phase builds `.build/toolchain/Dockerfile` with the .NET SDK and Node versions from `WORKSPACE`, plus Clang and zlib headers. Only this Docker image preparation runs on the host. `@dotnet`, `@npm`, and the custom `workflow` extension all specify the GHCR toolchain image and an explicit Linux architecture. Images use `ghcr.io/magnusopera/fscript-toolchain` and carry the source repository label. No application deployment occurs. Persistent MSBuild nodes and shared compiler servers are disabled in the image because CLI tests capture subprocess output; retained server processes can keep those pipes open after a command exits. Terrabuild stops discovery at a `PROJECT` file. The FFI sample therefore keeps scripts and `fscript.toml` at `samples/ffi`, with sibling `Provider`, `Renderer`, and `Tests` projects below it. Build the provider directly with `dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release` when using the existing host workflow. @@ -47,7 +47,7 @@ The workspace ID is configured in `WORKSPACE`. Local caching works without an In The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install the latest stable Terrabuild release and configure authentication. Both call the shared `ci.yml` workflow, which builds once, scatters tests across seven runners, and joins the results. The build job runs `make build`; each test runner requests only its shard's `ci` target and lets Terrabuild restore prerequisite outputs from the shared cache. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching and rebuilds prerequisites on each test runner; cache sharing across jobs requires both credentials. The shared `collect-build-results` action collects the latest TRX result from each test output directory and all Node JUnit files into `.out/test-results`, uploads them and publishes both report formats in the GitHub build summary, including after a failed build. Fable, VS Code, and debugger tests retain console output while also writing JUnit XML. A reporting target consumes test outputs so cached reports are restored before collection. Terrabuild diagnostics, its JSON completion report, and captured console output are uploaded as a separate `terrabuild-logs-*` artifact. Its `terrabuild-debug.json` includes performance timings, slowest tasks, the critical chain, and FScript performance data for build optimization. -Managed build outputs and successful test results are cacheable separately. npm dependencies are kept in the local workspace cache. When application work must execute, the toolchain bootstrap checks the image using Docker layer caching. Fully cached requests skip execution; the phase barrier does not force application cache hits to rebuild. The configured Linux architecture separates ARM64 and AMD64 artifacts. +Managed build outputs and successful test results are cacheable separately. npm dependencies are kept in the local workspace cache. When application work must execute, the toolchain bootstrap checks the local image first. In trusted GitHub build jobs it pulls an existing GHCR image or builds and publishes a missing one; test shards only pull and fail if the build job has not published the required image. The shared `setup-tools` action authenticates Docker with `GITHUB_TOKEN`. Only the build job has package write permission; test jobs have package read permission. Fork PRs and local commands use local Docker builds without pushing. Fully cached requests skip execution; the phase barrier does not force application cache hits to rebuild. The configured Linux architecture separates ARM64 and AMD64 artifacts. Use `terrabuild explain build test --local-only` to inspect cache decisions. Repeating `make tb-check TB_FLAGS=--local-only` should reuse successful unchanged work. Use `TB_FLAGS='--local-only --force'` to execute deliberately, or `--retry` to rerun a failed cached task after fixing its environment. @@ -72,3 +72,5 @@ The GitHub-only `.build/ci-shards.json` lists a static matrix. `.build/ci/*/PROJ The FFI categories partition its cases into managed tests and the two Native AOT variants; their results and cache entries are separate. Each shard's executing `ci` target consumes just its test outputs, restoring cached reports before artifact upload. Every runner retains its own diagnostics and test artifact. The join downloads the artifacts into separate directories, publishes combined TRX and JUnit summaries, and requires the build and every shard to succeed. A failed shard does not cancel the others. The shared workflow sets `TERRABUILD_GROUP` to `:`, following `matis-next`; the wrapper passes it explicitly with `--group` for the build and every shard. Reruns retain the same group so Insights shows one build group for the workflow. Local commands do not set a group by default. The baseline [successful run](https://github.com/MagnusOpera/FScript/actions/runs/42246670574) took 7m34s in Terrabuild. Its trace identified FFI tests at 5m32s, SDK compatibility at 1m59s, and the website build at 1m36s. The matrix separates those workloads and splits the sequential AOT pair. Runner setup, cache transfers, and the build barrier add overhead; compare subsequent GitHub timings and `terrabuild-logs-*` artifacts before claiming a speedup. + +Toolchain preparation reads `FSCRIPT_TOOLCHAIN_MODE` from the workflow runner: `publish` for trusted build jobs, `pull` for their test shards, and `local` by default. This changes image preparation only; application commands and their container identity remain the same. Cold CI runs publish an image once per toolchain identity; subsequent matrix runners pull that immutable tag rather than compiling the toolchain again. From 68501baa50ae93fa805d35a45aed8404f91d974e Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 17:54:30 +0100 Subject: [PATCH 11/17] ci: use native Terrabuild Docker publishing and hash tags --- .build/toolchain/PROJECT | 15 ++--- .build/toolchain/image.sh | 28 --------- .github/scripts/test-toolchain-image.mjs | 63 --------------------- .github/scripts/test-workspace-versions.mjs | 6 +- .github/workflows/ci.yml | 4 -- CHANGELOG.md | 1 + Makefile | 1 - WORKSPACE | 19 ++++++- docs/architecture/assemblies-and-roles.md | 2 +- docs/guides/terrabuild.md | 6 +- 10 files changed, 31 insertions(+), 114 deletions(-) delete mode 100644 .build/toolchain/image.sh delete mode 100644 .github/scripts/test-toolchain-image.mjs diff --git a/.build/toolchain/PROJECT b/.build/toolchain/PROJECT index 357773c2..f66289f6 100644 --- a/.build/toolchain/PROJECT +++ b/.build/toolchain/PROJECT @@ -1,14 +1,15 @@ project toolchain { labels = [ "toolchain" ] - includes = [ "Dockerfile" "PROJECT" "image.sh" ] + includes = [ "**/*" "../../WORKSPACE" ] + @docker { } } -# CI builds publish missing images; shards only pull. Local runs build locally. -target image { +target dist { phase = phase.toolchains - build = ~always - artifacts = ~none - @shell bash { - args = "image.sh ghcr.io/magnusopera/fscript-toolchain:${terrabuild.version}-sdk${local.versions.dotnet_sdk}-node${local.versions.node}-${terrabuild.arch} ${local.versions.dotnet_sdk} ${local.versions.node} linux/${terrabuild.arch}" + @docker build { + build_args = { + DOTNET_SDK_VERSION: local.versions.dotnet_sdk + NODE_VERSION: local.versions.node + } } } diff --git a/.build/toolchain/image.sh b/.build/toolchain/image.sh deleted file mode 100644 index 816338c1..00000000 --- a/.build/toolchain/image.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -image=${1:?Expected image reference} -sdk=${2:?Expected .NET SDK version} -node=${3:?Expected Node version} -platform=${4:?Expected target platform} -mode=${FSCRIPT_TOOLCHAIN_MODE:-local} -case "$mode" in - local|publish|pull) ;; - *) echo "Unknown toolchain mode: $mode" >&2; exit 2 ;; -esac - -if docker image inspect "$image" > /dev/null 2>&1; then - if [[ "$mode" == publish ]]; then docker push "$image"; fi - exit 0 -fi - -if [[ "$mode" != local ]]; then - if docker pull "$image"; then exit 0; fi - if [[ "$mode" == pull ]]; then - echo "The build job must publish toolchain image $image before test shards start." >&2 - exit 1 - fi -fi - -docker build --platform "$platform" --build-arg "DOTNET_SDK_VERSION=$sdk" --build-arg "NODE_VERSION=$node" --tag "$image" . -if [[ "$mode" == publish ]]; then docker push "$image"; fi diff --git a/.github/scripts/test-toolchain-image.mjs b/.github/scripts/test-toolchain-image.mjs deleted file mode 100644 index e16d0c2e..00000000 --- a/.github/scripts/test-toolchain-image.mjs +++ /dev/null @@ -1,63 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { test } from 'node:test'; -import { fileURLToPath } from 'node:url'; - -test('toolchain publishing, pulling, local fallback, and registry failures', () => { - const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-toolchain-image-')); - try { - fs.mkdirSync(path.join(fixture, 'bin')); - const calls = path.join(fixture, 'calls'); - fs.writeFileSync(path.join(fixture, 'bin/docker'), `#!/usr/bin/env bash -printf '%s\\n' "$*" >> "$DOCKER_FIXTURE_CALLS" -case "$1" in - image) exit "\${DOCKER_FIXTURE_LOCAL:-1}" ;; - pull) exit "\${DOCKER_FIXTURE_PULL:-1}" ;; - push) exit "\${DOCKER_FIXTURE_PUSH:-0}" ;; - build) exit "\${DOCKER_FIXTURE_BUILD:-0}" ;; - *) exit 2 ;; -esac -`, { mode: 0o755 }); - const script = fileURLToPath(new URL('../../.build/toolchain/image.sh', import.meta.url)); - const image = 'ghcr.io/example/toolchain:content-sdk10-node22-amd64'; - function run(mode, extra = {}) { - fs.writeFileSync(calls, ''); - const result = spawnSync('bash', [script, image, '10.0.403', '22', 'linux/amd64'], { - cwd: fixture, encoding: 'utf8', - env: { ...process.env, PATH: `${fixture}/bin:${process.env.PATH}`, DOCKER_FIXTURE_CALLS: calls, - FSCRIPT_TOOLCHAIN_MODE: mode, ...extra } - }); - return { ...result, calls: fs.readFileSync(calls, 'utf8').trim().split('\n') }; - } - const published = run('publish'); - assert.equal(published.status, 0, published.stderr); - assert.deepEqual(published.calls, [ - `image inspect ${image}`, `pull ${image}`, - `build --platform linux/amd64 --build-arg DOTNET_SDK_VERSION=10.0.403 --build-arg NODE_VERSION=22 --tag ${image} .`, - `push ${image}` - ]); - const reused = run('publish', { DOCKER_FIXTURE_PULL: '0' }); - assert.equal(reused.status, 0); - assert.deepEqual(reused.calls, [`image inspect ${image}`, `pull ${image}`]); - const pulled = run('pull', { DOCKER_FIXTURE_PULL: '0' }); - assert.equal(pulled.status, 0); - assert.deepEqual(pulled.calls, reused.calls); - const missing = run('pull'); - assert.notEqual(missing.status, 0); - assert.deepEqual(missing.calls, reused.calls, 'A test runner must never rebuild or push a missing image.'); - const local = run('local'); - assert.equal(local.status, 0); - assert.ok(!local.calls.some(command => command.startsWith('push ') || command.startsWith('pull '))); - const alreadyLocal = run('local', { DOCKER_FIXTURE_LOCAL: '0' }); - assert.equal(alreadyLocal.status, 0); - assert.deepEqual(alreadyLocal.calls, [`image inspect ${image}`]); - assert.equal(run('publish', { DOCKER_FIXTURE_BUILD: '7' }).status, 7); - assert.equal(run('publish', { DOCKER_FIXTURE_PUSH: '8' }).status, 8); - assert.notEqual(run('unknown').status, 0); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } -}); diff --git a/.github/scripts/test-workspace-versions.mjs b/.github/scripts/test-workspace-versions.mjs index c1a35ec5..c707e231 100644 --- a/.github/scripts/test-workspace-versions.mjs +++ b/.github/scripts/test-workspace-versions.mjs @@ -37,12 +37,12 @@ test('missing, duplicate, or computed versions fail before tool installation', ( assert.throws(() => readToolVersions(source.replace('node: "99"', 'node: "$(whoami)"')), /quoted literal values/); }); -test('toolchain identity follows tool inputs without following unrelated workspace edits', () => { +test('native toolchain tags follow workspace tool versions and Dockerfile inputs', () => { const root = fileURLToPath(new URL('../../', import.meta.url)); const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-toolchain-identity-')); try { fs.mkdirSync(path.join(fixture, '.build/toolchain'), { recursive: true }); - for (const file of ['WORKSPACE', '.build/toolchain/PROJECT', '.build/toolchain/Dockerfile', '.build/toolchain/image.sh']) { + for (const file of ['WORKSPACE', '.build/toolchain/PROJECT', '.build/toolchain/Dockerfile']) { fs.copyFileSync(path.join(root, file), path.join(fixture, file)); } fs.mkdirSync(path.join(fixture, 'app')); @@ -60,8 +60,6 @@ test('toolchain identity follows tool inputs without following unrelated workspa const original = image(); const workspace = path.join(fixture, 'WORKSPACE'); const contents = fs.readFileSync(workspace, 'utf8'); - fs.writeFileSync(workspace, contents + '\ntarget unrelated_ci_check { depends_on = [] }\n'); - assert.equal(image(), original, 'An unrelated target must not invalidate every build.'); fs.writeFileSync(workspace, fs.readFileSync(workspace, 'utf8').replace(/dotnet_sdk: "[^"]+"/, 'dotnet_sdk: "10.0.999"')); assert.notEqual(image(), original); fs.writeFileSync(workspace, contents.replace(/node: "[^"]+"/, 'node: "99"')); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a65effe..2bf90608 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,6 @@ jobs: contents: read checks: write packages: write - env: - FSCRIPT_TOOLCHAIN_MODE: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'local' || 'publish' }} runs-on: ubuntu-latest timeout-minutes: 25 outputs: @@ -77,8 +75,6 @@ jobs: name: Tests (${{ matrix.name }}) runs-on: ubuntu-latest timeout-minutes: 25 - env: - FSCRIPT_TOOLCHAIN_MODE: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'local' || 'pull' }} strategy: fail-fast: false matrix: ${{ fromJSON(needs.build.outputs.matrix) }} diff --git a/CHANGELOG.md b/CHANGELOG.md index e61ace0e..d768cd8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Use Terrabuild's native Docker publishing and hash-based toolchain tags. - Reuse GHCR toolchain images across CI runners and avoid invalidating builds for unrelated workspace edits. - Restore the CLI and website test dependencies when CI shards start with cached builds on fresh runners. - Report the GitHub build and all test shards as one Insights build group, including reruns. diff --git a/Makefile b/Makefile index 2423f26c..1106e0c1 100644 --- a/Makefile +++ b/Makefile @@ -130,7 +130,6 @@ test-terrabuild: bash .github/scripts/test-node-reports.sh node --test .github/scripts/test-collect-results.mjs node --test .github/scripts/test-workspace-versions.mjs - node --test .github/scripts/test-toolchain-image.mjs tb-build: FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh build $(TB_FLAGS) diff --git a/WORKSPACE b/WORKSPACE index 5f3b19e0..7c8056f8 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -33,6 +33,12 @@ target build { depends_on = [ target.install target.^build ] } +target dist { + phase = phase.application + artifacts = ~external + depends_on = [ target.build target.^dist ] +} + target test { batch = ~never phase = phase.application @@ -92,20 +98,27 @@ target test_ci { } extension @dotnet { - image = "ghcr.io/magnusopera/fscript-toolchain:${project.toolchain.version}-sdk${local.versions.dotnet_sdk}-node${local.versions.node}-${terrabuild.arch}" + image = "ghcr.io/magnusopera/fscript-toolchain:${project.toolchain.version}" platform = "linux/${terrabuild.arch}" defaults { configuration = var.config } } extension @npm { - image = "ghcr.io/magnusopera/fscript-toolchain:${project.toolchain.version}-sdk${local.versions.dotnet_sdk}-node${local.versions.node}-${terrabuild.arch}" + image = "ghcr.io/magnusopera/fscript-toolchain:${project.toolchain.version}" platform = "linux/${terrabuild.arch}" env { CI = "true" } } extension workflow { script = ".build/workflow.fss" - image = "ghcr.io/magnusopera/fscript-toolchain:${project.toolchain.version}-sdk${local.versions.dotnet_sdk}-node${local.versions.node}-${terrabuild.arch}" + image = "ghcr.io/magnusopera/fscript-toolchain:${project.toolchain.version}" platform = "linux/${terrabuild.arch}" env { FSCRIPT_BUILD_CONFIG = var.config } } + +extension @docker { + defaults { + image = "ghcr.io/magnusopera/fscript-${terrabuild.project}" + platforms = [ "linux/${terrabuild.arch}" ] + } +} diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index a7064259..ac8d27c5 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -271,6 +271,6 @@ Language owns the permission enum, native descriptor requirements, registration GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. Shards select GitHub-only `ci` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. FFI test targets directly consume referenced builds to restore the CLI on fresh runners; the GitHub-only website test target consumes referenced builds and reinstalls Node dependencies before typechecking to recreate launcher symlinks. The local website test target is unchanged. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID group to Terrabuild, including reruns, so Insights correlates the workflow as one build group. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. -The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. Build-tool versions live in `WORKSPACE` under `locals.versions`. A toolchain phase passes the SDK and Node versions to Docker to create the local image with Clang and zlib headers. The toolchain source hash plus explicit SDK, Node, and architecture tag components determine image identity, so unrelated workspace target changes do not invalidate application builds. GitHub Actions reads the same version literals through the shared `workspace-versions` action before installing tools. The image bootstrap uses Docker on the host. Trusted GitHub build jobs publish missing toolchain images to GHCR, and test shards pull them with read-only package access. Local and fork-PR builds retain local image preparation without publishing. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. +The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. Build-tool versions live in `WORKSPACE` under `locals.versions`. A toolchain phase passes the SDK and Node versions to Docker to create the local image with Clang and zlib headers. The native `@docker build` extension handles image hashing and CI publishing under the `~external` artifact policy. The toolchain includes `WORKSPACE` as a source input so SDK and Node changes invalidate the image. GitHub Actions reads the same version literals through the shared `workspace-versions` action before installing tools. The Docker extension runs on the host during the toolchain phase. GitHub build jobs publish toolchain images to GHCR, and test shards reuse the external-artifact cache and pull images with read-only package access. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. Build outputs and test reports are declared per target. Tests depend on their build outputs and use `--no-build --no-restore`; build actions avoid rebuilding project references already represented by graph dependencies. The independent SDK check copies current source to a temporary directory before publishing different product versions, avoiding changes to outputs used by concurrent tests. Native AOT integration tests remain part of the normal graph. Graph execution and local cache reuse need no Insights account; shared caching is configured separately. See [Terrabuild workflow](../guides/terrabuild.md). diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index 133fc243..57bc972d 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -29,7 +29,7 @@ The Make targets check Terrabuild's JSON completion status as well as its proces `locals.versions` in `WORKSPACE` defines the .NET SDK, Node, Terrabuild release selector, VS Code packaging tool, and Open VSX publishing tool. Change these quoted literal values in one place. Terrabuild defaults to `latest`. -The toolchain project passes the SDK and Node versions as Docker build arguments. The image tag combines the toolchain source hash, SDK version, Node version, and architecture. Version changes invalidate the image, while unrelated `WORKSPACE` target edits do not. The shared `workspace-versions` GitHub action reads the same values before tool installation; release jobs read them from the checked-out release tag. The reader fails on missing, duplicate, or computed version values rather than choosing a fallback. +The toolchain project passes the SDK and Node versions as Docker build arguments. Terrabuild generates the image hash tag. The toolchain includes `WORKSPACE` as an input so SDK and Node updates invalidate the image; this currently also invalidates it for other workspace edits. The shared `workspace-versions` GitHub action reads the same values before tool installation; release jobs read them from the checked-out release tag. The reader fails on missing, duplicate, or computed version values rather than choosing a fallback. ## Containers and project layout @@ -47,7 +47,7 @@ The workspace ID is configured in `WORKSPACE`. Local caching works without an In The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install the latest stable Terrabuild release and configure authentication. Both call the shared `ci.yml` workflow, which builds once, scatters tests across seven runners, and joins the results. The build job runs `make build`; each test runner requests only its shard's `ci` target and lets Terrabuild restore prerequisite outputs from the shared cache. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching and rebuilds prerequisites on each test runner; cache sharing across jobs requires both credentials. The shared `collect-build-results` action collects the latest TRX result from each test output directory and all Node JUnit files into `.out/test-results`, uploads them and publishes both report formats in the GitHub build summary, including after a failed build. Fable, VS Code, and debugger tests retain console output while also writing JUnit XML. A reporting target consumes test outputs so cached reports are restored before collection. Terrabuild diagnostics, its JSON completion report, and captured console output are uploaded as a separate `terrabuild-logs-*` artifact. Its `terrabuild-debug.json` includes performance timings, slowest tasks, the critical chain, and FScript performance data for build optimization. -Managed build outputs and successful test results are cacheable separately. npm dependencies are kept in the local workspace cache. When application work must execute, the toolchain bootstrap checks the local image first. In trusted GitHub build jobs it pulls an existing GHCR image or builds and publishes a missing one; test shards only pull and fail if the build job has not published the required image. The shared `setup-tools` action authenticates Docker with `GITHUB_TOKEN`. Only the build job has package write permission; test jobs have package read permission. Fork PRs and local commands use local Docker builds without pushing. Fully cached requests skip execution; the phase barrier does not force application cache hits to rebuild. The configured Linux architecture separates ARM64 and AMD64 artifacts. +Managed build outputs and successful test results are cacheable separately. npm dependencies are kept in the local workspace cache. The toolchain mirrors `matis-next`: a project with the `@docker` initializer and a `dist` target uses `@docker build`, with `~external` artifact policy. Terrabuild generates the hash tag, builds and publishes the image in CI, and caches the external artifact. Test jobs reuse the successful target and Docker pulls the image when a container needs it. The shared `setup-tools` action authenticates Docker with `GITHUB_TOKEN`. Only the build job has package write permission; test jobs have package read permission. Publishing a missing image requires registry write access. Fully cached requests skip execution; the phase barrier does not force application cache hits to rebuild. The configured Linux architecture separates ARM64 and AMD64 artifacts. Use `terrabuild explain build test --local-only` to inspect cache decisions. Repeating `make tb-check TB_FLAGS=--local-only` should reuse successful unchanged work. Use `TB_FLAGS='--local-only --force'` to execute deliberately, or `--retry` to rerun a failed cached task after fixing its environment. @@ -73,4 +73,4 @@ The FFI categories partition its cases into managed tests and the two Native AOT The baseline [successful run](https://github.com/MagnusOpera/FScript/actions/runs/42246670574) took 7m34s in Terrabuild. Its trace identified FFI tests at 5m32s, SDK compatibility at 1m59s, and the website build at 1m36s. The matrix separates those workloads and splits the sequential AOT pair. Runner setup, cache transfers, and the build barrier add overhead; compare subsequent GitHub timings and `terrabuild-logs-*` artifacts before claiming a speedup. -Toolchain preparation reads `FSCRIPT_TOOLCHAIN_MODE` from the workflow runner: `publish` for trusted build jobs, `pull` for their test shards, and `local` by default. This changes image preparation only; application commands and their container identity remain the same. Cold CI runs publish an image once per toolchain identity; subsequent matrix runners pull that immutable tag rather than compiling the toolchain again. +The toolchain image is `ghcr.io/magnusopera/fscript-toolchain:`. `WORKSPACE` configures the Docker image repository and platform defaults, and consumers reference `project.toolchain.version`. There is no custom image preparation script or manually constructed version suffix. From 654d890613760c4a409fb8670c93fa3379c59b0b Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 18:05:19 +0100 Subject: [PATCH 12/17] ci: publish toolchain without an attested image index --- .build/toolchain/PROJECT | 2 ++ CHANGELOG.md | 3 ++- docs/guides/terrabuild.md | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.build/toolchain/PROJECT b/.build/toolchain/PROJECT index f66289f6..bcbfd692 100644 --- a/.build/toolchain/PROJECT +++ b/.build/toolchain/PROJECT @@ -7,6 +7,8 @@ project toolchain { target dist { phase = phase.toolchains @docker build { + # GHCR returned missing child manifests for the attested index. + args = "--provenance=false" build_args = { DOTNET_SDK_VERSION: local.versions.dotnet_sdk NODE_VERSION: local.versions.node diff --git a/CHANGELOG.md b/CHANGELOG.md index d768cd8f..096897ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,9 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Publish the CI toolchain as a single image manifest so test runners can pull it from GHCR. - Use Terrabuild's native Docker publishing and hash-based toolchain tags. -- Reuse GHCR toolchain images across CI runners and avoid invalidating builds for unrelated workspace edits. +- Reuse GHCR toolchain images across CI runners. - Restore the CLI and website test dependencies when CI shards start with cached builds on fresh runners. - Report the GitHub build and all test shards as one Insights build group, including reruns. - Split GitHub CI into a cached build, parallel test shards, and combined test reports, including separate Native AOT variants. diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index 57bc972d..62db7d09 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -73,4 +73,4 @@ The FFI categories partition its cases into managed tests and the two Native AOT The baseline [successful run](https://github.com/MagnusOpera/FScript/actions/runs/42246670574) took 7m34s in Terrabuild. Its trace identified FFI tests at 5m32s, SDK compatibility at 1m59s, and the website build at 1m36s. The matrix separates those workloads and splits the sequential AOT pair. Runner setup, cache transfers, and the build barrier add overhead; compare subsequent GitHub timings and `terrabuild-logs-*` artifacts before claiming a speedup. -The toolchain image is `ghcr.io/magnusopera/fscript-toolchain:`. `WORKSPACE` configures the Docker image repository and platform defaults, and consumers reference `project.toolchain.version`. There is no custom image preparation script or manually constructed version suffix. +The toolchain image is `ghcr.io/magnusopera/fscript-toolchain:`. `WORKSPACE` configures the Docker image repository and platform defaults, and consumers reference `project.toolchain.version`. There is no custom image preparation script or manually constructed version suffix. The native Docker build disables provenance attestations with `--provenance=false` to publish a single platform manifest. The previous attested index was readable in GHCR, but its referenced image manifest returned `manifest unknown` on fresh runners after successful registry login. From 0c6ad7c4aa9c09dae415c5d1ac94004eb4c3b060 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 18:18:34 +0100 Subject: [PATCH 13/17] ci: separate Insights groups by workflow attempt --- .github/scripts/test-terrabuild.sh | 4 ++-- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 3 ++- docs/architecture/assemblies-and-roles.md | 2 +- docs/guides/terrabuild.md | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/scripts/test-terrabuild.sh b/.github/scripts/test-terrabuild.sh index 890365ff..11d9d37f 100644 --- a/.github/scripts/test-terrabuild.sh +++ b/.github/scripts/test-terrabuild.sh @@ -69,7 +69,7 @@ if grep -q -- '--parallel' "$fixture/args"; then fi test "$(wc -l < "$fixture/args" | tr -d ' ')" = 8 -export TERRABUILD_GROUP='MagnusOpera/FScript:12345' +export TERRABUILD_GROUP='MagnusOpera/FScript:12345:2' if ! run_build success ''; then cat "$fixture/log"; exit 1; fi cat > "$fixture/expected-group-args" <<'ARGS' run @@ -79,7 +79,7 @@ build --variable config=Debug --group -MagnusOpera/FScript:12345 +MagnusOpera/FScript:12345:2 --out .terrabuild/result.json ARGS diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bf90608..085f380c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ permissions: packages: read env: - TERRABUILD_GROUP: ${{ github.repository }}:${{ github.run_id }} + TERRABUILD_GROUP: ${{ github.repository }}:${{ github.run_id }}:${{ github.run_attempt }} jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 096897ce..322d3fd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,12 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Keep GitHub workflow reruns in separate Insights build groups while correlating each attempt's build and test shards. - Publish the CI toolchain as a single image manifest so test runners can pull it from GHCR. - Use Terrabuild's native Docker publishing and hash-based toolchain tags. - Reuse GHCR toolchain images across CI runners. - Restore the CLI and website test dependencies when CI shards start with cached builds on fresh runners. -- Report the GitHub build and all test shards as one Insights build group, including reruns. +- Report the GitHub build and all test shards as one Insights build group per workflow attempt. - Split GitHub CI into a cached build, parallel test shards, and combined test reports, including separate Native AOT variants. - Allow the native teapot debugger test to finish mesh generation on busy CI runners and report clearer event timeouts. - Define shared build-tool versions in WORKSPACE for container builds and GitHub workflows. diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index ac8d27c5..e6486adc 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -269,7 +269,7 @@ Language owns the permission enum, native descriptor requirements, registration `make build` delegates to the Terrabuild executable on `PATH` through `.build/run-terrabuild.sh`, which checks both the process exit code and the JSON completion status. Missing Terrabuild or a failed graph fails the build without falling back to the host .NET SDK. -GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. Shards select GitHub-only `ci` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. FFI test targets directly consume referenced builds to restore the CLI on fresh runners; the GitHub-only website test target consumes referenced builds and reinstalls Node dependencies before typechecking to recreate launcher symlinks. The local website test target is unchanged. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID group to Terrabuild, including reruns, so Insights correlates the workflow as one build group. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. +GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. Shards select GitHub-only `ci` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. FFI test targets directly consume referenced builds to restore the CLI on fresh runners; the GitHub-only website test target consumes referenced builds and reinstalls Node dependencies before typechecking to recreate launcher symlinks. The local website test target is unchanged. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID/run-attempt group to Terrabuild, so Insights correlates each workflow attempt as one build group and keeps reruns separate. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. Build-tool versions live in `WORKSPACE` under `locals.versions`. A toolchain phase passes the SDK and Node versions to Docker to create the local image with Clang and zlib headers. The native `@docker build` extension handles image hashing and CI publishing under the `~external` artifact policy. The toolchain includes `WORKSPACE` as a source input so SDK and Node changes invalidate the image. GitHub Actions reads the same version literals through the shared `workspace-versions` action before installing tools. The Docker extension runs on the host during the toolchain phase. GitHub build jobs publish toolchain images to GHCR, and test shards reuse the external-artifact cache and pull images with read-only package access. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index 62db7d09..eb3d6f2c 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -69,7 +69,7 @@ The GitHub-only `.build/ci-shards.json` lists a static matrix. `.build/ci/*/PROJ | web | Fable, VS Code, and website tests | | integration | Debugger adapter, all sample smoke tests, and SDK compatibility | -The FFI categories partition its cases into managed tests and the two Native AOT variants; their results and cache entries are separate. Each shard's executing `ci` target consumes just its test outputs, restoring cached reports before artifact upload. Every runner retains its own diagnostics and test artifact. The join downloads the artifacts into separate directories, publishes combined TRX and JUnit summaries, and requires the build and every shard to succeed. A failed shard does not cancel the others. The shared workflow sets `TERRABUILD_GROUP` to `:`, following `matis-next`; the wrapper passes it explicitly with `--group` for the build and every shard. Reruns retain the same group so Insights shows one build group for the workflow. Local commands do not set a group by default. +The FFI categories partition its cases into managed tests and the two Native AOT variants; their results and cache entries are separate. Each shard's executing `ci` target consumes just its test outputs, restoring cached reports before artifact upload. Every runner retains its own diagnostics and test artifact. The join downloads the artifacts into separate directories, publishes combined TRX and JUnit summaries, and requires the build and every shard to succeed. A failed shard does not cancel the others. The shared workflow sets `TERRABUILD_GROUP` to `::`; the wrapper passes it explicitly with `--group` for the build and every shard. Each rerun gets a separate group, so Insights shows one build group per workflow attempt. Local commands do not set a group by default. The baseline [successful run](https://github.com/MagnusOpera/FScript/actions/runs/42246670574) took 7m34s in Terrabuild. Its trace identified FFI tests at 5m32s, SDK compatibility at 1m59s, and the website build at 1m36s. The matrix separates those workloads and splits the sequential AOT pair. Runner setup, cache transfers, and the build barrier add overhead; compare subsequent GitHub timings and `terrabuild-logs-*` artifacts before claiming a speedup. From a25f7a83c4da632136e128859dc68f8ddf97432a Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 18:28:05 +0100 Subject: [PATCH 14/17] ci: name Terrabuild shard runs test --- .build/ci/backend/PROJECT | 4 +++- .build/ci/cli/PROJECT | 4 +++- .build/ci/core/PROJECT | 4 +++- .build/ci/ffi-default/PROJECT | 4 +++- .build/ci/ffi-managed/PROJECT | 4 +++- .build/ci/ffi-size/PROJECT | 4 +++- .build/ci/integration/PROJECT | 4 +++- .build/ci/web/PROJECT | 4 +++- .build/ci/website/PROJECT | 4 +++- .github/scripts/run-ci-shard.mjs | 2 +- .github/scripts/test-ci-shards.mjs | 14 +++++++++----- CHANGELOG.md | 1 + WORKSPACE | 8 -------- docs/architecture/assemblies-and-roles.md | 2 +- docs/guides/terrabuild.md | 4 ++-- 15 files changed, 41 insertions(+), 26 deletions(-) diff --git a/.build/ci/backend/PROJECT b/.build/ci/backend/PROJECT index 0181f1ee..0283d19e 100644 --- a/.build/ci/backend/PROJECT +++ b/.build/ci/backend/PROJECT @@ -3,7 +3,9 @@ project ci_backend { includes = [ "../../checks.sh" ] } -target ci { +target test { + build = ~always + artifacts = ~none depends_on = [ target.^test ] workflow run { script = "../../checks.sh" args = "reports" } } diff --git a/.build/ci/cli/PROJECT b/.build/ci/cli/PROJECT index 111364c4..2da621a3 100644 --- a/.build/ci/cli/PROJECT +++ b/.build/ci/cli/PROJECT @@ -3,7 +3,9 @@ project ci_cli { includes = [ "../../checks.sh" ] } -target ci { +target test { + build = ~always + artifacts = ~none depends_on = [ target.^test ] workflow run { script = "../../checks.sh" args = "reports" } } diff --git a/.build/ci/core/PROJECT b/.build/ci/core/PROJECT index 0d5f3dfa..b84b60ae 100644 --- a/.build/ci/core/PROJECT +++ b/.build/ci/core/PROJECT @@ -3,7 +3,9 @@ project ci_core { includes = [ "../../checks.sh" ] } -target ci { +target test { + build = ~always + artifacts = ~none depends_on = [ target.^test ] workflow run { script = "../../checks.sh" args = "reports" } } diff --git a/.build/ci/ffi-default/PROJECT b/.build/ci/ffi-default/PROJECT index b7b91595..3ab1b4cf 100644 --- a/.build/ci/ffi-default/PROJECT +++ b/.build/ci/ffi-default/PROJECT @@ -3,7 +3,9 @@ project ci_ffi_default { includes = [ "../../checks.sh" ] } -target ci { +target test { + build = ~always + artifacts = ~none depends_on = [ target.^test_ffi_default ] workflow run { script = "../../checks.sh" args = "reports" } } diff --git a/.build/ci/ffi-managed/PROJECT b/.build/ci/ffi-managed/PROJECT index 1ba80880..73fd5405 100644 --- a/.build/ci/ffi-managed/PROJECT +++ b/.build/ci/ffi-managed/PROJECT @@ -3,7 +3,9 @@ project ci_ffi_managed { includes = [ "../../checks.sh" ] } -target ci { +target test { + build = ~always + artifacts = ~none depends_on = [ target.^test_ffi_managed ] workflow run { script = "../../checks.sh" args = "reports" } } diff --git a/.build/ci/ffi-size/PROJECT b/.build/ci/ffi-size/PROJECT index 7f90cbf9..2d7ea41c 100644 --- a/.build/ci/ffi-size/PROJECT +++ b/.build/ci/ffi-size/PROJECT @@ -3,7 +3,9 @@ project ci_ffi_size { includes = [ "../../checks.sh" ] } -target ci { +target test { + build = ~always + artifacts = ~none depends_on = [ target.^test_ffi_size ] workflow run { script = "../../checks.sh" args = "reports" } } diff --git a/.build/ci/integration/PROJECT b/.build/ci/integration/PROJECT index e6f495e5..fb70e680 100644 --- a/.build/ci/integration/PROJECT +++ b/.build/ci/integration/PROJECT @@ -3,7 +3,9 @@ project ci_integration { includes = [ "../../checks.sh" ] } -target ci { +target test { + build = ~always + artifacts = ~none depends_on = [ target.^test target.^smoke target.^sdk ] workflow run { script = "../../checks.sh" args = "reports" } } diff --git a/.build/ci/web/PROJECT b/.build/ci/web/PROJECT index 61df3bd2..7f62e8b8 100644 --- a/.build/ci/web/PROJECT +++ b/.build/ci/web/PROJECT @@ -3,7 +3,9 @@ project ci_web { includes = [ "../../checks.sh" ] } -target ci { +target test { + build = ~always + artifacts = ~none depends_on = [ target.^test ] workflow run { script = "../../checks.sh" args = "reports" } } diff --git a/.build/ci/website/PROJECT b/.build/ci/website/PROJECT index 5c4ef09f..c8a65dbb 100644 --- a/.build/ci/website/PROJECT +++ b/.build/ci/website/PROJECT @@ -3,7 +3,9 @@ project ci_website { includes = [ "../../checks.sh" ] } -target ci { +target test { + build = ~always + artifacts = ~none depends_on = [ target.^test_ci ] workflow run { script = "../../checks.sh" args = "reports" } } diff --git a/.github/scripts/run-ci-shard.mjs b/.github/scripts/run-ci-shard.mjs index 3a8f0123..d969a1cd 100644 --- a/.github/scripts/run-ci-shard.mjs +++ b/.github/scripts/run-ci-shard.mjs @@ -4,7 +4,7 @@ import fs from 'node:fs'; const matrix = JSON.parse(fs.readFileSync('.build/ci-shards.json', 'utf8')); const shard = matrix.include.find(shard => shard.name === process.argv[2]); if (!shard) throw new Error(`Unknown CI shard: ${process.argv[2]}`); -const args = ['ci', '--project', ...shard.projects, '--debug', '--log']; +const args = ['test', '--project', ...shard.projects, '--debug', '--log']; // setup-tools sets this only for a runner without shared-cache credentials. if (process.env.TB_FLAGS === '--local-only') args.push('--local-only'); const result = spawnSync('bash', ['.build/run-terrabuild.sh', ...args], { stdio: 'inherit' }); diff --git a/.github/scripts/test-ci-shards.mjs b/.github/scripts/test-ci-shards.mjs index c48206aa..7b863b89 100644 --- a/.github/scripts/test-ci-shards.mjs +++ b/.github/scripts/test-ci-shards.mjs @@ -34,7 +34,7 @@ test('CI runner forwards the exact project selection and propagates failures', ( const core = run('core'); assert.equal(core.status, 0, core.stderr); assert.deepEqual(core.stdout.trim().split('\n'), - ['ci', '--project', 'ci_core', 'ci_ffi_managed', '--debug', '--log', '--local-only']); + ['test', '--project', 'ci_core', 'ci_ffi_managed', '--debug', '--log', '--local-only']); assert.equal(run('ffi-default', { CI_FIXTURE_EXIT: '7' }).status, 7); assert.notEqual(run('missing').status, 0); const shared = run('ffi-size', { TB_FLAGS: '' }); @@ -49,15 +49,18 @@ test('static CI shards select exactly their checks, restore reports, and cover e assert.deepEqual(matrix.map(shard => shard.name).sort(), Object.keys(expected).sort()); const covered = []; for (const shard of matrix) { - execFileSync('terrabuild', ['run', 'ci', '--project', ...shard.projects, + execFileSync('terrabuild', ['run', 'test', '--project', ...shard.projects, '--dry-run', '--local-only', '--debug'], { stdio: 'pipe', timeout: 30000 }); const graph = JSON.parse(fs.readFileSync('terrabuild-debug.json', 'utf8')); const selected = graph.nodes.filter(node => node.selected); - const checks = selected.filter(node => /^(test(?:_ffi_.*|_ci)?|smoke|sdk)$/.test(node.target)); + const consumers = selected.filter(node => node.projectDirectory.startsWith('.build/ci/')); + const checks = selected.filter(node => !consumers.includes(node) && /^(test(?:_ffi_.*|_ci)?|smoke|sdk)$/.test(node.target)); const names = checks.map(node => `${node.projectDirectory.toLowerCase()}:${node.target}`).sort(); assert.deepEqual(names, expected[shard.name].toSorted(), shard.name); - assert.ok(selected.some(node => node.target === 'ci' && node.build === 'always'), shard.name); - for (const consumer of selected.filter(node => node.target === 'ci')) { + assert.ok(consumers.length > 0, shard.name); + for (const consumer of consumers) { + assert.equal(consumer.target, 'test'); + assert.equal(consumer.build, 'always'); assert.ok(consumer.resolvedOperations.length > 0, 'Cached reports need an executing consumer.'); } for (const check of checks) { @@ -78,6 +81,7 @@ test('static CI shards select exactly their checks, restore reports, and cover e } const allTests = []; function scan(directory) { + if (directory === '.build/ci') return; for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { if (['node_modules', 'bin', 'obj', '.git', '.terrabuild', 'TestResults', '.out'].includes(entry.name)) continue; const file = path.join(directory, entry.name); diff --git a/CHANGELOG.md b/CHANGELOG.md index 322d3fd5..f5b24a65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Label GitHub test shards as test instead of ci in Insights. - Keep GitHub workflow reruns in separate Insights build groups while correlating each attempt's build and test shards. - Publish the CI toolchain as a single image manifest so test runners can pull it from GHCR. - Use Terrabuild's native Docker publishing and hash-based toolchain tags. diff --git a/WORKSPACE b/WORKSPACE index 7c8056f8..f21ec822 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -65,14 +65,6 @@ target reports { depends_on = [ target.^test ] } -# Static CI entry points consume only their selected test outputs. -target ci { - phase = phase.application - build = ~always - artifacts = ~none - depends_on = [] -} - target test_ffi_managed { phase = phase.application artifacts = ~managed diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index e6486adc..12aa1f91 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -269,7 +269,7 @@ Language owns the permission enum, native descriptor requirements, registration `make build` delegates to the Terrabuild executable on `PATH` through `.build/run-terrabuild.sh`, which checks both the process exit code and the JSON completion status. Missing Terrabuild or a failed graph fails the build without falling back to the host .NET SDK. -GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. Shards select GitHub-only `ci` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. FFI test targets directly consume referenced builds to restore the CLI on fresh runners; the GitHub-only website test target consumes referenced builds and reinstalls Node dependencies before typechecking to recreate launcher symlinks. The local website test target is unchanged. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID/run-attempt group to Terrabuild, so Insights correlates each workflow attempt as one build group and keeps reruns separate. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. +GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. Shards select GitHub-only `test` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. FFI test targets directly consume referenced builds to restore the CLI on fresh runners; the GitHub-only website test target consumes referenced builds and reinstalls Node dependencies before typechecking to recreate launcher symlinks. The local website test target is unchanged. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID/run-attempt group to Terrabuild, so Insights correlates each workflow attempt as one build group and keeps reruns separate. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. Build-tool versions live in `WORKSPACE` under `locals.versions`. A toolchain phase passes the SDK and Node versions to Docker to create the local image with Clang and zlib headers. The native `@docker build` extension handles image hashing and CI publishing under the `~external` artifact policy. The toolchain includes `WORKSPACE` as a source input so SDK and Node changes invalidate the image. GitHub Actions reads the same version literals through the shared `workspace-versions` action before installing tools. The Docker extension runs on the host during the toolchain phase. GitHub build jobs publish toolchain images to GHCR, and test shards reuse the external-artifact cache and pull images with read-only package access. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index eb3d6f2c..828a21db 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -45,7 +45,7 @@ Builds use the normal `bin`, `obj`, `node_modules`, and generated website direct The workspace ID is configured in `WORKSPACE`. Local caching works without an Insights account; `--local-only` disables shared caching. Omit `TB_FLAGS=--local-only` after connecting the workspace to allow shared reuse. Do not put tokens or master keys in repository files. -The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install the latest stable Terrabuild release and configure authentication. Both call the shared `ci.yml` workflow, which builds once, scatters tests across seven runners, and joins the results. The build job runs `make build`; each test runner requests only its shard's `ci` target and lets Terrabuild restore prerequisite outputs from the shared cache. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching and rebuilds prerequisites on each test runner; cache sharing across jobs requires both credentials. The shared `collect-build-results` action collects the latest TRX result from each test output directory and all Node JUnit files into `.out/test-results`, uploads them and publishes both report formats in the GitHub build summary, including after a failed build. Fable, VS Code, and debugger tests retain console output while also writing JUnit XML. A reporting target consumes test outputs so cached reports are restored before collection. Terrabuild diagnostics, its JSON completion report, and captured console output are uploaded as a separate `terrabuild-logs-*` artifact. Its `terrabuild-debug.json` includes performance timings, slowest tasks, the critical chain, and FScript performance data for build optimization. +The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install the latest stable Terrabuild release and configure authentication. Both call the shared `ci.yml` workflow, which builds once, scatters tests across seven runners, and joins the results. The build job runs `make build`; each test runner requests only its shard's `test` target and lets Terrabuild restore prerequisite outputs from the shared cache. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching and rebuilds prerequisites on each test runner; cache sharing across jobs requires both credentials. The shared `collect-build-results` action collects the latest TRX result from each test output directory and all Node JUnit files into `.out/test-results`, uploads them and publishes both report formats in the GitHub build summary, including after a failed build. Fable, VS Code, and debugger tests retain console output while also writing JUnit XML. A reporting target consumes test outputs so cached reports are restored before collection. Terrabuild diagnostics, its JSON completion report, and captured console output are uploaded as a separate `terrabuild-logs-*` artifact. Its `terrabuild-debug.json` includes performance timings, slowest tasks, the critical chain, and FScript performance data for build optimization. Managed build outputs and successful test results are cacheable separately. npm dependencies are kept in the local workspace cache. The toolchain mirrors `matis-next`: a project with the `@docker` initializer and a `dist` target uses `@docker build`, with `~external` artifact policy. Terrabuild generates the hash tag, builds and publishes the image in CI, and caches the external artifact. Test jobs reuse the successful target and Docker pulls the image when a container needs it. The shared `setup-tools` action authenticates Docker with `GITHUB_TOKEN`. Only the build job has package write permission; test jobs have package read permission. Publishing a missing image requires registry write access. Fully cached requests skip execution; the phase barrier does not force application cache hits to rebuild. The configured Linux architecture separates ARM64 and AMD64 artifacts. @@ -69,7 +69,7 @@ The GitHub-only `.build/ci-shards.json` lists a static matrix. `.build/ci/*/PROJ | web | Fable, VS Code, and website tests | | integration | Debugger adapter, all sample smoke tests, and SDK compatibility | -The FFI categories partition its cases into managed tests and the two Native AOT variants; their results and cache entries are separate. Each shard's executing `ci` target consumes just its test outputs, restoring cached reports before artifact upload. Every runner retains its own diagnostics and test artifact. The join downloads the artifacts into separate directories, publishes combined TRX and JUnit summaries, and requires the build and every shard to succeed. A failed shard does not cancel the others. The shared workflow sets `TERRABUILD_GROUP` to `::`; the wrapper passes it explicitly with `--group` for the build and every shard. Each rerun gets a separate group, so Insights shows one build group per workflow attempt. Local commands do not set a group by default. +The FFI categories partition its cases into managed tests and the two Native AOT variants; their results and cache entries are separate. Each shard's executing `test` target consumes just its test outputs, restoring cached reports before artifact upload. Every runner retains its own diagnostics and test artifact. The join downloads the artifacts into separate directories, publishes combined TRX and JUnit summaries, and requires the build and every shard to succeed. A failed shard does not cancel the others. The shared workflow sets `TERRABUILD_GROUP` to `::`; the wrapper passes it explicitly with `--group` for the build and every shard. Each rerun gets a separate group, so Insights shows one build group per workflow attempt. Local commands do not set a group by default. The baseline [successful run](https://github.com/MagnusOpera/FScript/actions/runs/42246670574) took 7m34s in Terrabuild. Its trace identified FFI tests at 5m32s, SDK compatibility at 1m59s, and the website build at 1m36s. The matrix separates those workloads and splits the sequential AOT pair. Runner setup, cache transfers, and the build barrier add overhead; compare subsequent GitHub timings and `terrabuild-logs-*` artifacts before claiming a speedup. From fb7261580e7fb6a183229321aa95368ab3e14aa2 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 18:43:14 +0100 Subject: [PATCH 15/17] ci: serialize branch and PR runs with a shared lock --- .github/workflows/ci.yml | 4 ++++ CHANGELOG.md | 1 + docs/architecture/assemblies-and-roles.md | 2 +- docs/guides/terrabuild.md | 2 +- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 085f380c..9093f85a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,10 @@ on: TERRABUILD_MASTER_KEY: required: false +concurrency: + group: fscript-ci-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: false + permissions: contents: read checks: write diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c70d836..a73a6ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Serialize branch and pull-request CI runs for the same source branch while keeping test shards parallel. - Label GitHub test shards as test instead of ci in Insights. - Keep GitHub workflow reruns in separate Insights build groups while correlating each attempt's build and test shards. - Publish the CI toolchain as a single image manifest so test runners can pull it from GHCR. diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index 12aa1f91..7ef11ddf 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -269,7 +269,7 @@ Language owns the permission enum, native descriptor requirements, registration `make build` delegates to the Terrabuild executable on `PATH` through `.build/run-terrabuild.sh`, which checks both the process exit code and the JSON completion status. Missing Terrabuild or a failed graph fails the build without falling back to the host .NET SDK. -GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. Shards select GitHub-only `test` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. FFI test targets directly consume referenced builds to restore the CLI on fresh runners; the GitHub-only website test target consumes referenced builds and reinstalls Node dependencies before typechecking to recreate launcher symlinks. The local website test target is unchanged. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID/run-attempt group to Terrabuild, so Insights correlates each workflow attempt as one build group and keeps reruns separate. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. +GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. The shared workflow holds a concurrency lock keyed by source repository and branch, with in-progress cancellation disabled, so push and PR runs for the same branch execute sequentially while shards remain parallel. Shards select GitHub-only `test` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. FFI test targets directly consume referenced builds to restore the CLI on fresh runners; the GitHub-only website test target consumes referenced builds and reinstalls Node dependencies before typechecking to recreate launcher symlinks. The local website test target is unchanged. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID/run-attempt group to Terrabuild, so Insights correlates each workflow attempt as one build group and keeps reruns separate. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. Build-tool versions live in `WORKSPACE` under `locals.versions`. A toolchain phase passes the SDK and Node versions to Docker to create the local image with Clang and zlib headers. The native `@docker build` extension handles image hashing and CI publishing under the `~external` artifact policy. The toolchain includes `WORKSPACE` as a source input so SDK and Node changes invalidate the image. GitHub Actions reads the same version literals through the shared `workspace-versions` action before installing tools. The Docker extension runs on the host during the toolchain phase. GitHub build jobs publish toolchain images to GHCR, and test shards reuse the external-artifact cache and pull images with read-only package access. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index 828a21db..53155143 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -53,7 +53,7 @@ Use `terrabuild explain build test --local-only` to inspect cache decisions. Rep Cached test success is not a fresh execution. Force a run when investigating a flaky test or an external dependency. Deleting generated output and repeating a fully cached request is not a restoration test: Terrabuild restores prerequisite files when an executing consumer needs them. -`ci-main.yml` runs on pushes to every branch, including names containing `/`, and supports manual dispatch. Tag pushes remain handled by the release workflows. `ci-pr.yml` separately validates pull requests targeting `main`, so a push to an open PR triggers both branch and PR CI. +`ci-main.yml` runs on pushes to every branch, including names containing `/`, and supports manual dispatch. Tag pushes remain handled by the release workflows. `ci-pr.yml` separately validates pull requests targeting `main`, so a push to an open PR triggers both branch and PR CI. The shared workflow holds one concurrency lock per source repository and branch, with `cancel-in-progress: false`, following the lock policy in `matis-next`. Branch, PR, and manual runs for the same source branch execute sequentially; separate branches can run concurrently. Test shards remain parallel inside the active workflow. GitHub retains one pending run per lock and replaces an older pending run if another arrives. ## GitHub test matrix From 41871589dac8cb65de03db7518a0b20f3ce90739 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 18:43:39 +0100 Subject: [PATCH 16/17] ci: cancel superseded runs for the same branch --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 +- docs/architecture/assemblies-and-roles.md | 2 +- docs/guides/terrabuild.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9093f85a..263519df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ on: concurrency: group: fscript-ci-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} - cancel-in-progress: false + cancel-in-progress: true permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index a73a6ace..6b23c9c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to FScript are documented in this file. ## [Unreleased] -- Serialize branch and pull-request CI runs for the same source branch while keeping test shards parallel. +- Cancel superseded branch and pull-request CI runs for the same source branch while keeping test shards parallel. - Label GitHub test shards as test instead of ci in Insights. - Keep GitHub workflow reruns in separate Insights build groups while correlating each attempt's build and test shards. - Publish the CI toolchain as a single image manifest so test runners can pull it from GHCR. diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index 7ef11ddf..bf324bd0 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -269,7 +269,7 @@ Language owns the permission enum, native descriptor requirements, registration `make build` delegates to the Terrabuild executable on `PATH` through `.build/run-terrabuild.sh`, which checks both the process exit code and the JSON completion status. Missing Terrabuild or a failed graph fails the build without falling back to the host .NET SDK. -GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. The shared workflow holds a concurrency lock keyed by source repository and branch, with in-progress cancellation disabled, so push and PR runs for the same branch execute sequentially while shards remain parallel. Shards select GitHub-only `test` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. FFI test targets directly consume referenced builds to restore the CLI on fresh runners; the GitHub-only website test target consumes referenced builds and reinstalls Node dependencies before typechecking to recreate launcher symlinks. The local website test target is unchanged. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID/run-attempt group to Terrabuild, so Insights correlates each workflow attempt as one build group and keeps reruns separate. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. +GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. The shared workflow holds a concurrency lock keyed by source repository and branch, with in-progress cancellation enabled, so a new push or PR run cancels the active run for the same branch while shards remain parallel. Shards select GitHub-only `test` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. FFI test targets directly consume referenced builds to restore the CLI on fresh runners; the GitHub-only website test target consumes referenced builds and reinstalls Node dependencies before typechecking to recreate launcher symlinks. The local website test target is unchanged. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID/run-attempt group to Terrabuild, so Insights correlates each workflow attempt as one build group and keeps reruns separate. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. Build-tool versions live in `WORKSPACE` under `locals.versions`. A toolchain phase passes the SDK and Node versions to Docker to create the local image with Clang and zlib headers. The native `@docker build` extension handles image hashing and CI publishing under the `~external` artifact policy. The toolchain includes `WORKSPACE` as a source input so SDK and Node changes invalidate the image. GitHub Actions reads the same version literals through the shared `workspace-versions` action before installing tools. The Docker extension runs on the host during the toolchain phase. GitHub build jobs publish toolchain images to GHCR, and test shards reuse the external-artifact cache and pull images with read-only package access. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index 53155143..8faaf9ad 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -53,7 +53,7 @@ Use `terrabuild explain build test --local-only` to inspect cache decisions. Rep Cached test success is not a fresh execution. Force a run when investigating a flaky test or an external dependency. Deleting generated output and repeating a fully cached request is not a restoration test: Terrabuild restores prerequisite files when an executing consumer needs them. -`ci-main.yml` runs on pushes to every branch, including names containing `/`, and supports manual dispatch. Tag pushes remain handled by the release workflows. `ci-pr.yml` separately validates pull requests targeting `main`, so a push to an open PR triggers both branch and PR CI. The shared workflow holds one concurrency lock per source repository and branch, with `cancel-in-progress: false`, following the lock policy in `matis-next`. Branch, PR, and manual runs for the same source branch execute sequentially; separate branches can run concurrently. Test shards remain parallel inside the active workflow. GitHub retains one pending run per lock and replaces an older pending run if another arrives. +`ci-main.yml` runs on pushes to every branch, including names containing `/`, and supports manual dispatch. Tag pushes remain handled by the release workflows. `ci-pr.yml` separately validates pull requests targeting `main`, so a push to an open PR triggers both branch and PR CI. The shared workflow holds one concurrency lock per source repository and branch, with `cancel-in-progress: true`. A new branch, PR, or manual run cancels the active run for the same source branch; separate branches can run concurrently. Test shards remain parallel inside the active workflow. GitHub retains one pending run per lock and replaces an older pending run if another arrives. ## GitHub test matrix From a785b20f2158c2ff5676a8982c6c04a1a55b7a15 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 6 Nov 2026 19:01:59 +0100 Subject: [PATCH 17/17] build: add VS Code changelog only during packaging --- .github/scripts/test-workspace-versions.mjs | 45 +++++++++++++++++++++ .gitignore | 3 ++ CHANGELOG.md | 1 + docs/guides/terrabuild.md | 2 + vscode-fscript/CHANGELOG.md | 1 - vscode-fscript/PROJECT | 2 +- vscode-fscript/package.json | 2 +- 7 files changed, 53 insertions(+), 3 deletions(-) delete mode 120000 vscode-fscript/CHANGELOG.md diff --git a/.github/scripts/test-workspace-versions.mjs b/.github/scripts/test-workspace-versions.mjs index c707e231..3710f0b4 100644 --- a/.github/scripts/test-workspace-versions.mjs +++ b/.github/scripts/test-workspace-versions.mjs @@ -72,3 +72,48 @@ test('native toolchain tags follow workspace tool versions and Dockerfile inputs fs.rmSync(fixture, { recursive: true, force: true }); } }); +test('workflow and changelog edits preserve VS Code cache keys while website tracks release notes', () => { + const root = fileURLToPath(new URL('../../', import.meta.url)); + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-frontend-inputs-')); + try { + // Copy tracked inputs only; generated packaging files must stay untracked. + const files = execFileSync('git', ['ls-files', '-z'], { cwd: root }).toString().split('\0').filter(Boolean); + for (const file of files) { + const source = path.join(root, file); + const destination = path.join(fixture, file); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + if (fs.lstatSync(source).isSymbolicLink()) fs.symlinkSync(fs.readlinkSync(source), destination); + else fs.copyFileSync(source, destination); + } + const packageFile = path.join(fixture, 'vscode-fscript/package.json'); + const manifest = JSON.parse(fs.readFileSync(packageFile, 'utf8')); + // Exercise the real packaging hook without installing the compiler in this fixture. + manifest.scripts.compile = 'node -e ""'; + fs.writeFileSync(packageFile, JSON.stringify(manifest)); + execFileSync('git', ['init', '--quiet'], { cwd: fixture }); + execFileSync('git', ['add', '.'], { cwd: fixture }); + execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--quiet', '-m', 'fixture'], { cwd: fixture }); + const keys = () => { + execFileSync('terrabuild', ['run', 'build', '--project', 'vscode', 'website', '--dry-run', '--local-only', '--debug'], + { cwd: fixture, stdio: 'pipe', timeout: 30000 }); + const graph = JSON.parse(fs.readFileSync(path.join(fixture, 'terrabuild-debug.json'), 'utf8')); + return Object.fromEntries(graph.nodes.filter(node => ['vscode', 'website'].includes(node.projectName) && node.target === 'build') + .map(node => [node.projectName, node.fingerprint.cacheKey])); + }; + const original = keys(); + fs.appendFileSync(path.join(fixture, '.github/workflows/ci.yml'), '\n# Workflow-only change\n'); + assert.deepEqual(keys(), original, 'Workflow changes must not invalidate frontend builds.'); + fs.appendFileSync(path.join(fixture, 'CHANGELOG.md'), '\n- Release-note-only change.\n'); + const releaseNotes = keys(); + assert.equal(releaseNotes.vscode, original.vscode, 'Release notes are not a compilation input.'); + assert.notEqual(releaseNotes.website, original.website, 'The website must still track the changelog.'); + execFileSync('npm', ['run', 'vscode:prepublish'], { cwd: path.join(fixture, 'vscode-fscript'), stdio: 'pipe' }); + assert.equal(fs.readFileSync(path.join(fixture, 'vscode-fscript/CHANGELOG.md'), 'utf8'), + fs.readFileSync(path.join(fixture, 'CHANGELOG.md'), 'utf8'), 'Packaging must include current release notes.'); + assert.deepEqual(keys(), releaseNotes, 'Generated packaging notes must not invalidate builds.'); + fs.appendFileSync(path.join(fixture, 'vscode-fscript/extension.js'), '\n// Source change\n'); + assert.notEqual(keys().vscode, original.vscode, 'Extension source changes must still invalidate its build.'); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); diff --git a/.gitignore b/.gitignore index 0c75948f..8f072c92 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ src/FScript.JavaScript/dist/ # Terrabuild planning/batch files and local diagnostics .terrabuild/ terrabuild-debug.* + +# Generated by vscode:prepublish, rather than tracked as a build input. +/vscode-fscript/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b23c9c4..fa850e00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Keep VS Code builds cached when release notes change by adding the changelog only during extension packaging. - Cancel superseded branch and pull-request CI runs for the same source branch while keeping test shards parallel. - Label GitHub test shards as test instead of ci in Insights. - Keep GitHub workflow reruns in separate Insights build groups while correlating each attempt's build and test shards. diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md index 8faaf9ad..fb8e1190 100644 --- a/docs/guides/terrabuild.md +++ b/docs/guides/terrabuild.md @@ -37,6 +37,8 @@ The toolchain phase builds `.build/toolchain/Dockerfile` with the .NET SDK and N Terrabuild stops discovery at a `PROJECT` file. The FFI sample therefore keeps scripts and `fscript.toml` at `samples/ffi`, with sibling `Provider`, `Renderer`, and `Tests` projects below it. Build the provider directly with `dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release` when using the existing host workflow. +The website includes the root changelog as a build input. The VS Code extension generates its untracked changelog copy in `vscode:prepublish`, so release-note edits do not invalidate extension compilation or tests. + Project-reference dependencies build before their consumers. Tests run already-built binaries with `--no-build --no-restore`. Tests that launch another project's executable must directly depend on that project's build target, not just their own cached build. FFI test targets consume `target.^build` to restore the CLI. The GitHub-only website `test_ci` target directly consumes `target.^build` for generated JavaScript and runs `npm ci` before typechecking to recreate package launcher symlinks on a fresh runner. Its normal local `test` target is unchanged. Conditional sibling-SDK project references are declared explicitly in the graph; ordinary builds use the published SDK package. Non-.NET checks declare their inputs and upstream builds too. The SDK compatibility check runs in a temporary source copy because it deliberately publishes different assembly versions. Builds use the normal `bin`, `obj`, `node_modules`, and generated website directories on the mounted checkout. They contain Linux outputs after a container run. Rebuild through the host toolchain before relying on native host apphosts or platform-specific Node dependencies. Avoid running host and container builds concurrently in the same checkout. diff --git a/vscode-fscript/CHANGELOG.md b/vscode-fscript/CHANGELOG.md deleted file mode 120000 index 04c99a55..00000000 --- a/vscode-fscript/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -../CHANGELOG.md \ No newline at end of file diff --git a/vscode-fscript/PROJECT b/vscode-fscript/PROJECT index e5d43f0c..4c81192f 100644 --- a/vscode-fscript/PROJECT +++ b/vscode-fscript/PROJECT @@ -1,6 +1,6 @@ project vscode { includes = [ "**/*" "../.build/checks.sh" "../tests/FScript.LanguageServer.Tests/**/*.js" ] - ignores = [ "node_modules/**" "dist/**" "server/**" "*.vsix" "TestResults/**" ] + ignores = [ "node_modules/**" "dist/**" "server/**" "*.vsix" "TestResults/**" "CHANGELOG.md" ] @npm { } } diff --git a/vscode-fscript/package.json b/vscode-fscript/package.json index 461822ab..66287ffe 100644 --- a/vscode-fscript/package.json +++ b/vscode-fscript/package.json @@ -257,7 +257,7 @@ "test": "node --test ../tests/FScript.LanguageServer.Tests/*Tests.js", "lint": "echo \"No lint configured\"", "compile": "esbuild extension.js --bundle --platform=node --format=cjs --target=node20 --outfile=dist/extension.js --external:vscode", - "vscode:prepublish": "npm run compile", + "vscode:prepublish": "node -e \"require('node:fs').copyFileSync('../CHANGELOG.md', 'CHANGELOG.md')\" && npm run compile", "package": "vsce package", "postinstall": "npm run compile" },