Skip to content

🐛 fix: a class named as .NET names one of its types is a module the page imports #2375

🐛 fix: a class named as .NET names one of its types is a module the page imports

🐛 fix: a class named as .NET names one of its types is a module the page imports #2375

Workflow file for this run

name: CI
on:
push:
branches: ["main"]
# `v*` is the framework's release; `ext-v*` is the extension's. The second is not a prefix of the
# first ON PURPOSE — a tag named `vscode-v0.2.0` matched `v*` and ran the framework's packing with
# VERSION="scode-v0.2.0". Renaming it fixed that and broke this: with only `v*` here, an `ext-v*`
# tag triggered nothing at all.
tags: ["v*", "ext-v*"]
pull_request:
branches: ["main"]
workflow_dispatch:
inputs:
version:
description: "Version to build (leave empty to use Directory.Build.props)"
required: false
default: ""
jobs:
# A commit message says something about the change and nothing about what composed it. Three
# squash messages on main ended with the scaffolding of the tool that wrote them before anything
# compared, so this compares. On a pull request it reads the branch's commits, which is
# prevention; on a push to main it reads what was actually squashed, which is the only place that
# message exists — and by then the merge has happened, so the job going red IS the report.
commit-messages:
runs-on: ubuntu-latest
steps:
# Full history: the guard refuses to walk a range it cannot resolve rather than report a
# clean sweep of nothing.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: The guard still discriminates
run: ./scripts/check-commit-messages.sh --self-test
- name: No commit message carries what a tool left behind
# Through the environment rather than interpolated into the command: a ref is data, and
# `${{ }}` substitution happens before the shell ever sees it.
env:
EQ_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
EQ_HEAD: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
run: ./scripts/check-commit-messages.sh --range "$EQ_BASE" "$EQ_HEAD"
# Every OpenSpec change and spec, validated strictly by the CLI tools/openspec/package-lock.json
# pins, through the same script a developer runs. The plain `openspec validate --all --strict`
# answers "No items found" and exits 0 on an empty tree, so the script also fails when nothing was
# validated: an openspec/ that lost its specs is not a state main can be in.
openspec:
runs-on: ubuntu-latest
# It reads the tree and nothing else.
permissions:
contents: read
env:
OPENSPEC_TELEMETRY: "0"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# The CLI's own engine range starts at 20.19; 22 is what the other Node jobs here use.
node-version: 22
- name: Validate every change and spec, strictly
run: ./scripts/check-openspec.sh
# The SessionStart hook acts only in a cloud container, a path no laptop takes, so nothing else
# would notice the day it stopped working. This runs it the way a fresh container would (a hostile
# global git config, no dotnet on PATH) and asserts every promise, with two controls: a commit
# before the hook must fail, and a hook with a wrong SHA-256 must refuse the SDK.
session-start:
runs-on: ubuntu-latest
# It reads the tree and nothing else; the fixture's commits stay in a throwaway clone.
permissions:
contents: read
steps:
# Full history, so the fixture's clone of HEAD is an ordinary repository.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Run the hook as a fresh cloud container, and assert what it promises
run: ./scripts/check-session-start.sh
# The docs guards read the wiki at a pull request's own wiki branch when there is one, and master
# otherwise (#406), and the checkout that decides it is a script. This proves it against a fixture
# wiki, reading each run's page rather than its exit code: the branch it should read, a name that
# is only the tail of a branch, a wiki it cannot list (which must fail, not read master), and a
# branch name written to run, which must not.
wiki-checkout:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: The wiki checkout still discriminates
run: ./scripts/checkout-wiki.sh --self-test
# The loop that publishes a release, run against a stub of the push and of nuget.org's index. The
# jobs that publish run only on a `v*` tag or by hand, so this is the only place the loop runs
# before a release depends on it: 0.2.0-preview.61 stopped after 33 of 50 packages on three 500s
# from nuget.org, and no rerun of the loop it had could finish it (#759). The check carries its
# own controls: a loop that stops at the first failure, pushes what is already there, tries a
# refused key again, does not wait, or prints the key has to fail the case that guards it.
publish-packages:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Publishing still finishes the list
run: ./scripts/check-publish-packages.sh
# The runtime's TypeScript, proven once: the command a developer runs, through the embedded bun
# (bun.lock as pinned, tsc, vitest). It hands nothing on. The bundle an app runs is written by
# the Server's own build, in every job that builds it, and a library build of the same sources
# used to travel from here to the packages instead (#335).
test-runtime:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Type-check and test the runtime (embedded bun)
run: dotnet build src/eQuantic.UI.Runtime -t:TestRuntime
# Build platform-specific runtime packages
package-extension:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- uses: actions/setup-node@v4
with:
# 22, because vsce's own dependencies ask for it — on 20 npm prints an EBADENGINE wall.
node-version: 22
# Builds the .vsix, and then checks the two ways it can be quietly broken: a .vscodeignore that
# leaves the design host out (the extension installs, activates, and fails on the first
# preview), and a host that was published against a different shape of the protocol. The
# second check SPAWNS the packaged host and speaks to it.
- name: Package the VS Code extension
working-directory: extensions/vscode
run: |
npm ci --no-audit --no-fund
npm run package
- name: Upload the extension
uses: actions/upload-artifact@v4
with:
name: equantic-ui-vsix
path: extensions/vscode/equantic-ui.vsix
build-platform-runtimes:
needs: test-runtime
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
# Each OS leg packs BOTH architectures of its platform — packing only zips the right
# Bun binary, it does not depend on the host arch. The SDK references all six.
- os: ubuntu-latest
runtime: Linux64
runtime2: LinuxArm64
- os: windows-latest
runtime: Win64
runtime2: WinArm64
- os: macos-latest
runtime: Osx64
runtime2: OsxArm64
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Determine Version
id: version
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
# A release tag IS the version: v0.2.0-preview.1 -> 0.2.0-preview.1
echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
elif [ "${{ github.event.inputs.version }}" != "" ]; then
echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
else
# Read version from Directory.Build.props (single source of truth)
VERSION=$(grep -o '<Version>[^<]*</Version>' Directory.Build.props | sed 's/<Version>//;s/<\/Version>//')
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Using version from Directory.Build.props: $VERSION"
fi
- name: Build and Pack Runtime.${{ matrix.runtime }} + ${{ matrix.runtime2 }}
shell: bash
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
dotnet pack src/eQuantic.UI.Runtime.${{ matrix.runtime }}/eQuantic.UI.Runtime.${{ matrix.runtime }}.csproj -c Release -p:Version=$VERSION
dotnet pack src/eQuantic.UI.Runtime.${{ matrix.runtime2 }}/eQuantic.UI.Runtime.${{ matrix.runtime2 }}.csproj -c Release -p:Version=$VERSION
- name: Upload Runtime.${{ matrix.runtime }} + ${{ matrix.runtime2 }} Packages
uses: actions/upload-artifact@v4
with:
name: runtime-${{ matrix.runtime }}-package
path: |
src/eQuantic.UI.Runtime.${{ matrix.runtime }}/bin/Release/*.nupkg
src/eQuantic.UI.Runtime.${{ matrix.runtime2 }}/bin/Release/*.nupkg
# Build core packages (platform-agnostic)
build-packages:
needs: build-platform-runtimes
# macOS: the package set includes the iOS/Android shells, whose target frameworks need
# platform workloads that only install on a Mac (and the full native test suite runs here
# instead of skipping its Apple-framework facts).
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Install mobile workloads (iOS/Android shells)
run: dotnet workload install ios android
- name: Download Platform Runtime Packages
uses: actions/download-artifact@v4
with:
pattern: runtime-*-package
path: .local-packages/
merge-multiple: true
- name: Create artifacts directory for NuGet
run: |
mkdir -p artifacts/packages
# Copy platform runtime packages to artifacts for NuGet resolution. find, not a glob:
# multi-path artifact uploads preserve their directory structure on download.
find .local-packages -name "*.nupkg" -exec cp {} artifacts/packages/ \;
- name: Determine Version
id: version
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
# A release tag IS the version: v0.2.0-preview.1 -> 0.2.0-preview.1
echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
elif [ "${{ github.event.inputs.version }}" != "" ]; then
echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
else
# Read version from Directory.Build.props (single source of truth)
VERSION=$(grep -o '<Version>[^<]*</Version>' Directory.Build.props | sed 's/<Version>//;s/<\/Version>//')
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Using version from Directory.Build.props: $VERSION"
fi
- name: Build and Pack All Packages
shell: bash
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
# The package set is DISCOVERED, not listed: every project under src/ that says
# IsPackable (the csproj is the one place that decision already lives) is built and
# packed here. The hand-kept roster this replaces missed eQuantic.UI.Email for two
# releases — it never reached nuget.org while the release notes announced it. Tools
# that ride inside the SDK packages (eQuantic.Build, eQuantic.UI.Native.Build) and
# design-time projects say IsPackable=false themselves. Left out on purpose: the
# per-RID runtime packs (the platform matrix packs them) and the three projects below,
# whose pack targets stage tools and must not run with --no-build.
SEPARATE="eQuantic.UI.Sdk eQuantic.UI.Sdk.Native eQuantic.UI.Templates"
PACKED=0
for csproj in src/*/*.csproj; do
name=$(basename "$(dirname "$csproj")")
case " $SEPARATE " in *" $name "*) continue;; esac
case "$name" in eQuantic.UI.Runtime.*) continue;; esac
# tr, not ${var,,}: the macOS runner's bash is 3.2, which has no case conversion —
# the same reason the Test step avoids globstar.
packable=$(dotnet msbuild "$csproj" -getProperty:IsPackable -nologo 2>/dev/null | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
if [ "$packable" = "false" ]; then continue; fi
echo "::group::pack $name"
dotnet build "$csproj" -c Release -p:Version=$VERSION
dotnet pack "$csproj" -c Release --no-build -p:Version=$VERSION
echo "::endgroup::"
PACKED=$((PACKED + 1))
done
echo "::notice::packed $PACKED discovered packages"
# A discovery that finds nothing is a broken discovery, not an empty repository.
[ "$PACKED" -ge 30 ] || { echo "::error::only $PACKED packable projects found under src/"; exit 1; }
# The SDKs and the templates pack WITHOUT --no-build: their pack targets stage tools (eqc,
# eQuantic.Build, eqicon) and stamp versions.
dotnet pack src/eQuantic.UI.Sdk/eQuantic.UI.Sdk.csproj -c Release -p:Version=$VERSION
dotnet pack src/eQuantic.UI.Sdk.Native/eQuantic.UI.Sdk.Native.csproj -c Release -p:Version=$VERSION
dotnet pack src/eQuantic.UI.Templates/eQuantic.UI.Templates.csproj -c Release -p:Version=$VERSION
# The runtime the Server package ships as a FILE is the one it embeds and serves: BundleRuntime
# writes it once, the compiler embeds it, the pack collects it. An app's copy comes from this
# file, and a presence check passed four releases while that copy was a library build with no
# `boot` that no page could start from (#335). So the bytes are compared, and the entry point
# is looked for.
- name: The Server package ships the runtime it serves
shell: bash
run: |
set -euo pipefail
package=$(ls src/eQuantic.UI.Server/bin/Release/eQuantic.UI.Server.[0-9]*.nupkg)
unzip -p "$package" tools/runtime/runtime.js > packed-runtime.js
cmp packed-runtime.js src/eQuantic.UI.Server/wwwroot/runtime.js
grep -q 'function boot' packed-runtime.js
echo "$package ships the served runtime ($(wc -c < packed-runtime.js) bytes)."
# The wiki is a SEPARATE repository, and the version-mark guard reads it from beside this
# one. Without this the guard passes here having checked nothing, and a wrong "Since
# 0.2.0-preview.N" reaches the site — which mirrors the wiki — unchallenged.
# The version-mark guard asks `git tag --contains`, and a shallow checkout has no tags: it
# would answer "unreleased" for everything and pass having checked nothing.
- name: Fetch tags (for the version-mark guard)
shell: bash
run: git fetch --tags --force --depth=1 origin
- name: Check out the wiki (for the docs guards)
shell: bash
continue-on-error: true
env:
# A pull request that changes what the wiki must say carries its pages on a wiki branch
# named like its own, and the guards read that branch; anything else reads master (#406).
# Only this repository's pull requests: a branch name is unique here, and only here can
# anyone push a wiki branch, while a fork's branch can repeat one of ours and would read
# another pull request's pages. Through the environment: a branch name is text someone
# else wrote, and `${{ }}` substitution happens before the shell ever sees it.
EQ_WIKI_BRANCH: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || '' }}
run: ./scripts/checkout-wiki.sh ../equantic-ui.wiki
- name: Test
shell: bash
env:
# Conformance must FAIL (not skip) if no JS engine works on the runner.
EQ_REQUIRE_JS: "1"
run: |
set -uo pipefail
# EVERY project reports, and the step fails at the END. `set -e` with a plain loop stops
# at the first red project, so the ones after it never run — and the report reads as one
# failure when it is an unknown number. The same reason the matrix sets fail-fast: false,
# one level further in. Measured: Web.Tests went red on both legs and hid eight projects.
#
# Sorted, because `find` returns filesystem order: without it, WHICH project hides the
# others changes from one machine to the next.
#
# find, not globstar: macOS ships bash 3.2, which has no globstar. Process substitution
# rather than a pipe, so the loop body is not a subshell and `status` survives it.
status=0
ran=0
failed=""
while IFS= read -r test_project; do
echo "::group::$test_project"
ran=$((ran + 1))
dotnet test "$test_project" --configuration Release --verbosity normal || {
status=1
failed="$failed$test_project"$'\n'
}
echo "::endgroup::"
done < <(find tests -name "*.csproj" | sort)
# An empty list is not a pass. Without this the step is green when `find` matches nothing —
# a renamed folder, a wrong working directory — having tested exactly nothing, which is the
# same silence this whole job exists to break.
if [ "$ran" -eq 0 ]; then
echo "::error::Found no test projects under tests/. That is not a green suite."
exit 1
fi
echo "Ran $ran test projects on ${{ runner.os }}."
if [ "$status" -ne 0 ]; then
echo "::error::These test projects failed on ${{ runner.os }}:"
printf '%s' "$failed"
fi
exit $status
- name: Collect All NuGet Packages
shell: bash
run: |
mkdir -p nupkgs
# Copy platform runtime packages (find: the artifact keeps its directory structure)
find .local-packages -name "*.nupkg" -exec cp {} nupkgs/ \;
# Copy core packages
find src -name "*.nupkg" -path "*/bin/Release/*" -exec cp {} nupkgs/ \;
# README.md names a picture by its path in the repository, which GitHub draws on every branch
# and nuget.org does not draw at all. So a package's readme is written when it packs, with
# each picture at its address under the release's tag (#880). A test runs that target. This
# reads the readme a pack put in each package, the per-OS runtime packages the Linux and
# Windows runners packed among them, and compares it with README.md byte for byte.
- name: Every package's readme names its pictures by their address
shell: bash
run: dotnet run scripts/check-package-readme.cs -- nupkgs
- name: Upload All NuGet Packages
uses: actions/upload-artifact@v4
with:
name: nupkgs
path: nupkgs/*.nupkg
# THE SUITE RUNS WHERE THE CODE RUNS.
#
# `build-packages` above is the only job that ever ran `dotnet test`, and it is macOS-only for a
# real reason — the iOS and Android shells need workloads that install on a Mac. The cost was
# invisible: a contract test over the DirectWrite measurer failed on every Windows checkout from
# the day it was written (#123) until somebody read the code (#136), because nothing here ever
# executed it. A pin nobody runs is a comment with a test's authority.
#
# So the same suite runs on Linux and Windows too. No filter and no hand-picked subset: the point
# is to LEARN which projects are OS-specific rather than to assume. What fails is a defect or a
# fence, and a fence is a Skip whose text names the OS and the reason — never a filter here,
# which would hide the fact in a file nobody reads while running the tests.
test:
needs: test-runtime
runs-on: ${{ matrix.os }}
strategy:
# Both legs always report. Cancelling one because the other failed is how a two-OS gap gets
# read as a one-OS gap.
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
# The version-mark guard asks `git tag --contains`, and a shallow checkout has no tags: it
# would answer "unreleased" for everything and pass having checked nothing.
- name: Fetch tags (for the version-mark guard)
shell: bash
run: git fetch --tags --force --depth=1 origin
- name: Check out the wiki (for the docs guards)
shell: bash
continue-on-error: true
env:
# A pull request that changes what the wiki must say carries its pages on a wiki branch
# named like its own, and the guards read that branch; anything else reads master (#406).
# Only this repository's pull requests: a branch name is unique here, and only here can
# anyone push a wiki branch, while a fork's branch can repeat one of ours and would read
# another pull request's pages. Through the environment: a branch name is text someone
# else wrote, and `${{ }}` substitution happens before the shell ever sees it.
EQ_WIKI_BRANCH: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || '' }}
run: ./scripts/checkout-wiki.sh ../equantic-ui.wiki
- name: Test
shell: bash
env:
# Conformance must FAIL (not skip) if no JS engine works on the runner.
EQ_REQUIRE_JS: "1"
run: |
set -uo pipefail
# EVERY project reports, and the step fails at the END. `set -e` with a plain loop stops at
# the first red project, so the ones after it never run — and the report then reads as one
# failure when it is an unknown number. The same reason the matrix sets fail-fast: false,
# one level further in.
#
# Sorted, because `find` returns filesystem order: without it, WHICH project hides the
# others changes from one machine to the next.
#
# find, not globstar: the same reason the macOS leg avoids it, and the Windows leg runs
# this through Git Bash, whose bash is not the one on the PATH of the other two. Process
# substitution rather than a pipe, so the loop body is not a subshell and `status` survives.
status=0
ran=0
failed=""
while IFS= read -r test_project; do
echo "::group::$test_project"
ran=$((ran + 1))
dotnet test "$test_project" --configuration Release --verbosity normal || {
status=1
failed="$failed$test_project"$'\n'
}
echo "::endgroup::"
done < <(find tests -name "*.csproj" | sort)
# An empty list is not a pass: a renamed folder and the step is green having tested nothing.
if [ "$ran" -eq 0 ]; then
echo "::error::Found no test projects under tests/. That is not a green suite."
exit 1
fi
echo "Ran $ran test projects on ${{ runner.os }}."
if [ "$status" -ne 0 ]; then
echo "::error::These test projects failed on ${{ runner.os }}:"
printf '%s' "$failed"
fi
exit $status
# NOTHING HERE HAD EVER BUILT A MOBILE APP.
#
# `build-packages` builds the iOS and Android SHELLS, which are libraries. `gate-templates`
# scaffolds and builds the native template, which is `net10.0` — desktop. So no job, on any
# branch and on any tag, ever produced an .app or an .apk, and every build check that only fires
# for an app bundle went unexercised: the Xcode version comparison this commit turns off is one
# of them, and it was found by a developer on their own machine rather than here.
#
# The samples are the consumers that exist — web, desktop, and the mobile one with all three
# target frameworks — so they are what the pipe can build to make "it works on my machine" say
# something about anyone else's. Debug, because that is the configuration a developer runs and
# the one `IsEQuanticDevMode` wires to project references.
samples:
needs: test-runtime
# macOS is the only runner that can build every target framework the samples declare: ios and
# android need workloads that install on a Mac, and the desktop head needs CoreGraphics.
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Install mobile workloads (the samples' ios and android heads)
run: dotnet workload install ios android
- name: Build every sample
shell: bash
run: |
set -uo pipefail
# EVERY sample reports and the step fails at the END, for the reason the Test steps give:
# stopping at the first red one turns an unknown number of failures into a report of one.
# Sorted, because `find` returns filesystem order. Process substitution rather than a
# pipe, so the loop body is not a subshell and `status` survives it.
status=0
built=0
failed=""
while IFS= read -r sample; do
echo "::group::$sample"
built=$((built + 1))
dotnet build "$sample" || {
status=1
failed="$failed$sample"$'\n'
}
echo "::endgroup::"
done < <(find samples -name "*.csproj" | sort)
# An empty list is not a pass: a renamed folder and this step is green having built
# nothing, which is the silence it exists to break.
if [ "$built" -eq 0 ]; then
echo "::error::Found no sample projects under samples/. That is not a green build."
exit 1
fi
echo "Built $built samples on ${{ runner.os }}."
if [ "$status" -ne 0 ]; then
echo "::error::These samples failed to build:"
printf '%s' "$failed"
fi
exit $status
# A page that uses a class library the app references (#624). The builds above cannot say it:
# before the fix this sample built green with a page that named the library's types and
# imported none of them, which only a browser reported. So what the build WROTE is read: the
# projects the SDK handed the compiler, a module for every type the page reaches and none for
# the server class it does not, and the catalogs of two projects that each have a Strings.
- name: The shared-library sample's page holds what it uses of its library
shell: bash
run: ./scripts/check-shared-library-sample.sh
# Composing a map installed an npm package beside eqc on the first Debug build, and bun's
# auto-install fetched what it imported (#356). eqc composes maps in C# now, so a package
# manager's files in its folder are a fetch someone put back. The Debug builds above composed
# every web sample's maps, and a folder with no eqc in it would prove nothing.
- name: No build wrote a package into eqc's folder
shell: bash
run: |
set -uo pipefail
tool=src/eQuantic.Build/bin/Debug/net10.0
if [ ! -f "$tool/eqc.dll" ]; then
echo "::error::no eqc at $tool, so this would check nothing"
exit 1
fi
found=$(find "$tool" \( -name node_modules -o -name package.json -o -name 'bun.lock*' \) -print)
if [ -n "$found" ]; then
echo "::error::a build wrote a package manager's files into eqc's folder:"
echo "$found"
exit 1
fi
echo "eqc's folder holds no package manager's files."
# Beside a source tree the SDK copies the Server project's own bundle next to a web app's
# modules. Each copy must be the served runtime byte for byte, derived from what the builds
# wrote rather than listed here, and a tree where no sample wrote one is not a pass.
- name: Each web sample carries the runtime it is served
shell: bash
run: |
set -uo pipefail
copies=$(find samples -name obj -prune -o -name bin -prune -o -path "*/wwwroot/_equantic/runtime.js" -print | sort)
if [ -z "$copies" ]; then
echo "::error::no sample wrote wwwroot/_equantic/runtime.js"
exit 1
fi
status=0
for copy in $copies; do
if cmp -s "$copy" src/eQuantic.UI.Server/wwwroot/runtime.js; then
echo "$copy is the served runtime."
else
echo "::error::$copy is not the runtime the Server serves"
status=1
fi
done
exit $status
# A map carries C# only in Debug, and names the project's files rather than the machine's
# (#352). A Release publish takes no map, and nothing it serves holds a [ServerAction]'s body:
# FormScreen.Register returns a message only the server has, and the published web root is
# searched for it. The Debug check runs first, because the Release build removes those maps.
- name: A Release publish carries no C#
shell: bash
run: |
set -uo pipefail
status=0
debug_maps=$(find samples/DefaultUIDashboard/wwwroot/_equantic -name '*.js.map' | wc -l | tr -d ' ')
if [ "$debug_maps" -eq 0 ]; then
echo "::error::the Debug build wrote no source maps, so the check below would prove nothing about them"
status=1
fi
if grep -rlF "$GITHUB_WORKSPACE" samples/DefaultUIDashboard/wwwroot/_equantic --include='*.map'; then
echo "::error::a Debug source map names the build machine's paths"
status=1
fi
dotnet publish samples/DefaultUIDashboard -c Release -o published || exit 1
maps=$(find published/wwwroot/_equantic -name '*.map*' | wc -l | tr -d ' ')
modules=$(find published/wwwroot/_equantic -name '*.js' | wc -l | tr -d ' ')
if [ "$modules" -eq 0 ]; then
echo "::error::the publish holds no modules at all, so it proves nothing"
status=1
fi
if [ "$maps" -ne 0 ]; then
echo "::error::the publish holds $maps source map(s)"
status=1
fi
if grep -rlF "That address is already registered." published/wwwroot; then
echo "::error::a server action's body reached the published web root"
status=1
fi
echo "Debug maps: $debug_maps. Published: $modules modules, $maps maps."
exit $status
# What each page costs to load, on every pull request (#290). The runtime every page loads
# first has a budget the suite enforces (ServedRuntimeBudgetTests); a page module is the app's
# own code, so it is REPORTED, and the question a reviewer asks is how it moved. Read from the
# Release publish above, which is what ships, and a publish with no module is not a report.
- name: The page modules' sizes, reported
shell: bash
run: |
set -uo pipefail
dir=published/wwwroot/_equantic
modules=$(find "$dir" -name '*.js' ! -name runtime.js | sort)
if [ -z "$modules" ]; then
echo "::error::the publish holds no page module, so there is nothing to report"
exit 1
fi
{
echo "### Page modules: samples/DefaultUIDashboard, Release publish"
echo
echo "| module | bytes | gzip -9 |"
echo "|---|---:|---:|"
total=0
total_gz=0
count=0
for module in $modules; do
name=${module#"$dir"/}
raw=$(wc -c < "$module" | tr -d ' ')
gz=$(gzip -9 -c "$module" | wc -c | tr -d ' ')
total=$((total + raw))
total_gz=$((total_gz + gz))
count=$((count + 1))
# A C# type name has no hyphen, so one in a module's name is bun's chunk naming.
case "$name" in
*-*) echo "| \`$name\` (shared chunk) | $raw | $gz |" ;;
*) echo "| \`$name\` | $raw | $gz |" ;;
esac
done
echo "| **$count modules** | **$total** | **$total_gz** |"
echo
echo "A shared chunk is what two or more modules have in common, split out by bun (eqc runs it with \`--splitting\`) and named after one of them; it loads once, with the first page that needs it. The served runtime, which every page loads first, is budgeted by \`ServedRuntimeBudgetTests\`."
} | tee -a "$GITHUB_STEP_SUMMARY"
# Edit, then publish: the most ordinary sequence there is, and it failed (#361). What two
# pages share is a chunk named by its content's hash, so changing it renames the chunk, and
# the old name, registered while the project evaluated, failed the publish's compression. The
# step changes the dashboard's console shell and publishes without building first. A chunk of
# the last build has to be gone from this one, or the step proved nothing; the publish has to
# hold exactly the chunks this build wrote; and each module it holds has to be served
# compressed, from THIS build's file, in both formats a browser negotiates: the same order
# once compressed the previous build's modules, and a compression that ran before the assets
# were defined would drop every module to its raw size, neither failing anything.
# scripts/check-compressed-assets.cs reads the bytes of both with .NET's own streams. Then
# the source goes back and a --no-build publish runs, which must carry what the last build
# wrote: the output folder is defined by a build target, and one that compiled here would
# carry the chunks of the source instead.
- name: A publish right after a shared component changes
shell: bash
run: |
set -uo pipefail
shell=samples/DefaultUIDashboard/Screens/ConsoleShell.cs
dir=samples/DefaultUIDashboard/wwwroot/_equantic
chunks() { [ -d "$1" ] && ls "$1" | grep -E -- '-[a-z0-9]{8}\.js$' | sort; }
before=$(chunks "$dir")
perl -0pi -e 's/(class ConsoleShell[^{]*\{)/$1\n public const string CiMark = "the shared chunk changed";/' "$shell"
status=0
dotnet publish samples/DefaultUIDashboard -c Release -o republished || status=1
git checkout -- "$shell"
after=$(chunks "$dir")
published=$(chunks republished/wwwroot/_equantic)
gone=$(comm -23 <(echo "$before") <(echo "$after"))
echo "chunks before: $(echo $before)"
echo "chunks after: $(echo $after)"
echo "published: $(echo $published)"
if [ "$status" -ne 0 ]; then
echo "::error::the publish after a shared component changed failed"
elif [ -z "$gone" ] || [ -z "$after" ]; then
echo "::error::no chunk of the last build was renamed, so this proves nothing about a renamed one"
status=1
elif [ "$published" != "$after" ]; then
echo "::error::the publish holds other chunks than the build wrote"
status=1
fi
dotnet run scripts/check-compressed-assets.cs -- republished/wwwroot/_equantic || status=1
dotnet publish samples/DefaultUIDashboard -c Release --no-build -o unbuilt || status=1
unbuilt=$(chunks unbuilt/wwwroot/_equantic)
echo "--no-build: $(echo $unbuilt)"
if [ "$unbuilt" != "$after" ]; then
echo "::error::a --no-build publish holds other chunks than the last build wrote"
status=1
fi
exit $status
# Exit 0 is not the proof. A mobile target framework exists to produce a BUNDLE, and the
# build checks worth exercising here are precisely the ones that only run when one is
# produced. So read the artifact back: derived from what the build wrote, never a list of
# paths kept in this file, and counted — a tree with no mobile sample left would otherwise
# pass this having proved nothing at all.
- name: The mobile heads produced bundles
shell: bash
run: |
set -uo pipefail
# obj/ is pruned, not filtered: the iOS codesign step stages a second copy of the whole
# bin/ tree under it, and counting that one would mean the count survives deleting the
# real output.
bundles=$(find samples -name obj -prune \
-o -path "*/bin/*-ios/*" -name "*.app" -print \
-o -path "*/bin/*-android/*" -name "*.apk" -print | sort)
printf '%s\n' "$bundles"
app=$(printf '%s\n' "$bundles" | grep -c '\.app$' || true)
apk=$(printf '%s\n' "$bundles" | grep -c '\.apk$' || true)
if [ "$app" -lt 1 ] || [ "$apk" -lt 1 ]; then
echo "::error::the samples left $app iOS bundle(s) and $apk Android package(s) behind; expected at least one of each"
exit 1
fi
echo "iOS bundles: $app, Android packages: $apk."
# A bundle is not a launch (#796). An iOS shell that stops connecting its scene builds green
# and shows a black screen, and one built with the iOS 27 SDK without the scene life cycle
# does not start. So the Wallet is launched on a fresh simulator, on the newest iOS runtime the
# image has, and the line it prints after its last frame is read back from the unified log.
- name: The Wallet sample launches and draws on an iOS simulator
shell: bash
run: ./scripts/check-ios-launch.sh
# GitHub Packages is a convenience feed for every main push — a light ubuntu job so the heavy
# macOS build job never dies on the upload (exit 137).
publish-github:
needs: [build-packages, gate-templates, test, samples]
# The cross-OS suite is a GATE, not a report: `needs.test` below. Without it a red Linux or
# Windows leg sat beside a green macOS build and the packages published anyway — coverage that
# cannot stop anything is a comment with a check's authority.
#
# These lines live HERE and not inside the `if:` below, and that is not style. `if: |` is a
# LITERAL block scalar, so a `#` inside it is part of the expression, not a comment — GitHub
# then fails to parse the condition and the whole workflow fails at STARTUP, with zero jobs and
# no log. Every push in this repository ran no CI at all for an hour because of three lines of
# prose written one indent too far down.
#
# On a TAG this waits for the gate, as nuget.org already does. It did not, so a release whose
# templates failed to scaffold still landed here: preview.31 sat on this feed carrying a broken
# template while the public feed correctly never got it, and the two had to be reconciled by
# hand. A feed that can disagree with the other one is worse than a slow feed.
#
# On an ordinary main push the gate does not run at all (it is tag-only) and this must still
# publish — a SKIPPED dependency skips the job under the default rule, which is why the results
# are named one by one instead. `always()` reaches this condition even when a need was skipped;
# build-packages is then required explicitly, because always() would otherwise publish the
# packages a failed build never produced.
#
# `needs.test` is in the condition because the cross-OS suite is a GATE, not a report: without
# it a red Linux or Windows leg sat beside a green macOS build and the packages published
# anyway — coverage that cannot stop anything is a comment with a check's authority, which is
# the defect that job was added for. The note lives ABOVE the expression because `#` inside a
# GitHub expression is not a comment: it is a parse error that fails the whole workflow before
# any job starts, which is how main's CI broke for every branch at once.
#
# `needs.samples` is in it for the same reason and no other: the samples are the only place an
# app bundle is ever built, so a feed that publishes past a red one is publishing an SDK nobody
# has watched compile an app.
if: |
always()
&& (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
&& needs.build-packages.result == 'success'
&& needs.test.result == 'success'
&& needs.samples.result == 'success'
&& (needs.gate-templates.result == 'success' || needs.gate-templates.result == 'skipped')
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Download NuGet Packages
uses: actions/download-artifact@v4
with:
name: nupkgs
path: nupkgs/
- name: Publish to GitHub Packages
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for package in nupkgs/*.nupkg; do
echo "Publishing $package to GitHub Packages..."
dotnet nuget push "$package" \
--source "https://nuget.pkg.github.com/eQuantic/index.json" \
--api-key "$GITHUB_TOKEN" \
--skip-duplicate
done
# Release gate — runs ONLY on a v* tag: on every desktop OS, a fresh consumer must be able to
# scaffold and BUILD (and on the web template, PUBLISH) against the exact packages being
# released. The native template gates on macOS only (its icon pipeline needs CoreGraphics).
gate-templates:
needs: [build-packages, test, samples]
# `refs/tags/v` matches by PREFIX, so any tag beginning with v enters here — the extension's
# releases are tagged `ext-v*` for exactly that reason.
if: startsWith(github.ref, 'refs/tags/v')
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Download NuGet Packages
uses: actions/download-artifact@v4
with:
name: nupkgs
path: nupkgs/
- name: Gate — templates against the release feed
shell: bash
run: |
set -euo pipefail
FEED="$PWD/nupkgs"
# git-bash reports POSIX paths; NuGet on Windows needs the native form.
if [ "$RUNNER_OS" = "Windows" ]; then FEED="$(cygpath -w "$FEED")"; fi
mkdir -p gate && cd gate
printf '%s\n' \
'<?xml version="1.0" encoding="utf-8"?>' \
'<configuration>' \
' <packageSources>' \
' <clear />' \
" <add key=\"release-feed\" value=\"$FEED\" />" \
' <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />' \
' </packageSources>' \
'</configuration>' > nuget.config
dotnet new install "$FEED"/eQuantic.UI.Templates.*.nupkg
# WHICH shapes to gate is asked of the template, never kept in a list here. Only the
# default shell was ever built, so `--shell dashboard` and `--shell topnav` — and all four
# native shapes — reached strangers having been compiled by nobody. A list in this file
# would be one more thing for the author of a new shell to remember, in the file they are
# least likely to open; the `--shell <a|b|c>` line is what a user reads anyway.
shells() {
dotnet new "$1" --help | grep -oE -- '--shell <[^>]+>' | head -1 \
| sed -E 's/.*<([^>]+)>.*/\1/' | tr '|' ' '
}
WEB_SHELLS="$(shells equantic-app)"
# A gate that walks nothing passes for the wrong reason, and would go on passing.
test -n "$WEB_SHELLS"
echo "Web shapes: $WEB_SHELLS"
for shape in $WEB_SHELLS; do
# Dashes are legal in a project name and not in a namespace — the shape names them.
name="GateWeb$(echo "$shape" | tr -d '-')"
dotnet new equantic-app -n "$name" --shell "$shape"
(cd "$name" && cp ../nuget.config . && dotnet build -c Release)
done
# A DEBUG build composes source maps, and composing them wrote an npm package into the
# SDK's own folder in the package cache (#356). The folder is listed before and after, and
# the Release builds above have already restored it, so a missing folder is a failure.
SDK_DIR=$(find "$HOME/.nuget/packages/equantic.ui.sdk" -mindepth 1 -maxdepth 1 -type d | head -1)
test -n "$SDK_DIR"
FIRST="GateWeb$(echo "$WEB_SHELLS" | awk '{print $1}' | tr -d '-')"
before=$(cd "$SDK_DIR" && find . -type f | sort)
(cd "$FIRST" && dotnet build -c Debug)
after=$(cd "$SDK_DIR" && find . -type f | sort)
if [ "$before" != "$after" ]; then
echo "::error::a Debug build wrote into the SDK's package folder:"
diff <(echo "$before") <(echo "$after") || true
exit 1
fi
# PUBLISH once, on the first shape: the runtime lands through one target for every shell,
# so publishing each of them buys the same assertion three times over.
(cd "$FIRST" && dotnet publish -c Release -o publish)
# The published copy is the runtime the Server package serves, byte for byte. This was a
# presence check, and it passed four releases while the copy was a library build with no
# `boot` (#335). ../nupkgs is the feed's POSIX path, which unzip reads on every runner.
unzip -p ../nupkgs/eQuantic.UI.Server.[0-9]*.nupkg tools/runtime/runtime.js > served-runtime.js
cmp served-runtime.js "$FIRST"/publish/wwwroot/_equantic/runtime.js
grep -q 'function boot' served-runtime.js
if [ "$RUNNER_OS" = "macOS" ]; then
NATIVE_SHELLS="$(shells equantic-native)"
test -n "$NATIVE_SHELLS"
echo "Native shapes: $NATIVE_SHELLS"
for shape in $NATIVE_SHELLS; do
name="GateNative$(echo "$shape" | tr -d '-')"
dotnet new equantic-native -n "$name" --shell "$shape"
(cd "$name" && cp ../nuget.config . && dotnet build -c Release)
done
fi
echo "Template gate green on $RUNNER_OS."
publish-extension:
needs: package-extension
# A tag of its own — and one that cannot be MISTAKEN for a framework tag. The release jobs below
# fire on `refs/tags/v`, which `vscode-v0.2.0` also starts with: that tag ran the framework's
# packing with VERSION="scode-v0.2.0" and failed restore on a version that is not one. `ext-v*`
# shares no prefix with `v*`.
if: startsWith(github.ref, 'refs/tags/ext-v')
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
env:
# Read as job-level env so the steps can BRANCH on which credential exists — a secret cannot be
# tested directly in a step's `if`.
VSCE_PAT: ${{ secrets.VSCE_PAT }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
steps:
- uses: actions/setup-node@v4
with:
node-version: 22
# The SAME archive the packaging job built and verified, not a rebuild of it. A rebuild is a
# different artifact, and the one that was checked would not be the one that shipped.
- name: Download the extension
uses: actions/download-artifact@v4
with:
name: equantic-ui-vsix
# The tag and the manifest have to agree, or the job publishes a version nobody tagged — and the
# Marketplace refuses a version that already exists, which is how a release "fails at the end"
# for a reason decided at the beginning. Read out of the ARCHIVE, since that is what ships.
- name: The tag names the version being published
shell: bash
run: |
set -euo pipefail
tagged="${GITHUB_REF_NAME#ext-v}"
packaged="$(unzip -p equantic-ui.vsix extension/package.json | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version")"
if [ "$tagged" != "$packaged" ]; then
echo "::error::the tag says $tagged and the package says $packaged."
exit 1
fi
echo "publishing $packaged"
# Microsoft Entra, when it is configured. `vsce publish --azure-credential` acquires its token
# through the Azure CLI, so something has to log that CLI in first — on GitHub Actions that is
# this action with workload identity federation, and no secret is stored.
- name: Sign in to Azure
if: env.AZURE_CLIENT_ID != ''
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Publish to the Marketplace
shell: bash
run: |
set -euo pipefail
if [ -n "${AZURE_CLIENT_ID:-}" ]; then
npx @vscode/vsce@^3.9.2 publish --packagePath equantic-ui.vsix --azure-credential
elif [ -n "${VSCE_PAT:-}" ]; then
# Retires 2026-12-01. Kept because it is the only path that needs nothing but a secret.
npx @vscode/vsce@^3.9.2 publish --packagePath equantic-ui.vsix
else
echo "::error::No Marketplace credential. Set VSCE_PAT, or AZURE_CLIENT_ID + AZURE_TENANT_ID + AZURE_SUBSCRIPTION_ID for Entra."
echo "::error::(vsce 3.9.2 has no --oidc; trusted publishing is on the tool's main branch and not released yet.)"
exit 1
fi
publish-nuget:
needs: gate-templates
# `refs/tags/v` matches by PREFIX, so any tag beginning with v enters here — the extension's
# releases are tagged `ext-v*` for exactly that reason.
if: startsWith(github.ref, 'refs/tags/v')
runs-on: macos-latest
steps:
# For scripts/publish-packages.sh, the one loop this job and the workflow run by hand share.
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Download NuGet Packages
uses: actions/download-artifact@v4
with:
name: nupkgs
path: nupkgs/
# The script skips what nuget.org already lists, waits before it tries a failed push again,
# goes on to the next package and fails at the end naming what is still owed (#759). When it
# does fail, the release is finished with the "Publish a release" workflow, from this run's
# own packages: a rerun of this job replays the workflow as it was at the tag.
- name: Publish to nuget.org
shell: bash
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
run: ./scripts/publish-packages.sh nupkgs "${GITHUB_REF_NAME#v}"
# The GitHub Release for a framework tag, written AFTER nuget.org has the packages: a release page
# announcing what the public feed does not carry is the arrangement preview.31 already taught, and
# `needs: publish-nuget` is what makes it impossible here. The notes are the tag's own annotation —
# the migration notes live in the `git tag -a` message — read from the tag object rather than kept
# as a second copy that could disagree with it.
release-github:
needs: publish-nuget
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create the release from the tag's annotation
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: ./scripts/release-page.sh "$GITHUB_REF_NAME"