diff --git a/.github/workflows/ci-acp-sdk.yml b/.github/workflows/ci-acp-sdk.yml index 7a49c1cc1..f39dbd4aa 100644 --- a/.github/workflows/ci-acp-sdk.yml +++ b/.github/workflows/ci-acp-sdk.yml @@ -16,7 +16,7 @@ on: - "NuGet.config" - "global.json" pull_request: - branches: [develop] + branches: [develop, main] paths: - "src/SalmonEgg.Acp/**" - "src/SalmonEgg.Acp/README.md" diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index c0a861dc9..323dfb9be 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -13,7 +13,7 @@ on: - "LICENSE" - ".github/FUNDING.yml" pull_request: - branches: [develop] + branches: [develop, main] paths-ignore: # Documentation-only edits cannot change build or test outcomes, and running the heavy platform # matrix for them costs runner minutes while adding no signal. Listed explicitly rather than as @@ -103,10 +103,11 @@ jobs: -SkipContractSuites # The release build can only assert this rule while producing a real MSI, which happens on tags - # only. Running the rule itself here keeps a weakened PATH check from reaching a release. - - name: Run CLI MSI PATH contract gate + # only. Running the rule itself here keeps a weakened PATH check from reaching a release. Both + # Windows installers register the command this way, so this one rule covers both. + - name: Run MSI PATH contract gate shell: pwsh - run: ./scripts/gates/run-cli-msi-path-contract-gate.ps1 + run: ./scripts/gates/run-msi-path-contract-gate.ps1 # The MSIX contract gate itself only runs against a real package in the platform workflow. Running # its self-test here keeps the rule from being weakened without anyone noticing: a check that never @@ -115,7 +116,7 @@ jobs: shell: pwsh run: ./scripts/gates/run-msix-package-contract-gate.ps1 -SelfTest - # Same reasoning as the CLI MSI gate above, for the desktop package: the release step can only read + # Same reasoning as the PATH gate above, for the desktop package as a whole: the release step can only read # a real MSI on a tag with WiX present, so the rule ran unrehearsed until it broke the v1.3.0 # release from inside OpenView. This drives the rule against fake databases on every push. - name: Run desktop MSI contract gate @@ -126,6 +127,13 @@ jobs: shell: bash run: scripts/gates/run-release-artifact-contract-gate.sh --self-test + # The macOS installer's postinstall is the only thing that puts salmon-egg on PATH there, and it + # normally runs only inside `installer -pkg` on a Mac. This drives that exact script against fake + # roots, so a weakened or broken link step fails on the pushing commit instead of on a user's machine. + - name: Run macOS installer PATH contract gate + shell: bash + run: scripts/gates/run-macos-pkg-contract-gate.sh + - name: Upload test results if: always() uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 782e612fb..5a39272b0 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -13,7 +13,7 @@ on: - "LICENSE" - ".github/FUNDING.yml" pull_request: - branches: [develop] + branches: [develop, main] paths-ignore: # Documentation-only edits cannot change build or test outcomes, and running the heavy platform # matrix for them costs runner minutes while adding no signal. Listed explicitly rather than as diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cdd308cbc..70b6e82f2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -11,7 +11,7 @@ on: - "global.json" - ".github/workflows/codeql.yml" pull_request: - branches: [develop] + branches: [develop, main] paths: - "src/**" - "tests/**" diff --git a/.github/workflows/gui-smoke-gates.yml b/.github/workflows/gui-smoke-gates.yml index 43b18a8e3..1dad6bd21 100644 --- a/.github/workflows/gui-smoke-gates.yml +++ b/.github/workflows/gui-smoke-gates.yml @@ -14,7 +14,7 @@ on: - "LICENSE" - ".github/FUNDING.yml" pull_request: - branches: [develop] + branches: [develop, main] paths-ignore: # Documentation-only edits cannot change build or test outcomes, and running the heavy platform # matrix for them costs runner minutes while adding no signal. Listed explicitly rather than as diff --git a/.github/workflows/platform-build-gates.yml b/.github/workflows/platform-build-gates.yml index c3362466d..037bfc524 100644 --- a/.github/workflows/platform-build-gates.yml +++ b/.github/workflows/platform-build-gates.yml @@ -13,7 +13,7 @@ on: - "LICENSE" - ".github/FUNDING.yml" pull_request: - branches: [develop] + branches: [develop, main] paths-ignore: # Documentation-only edits cannot change build or test outcomes, and running the heavy platform # matrix for them costs runner minutes while adding no signal. Listed explicitly rather than as @@ -72,15 +72,41 @@ jobs: with: global-json-file: global.json + # Installing SalmonEgg installs the salmon-egg command, so the app package carries the CLI and + # symlinks it into /usr/bin. Published first because the app publish embeds it. + - name: Publish bundled CLI + id: bundled-cli + run: scripts/release/publish-cli-binary.sh --rid linux-x64 --configuration ${{ env.CONFIGURATION }} + + - name: Smoke the bundled CLI executable + run: scripts/gates/run-cli-release-artifact-smoke.sh "${{ steps.bundled-cli.outputs.executable-path }}" + + # Self-contained rather than framework-dependent: this is the shape the Debian package ships, and a + # framework-dependent publish would prove something about a layout nobody installs. The bundled CLI + # lands at cli/salmon-egg in the publish output, which is what the package symlinks to. - name: Publish Linux Desktop run: >- dotnet publish SalmonEgg/SalmonEgg/SalmonEgg.csproj --configuration ${{ env.CONFIGURATION }} --framework net10.0-desktop --runtime linux-x64 - --self-contained false + --self-contained true + -p:SalmonEggBundledCliExecutable=${{ steps.bundled-cli.outputs.executable-path }} --output publish/linux-desktop + - name: Build Debian package + id: build-deb + run: >- + scripts/release/build-desktop-deb.sh + --publish-dir publish/linux-desktop + --architecture amd64 + + # The one packaging chain in this repository that can be verified end to end by a runner: install the + # package, prove the app and the command are both usable, then purge and prove they are gone. Every + # other installer's PATH registration can only be asserted against the package's own tables. + - name: Smoke the Debian package install and PATH registration + run: scripts/gates/run-desktop-linux-package-smoke.sh "${{ steps.build-deb.outputs.deb-path }}" + macos-desktop: name: macOS Desktop # Pinned rather than macos-latest: GitHub rolls that alias to a new major image on its own schedule, @@ -160,6 +186,22 @@ jobs: -f net10.0 ` -v minimal + # Installing SalmonEgg installs the salmon-egg command, so the package carries the CLI binary and + # Package.appxmanifest registers it as an app execution alias. Published on this runner rather than + # taken from a cross-job artifact: the alias points at a path inside the package, and a stale + # binary passed between jobs would satisfy the packaging step while shipping a different command + # than this commit builds. + - name: Publish bundled CLI + id: bundled-cli + shell: bash + run: scripts/release/publish-cli-binary.sh --rid win-x64 --configuration ${{ env.CONFIGURATION }} + + # A publish that produced a file is not evidence the file starts. This is the binary users will + # invoke, so exercise it before it is sealed into a package where nothing can run it again. + - name: Smoke the bundled CLI executable + shell: bash + run: scripts/gates/run-cli-release-artifact-smoke.sh "${{ steps.bundled-cli.outputs.executable-path }}" + - name: Publish unsigned Windows MSIX shell: pwsh run: | @@ -173,6 +215,7 @@ jobs: /p:IsolatedMsixBuild=true ` /p:BuildProjectReferences=false ` /p:DisableCustomWinSdkXamlReferences=true ` + /p:SalmonEggBundledCliExecutable="${{ steps.bundled-cli.outputs.executable-path-native }}" ` /p:AppxPackageSigningEnabled=false ` /p:Restore=false ` /v:minimal diff --git a/.github/workflows/release-packaging.yml b/.github/workflows/release-packaging.yml index 11069604d..1ce7269b1 100644 --- a/.github/workflows/release-packaging.yml +++ b/.github/workflows/release-packaging.yml @@ -86,6 +86,21 @@ jobs: cd SalmonEgg/SalmonEgg dotnet publish -f net10.0-desktop -c ${{ env.CONFIGURATION }} -o ../../publish/desktop-windows + # Installing SalmonEgg installs the salmon-egg command, so the MSI carries the CLI and registers the + # directory it lands in on the user's PATH. Published separately from the app rather than through the + # app's publish output, because the WiX authoring below has to name the directory the PATH row points + # at, and heat generates unstable identifiers for anything it harvests. + - name: Publish bundled CLI + id: bundled-cli + shell: bash + run: scripts/release/publish-cli-binary.sh --rid win-x64 --configuration ${{ env.CONFIGURATION }} + + # A publish that produced a file is not evidence the file starts. This is the binary users will + # invoke, so exercise it before it is sealed into an installer. + - name: Smoke the bundled CLI executable + shell: bash + run: scripts/gates/run-cli-release-artifact-smoke.sh "${{ steps.bundled-cli.outputs.executable-path }}" + - name: Install WiX Toolset shell: pwsh run: | @@ -93,6 +108,8 @@ jobs: - name: Build Windows Skia MSI shell: pwsh + env: + BUNDLED_CLI: ${{ steps.bundled-cli.outputs.executable-path-native }} run: | $desktopDir = Join-Path $env:GITHUB_WORKSPACE "publish\desktop-windows" if (-not (Test-Path $desktopDir)) { @@ -112,6 +129,22 @@ jobs: } $harvestPath = Join-Path $installerDir "Harvest.wxs" + # The CLI is authored explicitly below rather than harvested, because the PATH row has to name the + # directory it lands in and heat generates directory identifiers that change with the tree. So the + # app's publish output must not contain it too: two components declaring the same file in the same + # directory is a duplicate light refuses, and finding that out from an ICE error is worse than + # finding it out here. + $harvestedCli = Join-Path $desktopDir "cli" + if (Test-Path $harvestedCli) { + throw ("The desktop publish output contains '$harvestedCli'. The bundled CLI is authored " + + "into CLIFOLDER by this step; it must not also reach the heat harvest.") + } + + $bundledCli = $env:BUNDLED_CLI + if ([string]::IsNullOrWhiteSpace($bundledCli) -or -not (Test-Path -LiteralPath $bundledCli)) { + throw "The bundled CLI executable was not found: '$bundledCli'." + } + heat dir $desktopDir ` -cg SalmonEggDesktopFiles ` -dr INSTALLFOLDER ` @@ -132,6 +165,7 @@ jobs: ' ' ' ' ' ' + ' ' ' ' ' ' ' ' @@ -140,9 +174,28 @@ jobs: ' ' ' ' ' ' - ' ' + ' ' + ' ' + ' ' + ' ' ' ' ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' ' ' '' ) @@ -154,6 +207,7 @@ jobs: candle ` -ext WixUIExtension ` -dDesktopPublishDir="$desktopDir" ` + -dBundledCliPath="$bundledCli" ` -out (Join-Path $wixObjDir "") ` $productPath ` $harvestPath @@ -174,18 +228,18 @@ jobs: name: desktop-windows-build path: publish/desktop-windows - # The CLI MSI verifies its own Environment table (scripts/release/build-cli-msi.ps1); this MSI had - # no equivalent, so a heat harvest that picked up nothing would still emit a valid, installable, - # and empty package. Read the built package's own tables rather than trusting the authoring above. + # A heat harvest that picked up nothing still emits a valid, installable, empty package, and a PATH + # row can name a directory the package never creates. Read the built package's own tables rather + # than trusting the authoring above. # # The rule itself lives in scripts/release/DesktopMsiContract.ps1 so that # scripts/gates/run-desktop-msi-contract-gate.ps1 can rehearse it on every push. It used to be # inline here, and being unrehearsable is how it shipped with `SELECT COUNT(*)` -- SQL Windows # Installer cannot parse -- and took down the v1.3.0 release build from inside OpenView. # - # Native COM calls, matching build-cli-msi.ps1 after 2690a1eb: the GetType().InvokeMember(...) form - # was tried and replaced there, and GitHubWorkflowContractTests pins the native shape. Using the - # rejected form here would reintroduce what that commit removed. + # Native COM calls: the GetType().InvokeMember(...) form was tried and replaced in 2690a1eb, and + # GitHubWorkflowContractTests pins the native shape. Using the rejected form here would reintroduce + # what that commit removed. - name: Verify Windows Skia MSI contract shell: pwsh run: | @@ -254,6 +308,22 @@ jobs: -f net10.0 ` -v minimal + # Installing SalmonEgg installs the salmon-egg command, so the package carries the CLI binary and + # Package.appxmanifest registers it as an app execution alias. Published on this runner rather than + # taken from a cross-job artifact: the alias points at a path inside the package, and a stale binary + # passed between jobs would satisfy the packaging step while shipping a different command than this + # tag builds. + - name: Publish bundled CLI + id: bundled-cli + shell: bash + run: scripts/release/publish-cli-binary.sh --rid win-x64 --configuration ${{ env.CONFIGURATION }} + + # A publish that produced a file is not evidence the file starts. This is the binary users will + # invoke, so exercise it before it is sealed into a package where nothing can run it again. + - name: Smoke the bundled CLI executable + shell: bash + run: scripts/gates/run-cli-release-artifact-smoke.sh "${{ steps.bundled-cli.outputs.executable-path }}" + - name: Prepare MSIX signing certificate shell: pwsh env: @@ -301,6 +371,7 @@ jobs: /p:IsolatedMsixBuild=true ` /p:BuildProjectReferences=false ` /p:DisableCustomWinSdkXamlReferences=true ` + /p:SalmonEggBundledCliExecutable="${{ steps.bundled-cli.outputs.executable-path-native }}" ` /p:AppxPackageSigningEnabled=true ` /p:PackageCertificateThumbprint="$env:MSIX_CERT_THUMBPRINT" ` /p:Restore=false ` @@ -337,6 +408,9 @@ jobs: MACOS_RID: osx-arm64 MACOS_APP_CODESIGN_KEY: ${{ secrets.MACOS_APP_CODESIGN_KEY }} MACOS_DMG_CODESIGN_KEY: ${{ secrets.MACOS_DMG_CODESIGN_KEY }} + # A Developer ID Installer identity, which is a different certificate from the app and disk-image + # ones. Absent it, the .pkg is still built and installable after a Gatekeeper override. + MACOS_PKG_CODESIGN_KEY: ${{ secrets.MACOS_PKG_CODESIGN_KEY }} MACOS_NOTARY_PROFILE: ${{ secrets.MACOS_NOTARY_PROFILE }} steps: - name: Checkout @@ -349,8 +423,22 @@ jobs: with: global-json-file: global.json + # Installing SalmonEgg installs the salmon-egg command. Uno builds the .app from the publish output, + # so the command reaches Contents/MacOS/cli/salmon-egg by being part of that publish -- and it has to + # be there before signing, since adding a Mach-O to a signed bundle invalidates the signature. + - name: Publish bundled CLI + id: bundled-cli + shell: bash + run: scripts/release/publish-cli-binary.sh --rid ${{ env.MACOS_RID }} --configuration ${{ env.CONFIGURATION }} + + - name: Smoke the bundled CLI executable + shell: bash + run: scripts/gates/run-cli-release-artifact-smoke.sh "${{ steps.bundled-cli.outputs.executable-path }}" + - name: Publish macOS app bundle shell: pwsh + env: + BUNDLED_CLI: ${{ steps.bundled-cli.outputs.executable-path }} run: | cd SalmonEgg/SalmonEgg $codesignArgs = @() @@ -365,6 +453,7 @@ jobs: -r $env:MACOS_RID ` -p:PackageFormat=app ` -p:RuntimeIdentifiers=$env:MACOS_RID ` + -p:SalmonEggBundledCliExecutable=$env:BUNDLED_CLI ` @codesignArgs - name: Collect macOS app bundle @@ -381,6 +470,8 @@ jobs: - name: Publish macOS disk image if: env.MACOS_APP_CODESIGN_KEY != '' && env.MACOS_DMG_CODESIGN_KEY != '' shell: pwsh + env: + BUNDLED_CLI: ${{ steps.bundled-cli.outputs.executable-path }} run: | cd SalmonEgg/SalmonEgg $notaryArgs = @() @@ -396,6 +487,7 @@ jobs: -p:SelfContained=true ` -p:PackageFormat=dmg ` -p:RuntimeIdentifiers=$env:MACOS_RID ` + -p:SalmonEggBundledCliExecutable=$env:BUNDLED_CLI ` -p:CodesignKey=$env:MACOS_APP_CODESIGN_KEY ` -p:DiskImageSigningKey=$env:MACOS_DMG_CODESIGN_KEY ` @notaryArgs @@ -427,6 +519,37 @@ jobs: fi scripts/gates/run-release-artifact-contract-gate.sh macos-bundle "$APP_PATH" + # The .dmg is dragged, so nothing in it can register a command. This package carries a postinstall + # that symlinks the bundled CLI into /usr/local/bin, which is on the default macOS PATH. Built after + # the bundle contract check, so a bundle missing the command fails as a contract violation rather than + # inside pkgbuild. + - name: Build macOS installer package + id: build-pkg + shell: bash + run: | + set -euo pipefail + APP_PATH="$(find publish/macos-bundle -maxdepth 1 -name "*.app" -print -quit)" + if [ -z "$APP_PATH" ]; then + echo "No .app bundle was collected." >&2 + exit 1 + fi + + signing_args=() + if [ -n "${MACOS_PKG_CODESIGN_KEY:-}" ]; then + signing_args+=(--signing-key "$MACOS_PKG_CODESIGN_KEY") + fi + + scripts/release/build-macos-pkg.sh --app-bundle "$APP_PATH" "${signing_args[@]}" + + - name: Upload macOS installer package artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: desktop-macos-pkg + path: | + artifacts/macos/*.pkg + artifacts/macos/*.sha256 + if-no-files-found: error + - name: Upload macOS bundle artifact uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: @@ -440,30 +563,16 @@ jobs: name: desktop-macos-dmg path: publish/dmg/*.dmg - package-cli: - # One job per officially supported CLI runtime identifier. Each publishes on its own native runner, - # smokes the executable it just produced, and only then packages it — a cross-compiled artifact that - # nothing has executed is not something this repository is willing to release. - name: Package CLI (${{ matrix.rid }}) - runs-on: ${{ matrix.runs-on }} - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - include: - - rid: linux-x64 - runs-on: ubuntu-latest - deb-architecture: amd64 - - rid: win-x64 - runs-on: windows-latest - # Pinned for the same reason as the macOS package job: a rolling image alias silently changes - # the toolchain that produced a released binary. - - rid: osx-arm64 - runs-on: macos-15 + package-linux-desktop: + name: Package Linux Desktop + runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: + # The version identity is derived from git tags by MinVer; a shallow clone sees no tags and + # would stamp 0.0.0 into the package version, which no later release could upgrade over. fetch-depth: 0 - name: Setup .NET @@ -471,64 +580,51 @@ jobs: with: global-json-file: global.json - - name: Build CLI artifact - id: build-cli - shell: bash - run: scripts/release/build-cli-artifacts.sh --rid ${{ matrix.rid }} --configuration ${{ env.CONFIGURATION }} + # Installing SalmonEgg installs the salmon-egg command, so the package carries the CLI and symlinks + # it into /usr/bin. Published first because the app publish embeds it at cli/salmon-egg. + - name: Publish bundled CLI + id: bundled-cli + run: scripts/release/publish-cli-binary.sh --rid linux-x64 --configuration ${{ env.CONFIGURATION }} - - name: Smoke the published CLI executable - shell: bash - run: scripts/gates/run-cli-release-artifact-smoke.sh "${{ steps.build-cli.outputs.executable-path }}" + - name: Smoke the bundled CLI executable + run: scripts/gates/run-cli-release-artifact-smoke.sh "${{ steps.bundled-cli.outputs.executable-path }}" + + # Self-contained: the package installs into /opt and users are not asked to install a .NET runtime. + - name: Publish Linux Desktop + run: >- + dotnet publish SalmonEgg/SalmonEgg/SalmonEgg.csproj + --configuration ${{ env.CONFIGURATION }} + --framework net10.0-desktop + --runtime linux-x64 + --self-contained true + -p:SalmonEggBundledCliExecutable=${{ steps.bundled-cli.outputs.executable-path }} + --output publish/linux-desktop - name: Build Debian package - if: matrix.deb-architecture != '' id: build-deb - shell: bash run: >- - scripts/release/build-cli-deb.sh - --executable "${{ steps.build-cli.outputs.executable-path }}" - --version "${{ steps.build-cli.outputs.display-version }}" - --architecture ${{ matrix.deb-architecture }} + scripts/release/build-desktop-deb.sh + --publish-dir publish/linux-desktop + --architecture amd64 + # Unlike every other installer here, this one can be verified for real: install it, prove the app and + # the command are both usable, purge it, prove they are gone. A package that merely contains two + # executables is not an installed app with a PATH command. - name: Smoke the Debian package install and PATH registration - if: matrix.deb-architecture != '' - shell: bash - run: scripts/gates/run-cli-linux-package-smoke.sh "${{ steps.build-deb.outputs.deb-path }}" - - - name: Install WiX Toolset - if: matrix.rid == 'win-x64' - shell: pwsh - run: choco install wixtoolset -y --no-progress - - - name: Build Windows MSI - if: matrix.rid == 'win-x64' - shell: pwsh - env: - CLI_EXECUTABLE: ${{ steps.build-cli.outputs.executable-path }} - CLI_VERSION: ${{ steps.build-cli.outputs.display-version }} - run: | - $executable = $env:CLI_EXECUTABLE - if ($executable -match '^/([A-Za-z])/(.+)$') { - $executable = "$($matches[1].ToUpperInvariant()):\$($matches[2] -replace '/', '\')" - } + run: scripts/gates/run-desktop-linux-package-smoke.sh "${{ steps.build-deb.outputs.deb-path }}" - ./scripts/release/build-cli-msi.ps1 -Executable $executable -Version $env:CLI_VERSION - - - name: Upload CLI artifacts + - name: Upload Linux desktop package artifact uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: - name: cli-${{ matrix.rid }} + name: desktop-linux-deb path: | - artifacts/cli/*.tar.gz - artifacts/cli/*.zip - artifacts/cli/*.deb - artifacts/cli/*.msi - artifacts/cli/*.sha256 + artifacts/desktop/*.deb + artifacts/desktop/*.sha256 if-no-files-found: error publish-release-assets: name: Publish Release Assets - needs: [package-wasm, package-desktop, package-windows-msix, package-macos, package-cli] + needs: [package-wasm, package-desktop, package-windows-msix, package-macos, package-linux-desktop] runs-on: ubuntu-latest timeout-minutes: 20 # Only a tag build publishes. workflow_dispatch remains available for rehearsing the packaging jobs @@ -538,26 +634,12 @@ jobs: contents: write env: RELEASE_TAG: ${{ github.ref_name }} + # No checkout and no SDK: every step here works on downloaded artifacts and the gh CLI. Both used to be + # required by the CLI asset step, which evaluated MinVer to reconstruct archive names, and that step is + # gone now that the packaging jobs name and upload their own artifacts. Keeping them would also keep the + # ordering hazard that came with them -- `Setup .NET` before the checkout resolved global.json against an + # empty workspace and killed a tag build after all seven packaging jobs had already succeeded. steps: - # Checkout leads this job because the step below reads global.json out of the workspace. When - # `Setup .NET` came first the SDK pin resolved against an empty workspace and every tag build died - # on "The specified global.json file 'global.json' does not exist" -- after all seven packaging jobs - # had already succeeded, so the release ended up tagged with zero assets. - # - # The tree lands in `repo/` rather than the workspace root so the artifact downloads below own the - # root without colliding with tracked paths, and `global-json-file` is pointed at it. Full history - # is required: the CLI asset step reads the display version from MinVer, which needs the tags. - - name: Checkout for release tooling - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - path: repo - fetch-depth: 0 - - - name: Setup .NET - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - global-json-file: repo/global.json - - name: Download Windows MSIX artifact if: needs.package-windows-msix.result == 'success' uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 @@ -586,6 +668,13 @@ jobs: name: desktop-macos-bundle path: macos-bundle + - name: Download macOS installer package artifact + if: needs.package-macos.result == 'success' + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: desktop-macos-pkg + path: macos-pkg + - name: Download macOS .dmg artifact if: needs.package-macos.outputs.dmg-produced == 'true' uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 @@ -593,12 +682,12 @@ jobs: name: desktop-macos-dmg path: macos-dmg - - name: Download CLI artifacts + - name: Download Linux desktop package artifact + if: needs.package-linux-desktop.result == 'success' uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: - pattern: cli-* - merge-multiple: true - path: cli-artifacts + name: desktop-linux-deb + path: linux-deb - name: Ensure GitHub release exists shell: bash @@ -635,6 +724,12 @@ jobs: fi fi + # Copy the macOS installer package and its checksum + if [ -d "macos-pkg" ]; then + find macos-pkg -maxdepth 1 -type f \( -name '*.pkg' -o -name '*.sha256' \) \ + -exec cp {} release-assets/ \; + fi + # Copy DMG when signing is configured if [ -d "macos-dmg" ]; then DMG_PATH=$(find macos-dmg -maxdepth 1 -name "*.dmg" -print -quit) @@ -656,63 +751,17 @@ jobs: fi fi + # Copy the Linux desktop package and its checksum when available + if [ -d "linux-deb" ]; then + find linux-deb -maxdepth 1 -type f \( -name '*.deb' -o -name '*.sha256' \) \ + -exec cp {} release-assets/ \; + fi + # Package WebAssembly output as zip if [ -d "wasm-build" ]; then zip -r "release-assets/SalmonEgg-wasm.zip" "wasm-build" fi - - name: Prepare CLI release assets - shell: bash - run: | - set -euo pipefail - mkdir -p release-assets - - # Every supported runtime identifier must be present. A release that silently ships two of the - # three advertised platforms is worse than a failed release: the support matrix in the docs would - # be wrong for whoever downloads it. - supported_rids="$(cd repo && dotnet msbuild src/SalmonEgg.Cli/SalmonEgg.Cli.csproj \ - -getProperty:SalmonEggCliSupportedRuntimeIdentifiers -nologo | tr -d '\r' | tail -n 1)" - display_version="$(cd repo && dotnet msbuild src/SalmonEgg.Cli/SalmonEgg.Cli.csproj \ - -restore -t:MinVer -getProperty:SalmonEggDisplayVersion -nologo | tr -d '\r' | tail -n 1)" - - missing="" - IFS=';' read -ra rids <<< "$supported_rids" - for rid in "${rids[@]}"; do - [ -n "$rid" ] || continue - case "$rid" in - win-*) archive="salmon-egg-cli-$display_version-$rid.zip" ;; - *) archive="salmon-egg-cli-$display_version-$rid.tar.gz" ;; - esac - if [ ! -f "cli-artifacts/$archive" ]; then - missing="$missing $archive" - continue - fi - if [ ! -f "cli-artifacts/$archive.sha256" ]; then - missing="$missing $archive.sha256" - fi - done - - if [ -n "$missing" ]; then - echo "Missing CLI release assets for the supported matrix ($supported_rids):$missing" >&2 - exit 1 - fi - - cp cli-artifacts/*.tar.gz cli-artifacts/*.zip cli-artifacts/*.sha256 release-assets/ - - # Installer packages are copied only when present. An unmatched glob would otherwise pass the - # literal pattern to cp and fail the whole job under `set -e`. - find cli-artifacts -maxdepth 1 -type f \( -name '*.deb' -o -name '*.msi' \) \ - -exec cp {} release-assets/ \; - - # The formula's checksums are read from the sidecars produced by this run, so it can only ever - # describe archives that were actually built and uploaded. - repo/scripts/release/build-cli-homebrew-formula.sh \ - --version "$display_version" \ - --release-tag "${RELEASE_TAG}" \ - --repository "${GITHUB_REPOSITORY}" \ - --artifact-dir cli-artifacts \ - --output release-assets/salmon-egg-cli.rb - - name: Upload release assets shell: bash env: diff --git a/.github/workflows/wasm-smoke-gates.yml b/.github/workflows/wasm-smoke-gates.yml index bed7ef166..6b6a90801 100644 --- a/.github/workflows/wasm-smoke-gates.yml +++ b/.github/workflows/wasm-smoke-gates.yml @@ -13,7 +13,7 @@ on: - "LICENSE" - ".github/FUNDING.yml" pull_request: - branches: [develop] + branches: [develop, main] paths-ignore: # Documentation-only edits cannot change build or test outcomes, and running the heavy platform # matrix for them costs runner minutes while adding no signal. Listed explicitly rather than as diff --git a/.tools/run-winui3-msix.ps1 b/.tools/run-winui3-msix.ps1 index 4518fd0fd..d2da13d8b 100644 --- a/.tools/run-winui3-msix.ps1 +++ b/.tools/run-winui3-msix.ps1 @@ -91,6 +91,28 @@ function Get-MSBuildPath { return $msbuild } +# The bundled CLI is published by scripts/release/publish-cli-binary.sh, the same script the release +# workflow runs, so this local package embeds the same binary users get. That means this script needs a +# POSIX shell; Git for Windows provides one, and the repository's other gates already assume it. +function Get-BashPath { + $onPath = Get-Command bash -ErrorAction SilentlyContinue + if ($onPath) { + return $onPath.Source + } + + $candidates = @( + (Join-Path $env:ProgramFiles 'Git\bin\bash.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Git\bin\bash.exe') + ) + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path -LiteralPath $candidate)) { + return $candidate + } + } + + throw "bash.exe not found. The MSIX package embeds the salmon-egg CLI, published by scripts/release/publish-cli-binary.sh; install Git for Windows so this script can run it." +} + function Get-CertificateFromStore { param( [Parameter(Mandatory = $true)] [string] $Subject, @@ -751,6 +773,27 @@ foreach ($referenceProject in $referenceProjects) { -DisplayCommand "MSBuild Restore ($referenceProjectName, binlog: $referenceRestoreBinLogPath)" } +# Package.appxmanifest registers cli\salmon-egg.exe as an app execution alias, so packaging fails without +# this payload. Published before the app so a failure here is reported as a CLI publish failure rather +# than as a missing package file several minutes into MakeAppx. +$bundledCliLogPath = Join-Path $msixLogDir "$logStamp-bundled-cli.log" +$publishCliScript = Join-Path $repoRoot 'scripts\release\publish-cli-binary.sh' +Invoke-LoggedProcess ` + -FilePath (Get-BashPath) ` + -Arguments @( + $publishCliScript, + '--rid', 'win-x64', + '--configuration', $Configuration + ) ` + -LogPath $bundledCliLogPath ` + -StepName 'Publishing the bundled salmon-egg CLI' ` + -DisplayCommand "bash publish-cli-binary.sh --rid win-x64 --configuration $Configuration" + +$bundledCli = Join-Path $repoRoot 'artifacts\cli-bin\win-x64\salmon-egg.exe' +if (-not (Test-Path -LiteralPath $bundledCli)) { + throw "The bundled CLI was not produced at '$bundledCli'. See $bundledCliLogPath." +} + Invoke-LoggedProcess ` -FilePath $msbuild ` -Arguments @( @@ -759,6 +802,7 @@ Invoke-LoggedProcess ` "/p:Configuration=$Configuration", "/p:TargetFramework=$tfm", "/p:PublishProfile=$publishProfile", + "/p:SalmonEggBundledCliExecutable=$bundledCli", '/p:EnableWinUIBuild=true', '/p:IsolatedMsixBuild=true', '/p:BuildProjectReferences=true', diff --git a/AGENTS.md b/AGENTS.md index fbebdde5a..88570f030 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,3 +112,4 @@ - 兜底值的单一 owner 与失败归因:当同一个业务值(工作目录、根路径、身份、超时)既被**预备阶段**(草稿 / 预览 / 校验)消费、又被**执行阶段**(提交 / 启动 / 发送)消费时,它必须由同一个 resolver 从同一个请求源解析。触发条件:两阶段各自算一遍,且其中一个带兜底(如 stdio 缺目录时退到用户主目录)而另一个不带。原生期望行为是兜底规则连同其**分类依据**(此处为传输类型)一起收进那个 resolver,调用方只传原始请求值与分类依据。**此类缺陷最隐蔽的形态是「预备阶段成功、执行阶段失败」**:草稿能建、按钮亮着、选择器认为选择合法,用户点下去才失败,且失败文案是「请稍后重试」——而重试永远不会让缺失的值长出来。因此不可重试的失败必须呈现 resolver 自己的原因(「请先选择远程目录」而非「请稍后重试」),并且**不得只靠一道守卫**:既有的选择器/命令门禁拦住的是它能判定的子集,resolver 守卫是第二道,二者断言不同契约、不可互相替代。禁止做法:为「不是真实实体」的合法哨兵值(未归类 / 全部 / 默认)返回 null 再让下游当错误;在执行阶段新写一份兜底;把不可重试失败呈现为可重试;留下第二个 resolver 并让其唯一调用点传常量参数(它退化成 Trim 包装,看起来仍是「架构层」)。验证覆盖:哨兵值 + 各传输类型的组合各跑一遍(本地兜底必须成功启动、远程必须拒绝并给出原因);**反向验证必须把缺陷种回去**并确认新增测试见红(仅新测试全绿不构成证据);删除第二个 owner 时以 fail-closed 门禁断言其文件不存在且执行阶段只出现唯一 resolver 调用;以及审计**所有**触达执行阶段的既有测试——它们可能把缺陷行为写成断言(如 `Assert.Null(cwd)`),也可能因为夹具从未配置分类依据而在守卫前 return,使 `await 某个只在下游置位的 TaskCompletionSource` 永久挂死:表现为整套测试**无任何失败输出地超时**,而单跑新测试却是秒级全绿。 - 测试进程级监听器与可释放资源隔离:当测试创建会注册进程级 listener、provider、event source 或其它全局回调的 `IDisposable` 对象时,每个用例必须在自身作用域内确定释放。原生期望行为是用例结束后全局注册恢复到进入前的状态,后续用例的采样、事件与回调只由自身夹具决定;禁止依赖串行执行、GC、终结器、平台速度、重试或固定延时清理泄漏,也禁止把多个 live listener 聚合后的全局标志误当成单个 provider 的裁决。验证覆盖同一测试类完整顺序、多轮进程级重复运行和完整程序集;诊断时用受控的共存 listener 量化污染率,并在交付前删除临时探针。 + - 安装器拥有命令的 PATH 注册:当同一产品既分发 GUI 又分发命令行入口时,命令必须由**每个安装包**用该平台安装器自己的机制注册(MSIX 的 `windows.appExecutionAlias`、MSI 的 `Environment` 表、dpkg 拥有的符号链接、pkg 的 postinstall),且命令二进制必须由单一 publish 脚本产出后被各安装包嵌入。禁止做法:脚本编辑 `.bashrc` / `.zshrc` / 用户 PATH 字符串或调 `setx`(安装、升级、卸载必须同属一个所有者);每条打包链各自构建命令二进制(四份产物都能构建成功,正是它们开始漂移的形态);把注册断言写成只在打 tag 时才跑的内联脚本。验证方式:注册必须对**本次构建产出的安装物**断言(读包内清单、`Environment` / `File` 表、文件列表),断言规则要与读取产物的平台 API 分离,使其正反例能在任意平台的每次 push 上跑;能真装真卸的平台必须有安装→命令解析→卸载→残留检查全链门禁;符号链接必须断言**解析结果**而非链接文本(相对深度错误只在解析时暴露,包的文件列表看不出来);平台把辅助可执行文件放进包内哪个目录若非文档化契约,消费方必须探测候选位置并报告命中项,禁止赌一个位置。唯一例外是**该平台的包格式没有安装钩子**(macOS 拖拽 .app):此时应用自身可以拥有该注册,但必须写与该平台安装器**同一路径、同一形状**(先删再链),必须同时提供移除入口,且该逻辑要拆成可在任意平台用假根跑的脚本/服务 —— 应用与安装器各写一套是同一个双所有者缺陷。 diff --git a/README.en.md b/README.en.md index 1c42f0553..266ccae83 100644 --- a/README.en.md +++ b/README.en.md @@ -79,15 +79,21 @@ The repository includes a cross-platform desktop CLI for server configuration an #### Supported platforms and installation -Released as a self-contained single-file executable, so no .NET runtime is required. Installing the GUI does **not** put `salmon-egg` on PATH; the CLI packages own that. +The CLI ships with the app: **installing SalmonEgg registers the `salmon-egg` command**. There is nothing +separate to install, and no .NET runtime is required (the command is a self-contained single-file build). -| Platform | Install | PATH | -|---|---|---| -| Linux x64 | `sudo dpkg -i salmon-egg-cli__amd64.deb` | dpkg installs `/usr/bin/salmon-egg` and removes it on purge | -| Windows x64 | run `salmon-egg-cli--win-x64.msi` (per-user) | the MSI adds its install folder to your user PATH and removes it on uninstall | -| macOS Apple Silicon | `brew install --formula ./salmon-egg-cli.rb` | Homebrew links the binary into its `bin`, already on PATH | +| Installer | How the command is registered | +|---|---| +| Windows MSIX | the package declares an app execution alias, and Windows materializes it under `%LOCALAPPDATA%\Microsoft\WindowsApps`, a directory already on your user PATH | +| Windows MSI (Skia Desktop) | the MSI's `Environment` table appends the install folder's `cli` directory to your user PATH, and removes it on uninstall | +| Linux `.deb` | dpkg installs a `/usr/bin/salmon-egg` symlink and removes it on purge | +| macOS `.pkg` | the installer links the command into `/usr/local/bin`, which is on the default macOS PATH; remove it with `rm /usr/local/bin/salmon-egg` | +| macOS `.dmg` | the command is inside `SalmonEgg.app`, but a dragged app has no install hook, so link it yourself or use the `.pkg` | -Plain archives (`.tar.gz` / `.zip`) are also published for anyone who prefers to place the binary on PATH themselves. Other runtime identifiers — `win-arm64`, `linux-arm64`, `osx-x64` — are not officially supported: they can be cross-compiled, but nothing verifies them on a real machine. +The app shows this too: **Settings → Command line** resolves PATH live, reports which copy it found and whether its version matches, and on macOS offers to link or unlink the `/usr/local/bin` entry. + + +Other runtime identifiers — `win-arm64`, `linux-arm64`, `osx-x64` — are not officially supported: they can be cross-compiled, but nothing verifies them on a real machine. After installing, the command is available directly: diff --git a/README.md b/README.md index be68e6db4..5686cc297 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,20 @@ build.bat msix ### 配置管理 CLI -仓库包含一个跨平台桌面 CLI,用于管理服务器配置与凭据。发布产物是 self-contained 单文件,用户无需预装 .NET。安装 GUI **不会**注册 `salmon-egg` 命令,全局命令只来自 CLI 安装包。 +仓库包含一个跨平台桌面 CLI,用于管理服务器配置与凭据。它随主程序一起分发:**安装 SalmonEgg 就会注册 `salmon-egg` 命令**,无需单独安装,也不必预装 .NET(产物是 self-contained 单文件)。 -| 平台 | 安装方式 | PATH | -|---|---|---| -| Linux x64 | `sudo dpkg -i salmon-egg-cli_<版本>_amd64.deb` | dpkg 安装到 `/usr/bin/salmon-egg`,卸载时移除 | -| Windows x64 | 运行 `salmon-egg-cli-<版本>-win-x64.msi`(per-user) | MSI 追加安装目录到用户 PATH,卸载时移除 | -| macOS Apple Silicon | `brew install --formula ./salmon-egg-cli.rb` | Homebrew 链接到其 `bin`,已在 PATH 上 | +| 安装包 | 命令注册方式 | +|---|---| +| Windows MSIX | 包内声明 app execution alias,Windows 在 `%LOCALAPPDATA%\Microsoft\WindowsApps` 生成入口(该目录默认在用户 PATH 上) | +| Windows MSI(Skia Desktop) | MSI 的 `Environment` 表把安装目录下的 `cli` 追加到用户 PATH,卸载时移除 | +| Linux `.deb` | dpkg 安装 `/usr/bin/salmon-egg` 符号链接,purge 时移除 | +| macOS `.pkg` | 安装脚本把命令链接到 `/usr/local/bin`(macOS 默认 PATH),删除时手工 `rm /usr/local/bin/salmon-egg` | +| macOS `.dmg` | 命令在 `SalmonEgg.app` 内,但拖拽安装没有安装钩子,需要自行链接或改用 `.pkg` | -也提供 `.tar.gz` / `.zip` 压缩包供自行放入 PATH。`win-arm64`、`linux-arm64`、`osx-x64` 等不属于正式支持范围:可交叉编译,但没有真实机器验证。 +应用内可在 设置 → **命令行工具** 查看命令是否可用、PATH 命中哪一份、版本是否与当前应用一致;macOS 还可在该页一键链接或移除 `/usr/local/bin` 里的入口。 + + +`win-arm64`、`linux-arm64`、`osx-x64` 等不属于正式支持范围:可交叉编译,但没有真实机器验证。 ```bash salmon-egg --help diff --git a/SalmonEgg/SalmonEgg/DependencyInjection.cs b/SalmonEgg/SalmonEgg/DependencyInjection.cs index 03dfda5ec..f1f7c502d 100644 --- a/SalmonEgg/SalmonEgg/DependencyInjection.cs +++ b/SalmonEgg/SalmonEgg/DependencyInjection.cs @@ -51,6 +51,7 @@ #if !__WASM__ && !__ANDROID__ && !__IOS__ using SalmonEgg.Infrastructure.Desktop.AcpSetup; using SalmonEgg.Infrastructure.Desktop.DependencyInjection; +using SalmonEgg.Infrastructure.Desktop.Services; #endif #if __WASM__ using SalmonEgg.Platforms.WebAssembly; @@ -927,6 +928,21 @@ private static void RegisterInfrastructureServices(IServiceCollection services) sp.GetRequiredService>(), sp.GetRequiredService())); services.AddSingleton(); + + // Whether the salmon-egg command is reachable is a machine fact, so the inspector needs a PATH and a + // process host. Platforms with neither get an implementation that says so, rather than the view model + // learning which platform it is on. Linking is macOS-only for a different reason: everywhere else the + // installer owns the PATH entry and a second owner would fight it. +#if !__WASM__ && !__ANDROID__ && !__IOS__ + services.AddSingleton(sp => + new PathCliCommandRegistrationInspector(sp.GetRequiredService())); + services.AddSingleton(sp => + new MacOsCliCommandLinkService(sp.GetRequiredService())); +#else + services.AddSingleton(); + services.AddSingleton(); +#endif + services.AddSingleton(); services.AddSingleton(sp => new McpSettingsViewModel( sp.GetRequiredService(), diff --git a/SalmonEgg/SalmonEgg/Package.appxmanifest b/SalmonEgg/SalmonEgg/Package.appxmanifest index a895337ed..76e025809 100644 --- a/SalmonEgg/SalmonEgg/Package.appxmanifest +++ b/SalmonEgg/SalmonEgg/Package.appxmanifest @@ -2,11 +2,12 @@ + IgnorableNamespaces="uap uap3 uap5 rescap com desktop"> + + + + + + + Assets\Icons\Windows\iconLogo.png diff --git a/SalmonEgg/SalmonEgg/Presentation/Converters/CliCommandStatusSeverityConverter.cs b/SalmonEgg/SalmonEgg/Presentation/Converters/CliCommandStatusSeverityConverter.cs new file mode 100644 index 000000000..3af5232aa --- /dev/null +++ b/SalmonEgg/SalmonEgg/Presentation/Converters/CliCommandStatusSeverityConverter.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using SalmonEgg.Presentation.Models.Cli; + +namespace SalmonEgg.Presentation.Converters; + +/// +/// Maps the presentation-layer severity onto the native InfoBar's own. +/// +/// +/// Presentation.Core cannot reference WinUI types, so it reports severity as its own enum and the mapping +/// lands here. One-way only: an InfoBar's severity is never a source of user intent. +/// +public sealed partial class CliCommandStatusSeverityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) => + value is CliCommandStatusSeverity severity + ? severity switch + { + CliCommandStatusSeverity.Success => InfoBarSeverity.Success, + CliCommandStatusSeverity.Warning => InfoBarSeverity.Warning, + CliCommandStatusSeverity.Error => InfoBarSeverity.Error, + _ => InfoBarSeverity.Informational, + } + : InfoBarSeverity.Informational; + + public object ConvertBack(object value, Type targetType, object parameter, string language) => + throw new NotSupportedException("InfoBar severity is presentation output, never an input."); +} diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/Settings/CommandLineSettingsPage.xaml b/SalmonEgg/SalmonEgg/Presentation/Views/Settings/CommandLineSettingsPage.xaml new file mode 100644 index 000000000..5fc8f11f5 --- /dev/null +++ b/SalmonEgg/SalmonEgg/Presentation/Views/Settings/CommandLineSettingsPage.xaml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +