Repository navigation
🐛 fix: a class named as .NET names one of its types is a module the page imports #2375
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |