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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/Settings/CommandLineSettingsPage.xaml.cs b/SalmonEgg/SalmonEgg/Presentation/Views/Settings/CommandLineSettingsPage.xaml.cs
new file mode 100644
index 000000000..70215e2f1
--- /dev/null
+++ b/SalmonEgg/SalmonEgg/Presentation/Views/Settings/CommandLineSettingsPage.xaml.cs
@@ -0,0 +1,35 @@
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Navigation;
+using SalmonEgg.Presentation.Models.Settings;
+using SalmonEgg.Presentation.ViewModels.Settings;
+
+namespace SalmonEgg.Presentation.Views.Settings;
+
+public sealed partial class CommandLineSettingsPage : SalmonEgg.Presentation.Views.SettingsPageBase
+{
+ public CommandLineSettingsViewModel ViewModel { get; }
+
+ public CommandLineSettingsPage()
+ {
+ ViewModel = App.ServiceProvider.GetRequiredService();
+ InitializeComponent();
+ SetSettingsBreadcrumbForSection(SettingsSectionCatalog.CommandLineKey);
+ }
+
+ ///
+ /// Re-inspects on every arrival rather than once per app run.
+ ///
+ ///
+ /// PATH is a machine fact an installer or another install can change while the app is open, so a value
+ /// read at startup would go stale silently. The command owns its own failure handling, so the returned
+ /// task is deliberately not awaited here: navigation must not block on starting a child process.
+ ///
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ base.OnNavigatedTo(e);
+ _ = ViewModel.RefreshCommand.ExecuteAsync(null);
+ }
+
+ protected override Control? GetSectionEntryFocusTarget()
+ => CommandLineRefreshButton;
+}
diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/SettingsShellPage.xaml.cs b/SalmonEgg/SalmonEgg/Presentation/Views/SettingsShellPage.xaml.cs
index c8d8d84ff..28d04c05b 100644
--- a/SalmonEgg/SalmonEgg/Presentation/Views/SettingsShellPage.xaml.cs
+++ b/SalmonEgg/SalmonEgg/Presentation/Views/SettingsShellPage.xaml.cs
@@ -113,6 +113,7 @@ private void OnSettingsFrameNavigated(object sender, NavigationEventArgs e)
SettingsSectionCatalog.AgentAcpKey => typeof(Settings.AcpConnectionSettingsPage),
SettingsSectionCatalog.McpKey => typeof(Settings.McpSettingsPage),
SettingsSectionCatalog.DataStorageKey => typeof(Settings.DataStorageSettingsPage),
+ SettingsSectionCatalog.CommandLineKey => typeof(Settings.CommandLineSettingsPage),
SettingsSectionCatalog.ShortcutsKey => typeof(Settings.ShortcutsSettingsPage),
SettingsSectionCatalog.DiagnosticsKey => typeof(Settings.DiagnosticsSettingsPage),
SettingsSectionCatalog.AboutKey => typeof(Settings.AboutPage),
diff --git a/SalmonEgg/SalmonEgg/SalmonEgg.csproj b/SalmonEgg/SalmonEgg/SalmonEgg.csproj
index dfa1d9bb6..8b292774b 100644
--- a/SalmonEgg/SalmonEgg/SalmonEgg.csproj
+++ b/SalmonEgg/SalmonEgg/SalmonEgg.csproj
@@ -304,6 +304,36 @@
+
+
+
+
+
$(BaseIntermediateOutputPath)generated\windows\$(SalmonEggGeneratedManifestDirectory)Package.appxmanifest
@@ -381,6 +411,38 @@
+
+
+
+ $([System.IO.Path]::GetFileName('$(SalmonEggBundledCliExecutable)'))
+
+
+
+
+
+
+
+
+
diff --git a/SalmonEgg/SalmonEgg/Strings/en-US/Resources.resw b/SalmonEgg/SalmonEgg/Strings/en-US/Resources.resw
index 70b4a7375..c596194d2 100644
--- a/SalmonEgg/SalmonEgg/Strings/en-US/Resources.resw
+++ b/SalmonEgg/SalmonEgg/Strings/en-US/Resources.resw
@@ -1750,4 +1750,52 @@
Submit
+
+ Command line tools
+
+
+ Check and manage the salmon-egg command that ships with this app.
+
+
+ Status
+
+
+ Resolution details
+
+
+ Resolved on PATH
+
+
+ Link target
+
+
+ Version it reports
+
+
+ This app's version
+
+
+ Re-check
+
+
+ Manage
+
+
+ Link into /usr/local/bin
+
+
+ Remove link
+
+
+ A dragged app has no install step, so the app can create the link itself. macOS will ask for your administrator password.
+
+
+ Managed by the installer
+
+
+ On this platform the installer registers and removes the command, so the app leaves it alone. Reinstall to restore it.
+
+
+ Try it
+
diff --git a/SalmonEgg/SalmonEgg/Strings/en/Resources.resw b/SalmonEgg/SalmonEgg/Strings/en/Resources.resw
index bc34eea09..85a500ddb 100644
--- a/SalmonEgg/SalmonEgg/Strings/en/Resources.resw
+++ b/SalmonEgg/SalmonEgg/Strings/en/Resources.resw
@@ -1855,4 +1855,52 @@
Submit
+
+ Command line tools
+
+
+ Check and manage the salmon-egg command that ships with this app.
+
+
+ Status
+
+
+ Resolution details
+
+
+ Resolved on PATH
+
+
+ Link target
+
+
+ Version it reports
+
+
+ This app's version
+
+
+ Re-check
+
+
+ Manage
+
+
+ Link into /usr/local/bin
+
+
+ Remove link
+
+
+ A dragged app has no install step, so the app can create the link itself. macOS will ask for your administrator password.
+
+
+ Managed by the installer
+
+
+ On this platform the installer registers and removes the command, so the app leaves it alone. Reinstall to restore it.
+
+
+ Try it
+
diff --git a/SalmonEgg/SalmonEgg/Strings/zh-Hans/Resources.resw b/SalmonEgg/SalmonEgg/Strings/zh-Hans/Resources.resw
index 94b42dc9e..aa7826aec 100644
--- a/SalmonEgg/SalmonEgg/Strings/zh-Hans/Resources.resw
+++ b/SalmonEgg/SalmonEgg/Strings/zh-Hans/Resources.resw
@@ -1750,4 +1750,52 @@
提交
+
+ 命令行工具
+
+
+ 查看并管理随应用一起安装的 salmon-egg 命令。
+
+
+ 状态
+
+
+ 解析详情
+
+
+ PATH 命中的路径
+
+
+ 链接指向
+
+
+ 该命令报告的版本
+
+
+ 当前应用版本
+
+
+ 重新检查
+
+
+ 管理
+
+
+ 链接到 /usr/local/bin
+
+
+ 移除链接
+
+
+ 拖拽安装没有安装步骤,因此由应用来创建这个链接。macOS 会要求输入管理员密码。
+
+
+ 由安装包管理
+
+
+ 这个平台的命令由安装包注册和移除,应用不会改动它。重新安装即可恢复。
+
+
+ 试一下
+
diff --git a/docs/release-guide.md b/docs/release-guide.md
index 1d5af411f..e7cecf47e 100644
--- a/docs/release-guide.md
+++ b/docs/release-guide.md
@@ -10,8 +10,9 @@
4. [Android 发布](#android-发布)
5. [iOS 发布](#ios-发布)
6. [macOS 发布](#macos-发布)
-7. [CLI 发布](#cli-发布)
-8. [持续集成发布](#持续集成发布)
+7. [Linux 发布](#linux-发布)
+8. [命令行工具(随主程序分发)](#命令行工具随主程序分发)
+9. [持续集成发布](#持续集成发布)
---
@@ -245,89 +246,142 @@ dotnet publish -f net10.0-desktop -c Release \
-o ../../publish/macos-arm64
```
-### 创建 DMG 安装包
+### 打包 `.app` / `.dmg` / `.pkg`
-当前工程的 macOS desktop 产物来自 Uno `net10.0-desktop`,publish 输出为可执行文件目录;如需 `.app`/DMG,需要先在 macOS 上完成 `.app` bundle、签名和公证流程,再打包 DMG。
+`.app` bundle 与 `.dmg` 由 Uno 的打包目标产出(`PackageFormat=app` / `dmg`),签名与公证参数来自 workflow 的 secrets。**bundle 必须携带 CLI**,因此 publish 时要传 `-p:SalmonEggBundledCliExecutable=`:
```bash
-# 使用 create-dmg(需要安装)
-cd publish/macos-x64
-create-dmg \
- --volname "SalmonEgg" \
- --window-pos 200,120 \
- --window-size 600,400 \
- --icon-size 100 \
- --app-drop-link 400,200 \
- "SalmonEgg.dmg" \
- "SalmonEgg.app"
-```
+scripts/release/publish-cli-binary.sh --rid osx-arm64
----
+cd SalmonEgg/SalmonEgg
+dotnet publish -f net10.0-desktop -c Release -r osx-arm64 \
+ -p:PackageFormat=app \
+ -p:RuntimeIdentifiers=osx-arm64 \
+ -p:SalmonEggBundledCliExecutable=../../artifacts/cli-bin/osx-arm64/salmon-egg
+```
-## CLI 发布
+`.pkg` 不走 Uno:它的 `PackageAppBundle` 任务不接受 scripts 参数,而 `.pkg` 存在的唯一理由就是那个 postinstall——把 bundle 内的命令链接进 `/usr/local/bin`。所以由仓库脚本直接调 `pkgbuild`:
-### 正式支持矩阵
+```bash
+scripts/release/build-macos-pkg.sh \
+ --app-bundle publish/macos-bundle/SalmonEgg.app \
+ --signing-key "Developer ID Installer: ..." # 可选,省略则产出未签名包
+```
-CLI 的支持矩阵事实源是 `src/SalmonEgg.Cli/SalmonEgg.Cli.csproj` 中的 `SalmonEggCliSupportedRuntimeIdentifiers`;发布脚本与工作流都从该属性读取,不另行维护列表。
+`--signing-key` 需要 **Developer ID Installer** 证书(与 app、dmg 的签名证书不是同一张);workflow 通过 `MACOS_PKG_CODESIGN_KEY` 提供,缺失时仍产出未签名包。
-| Runtime identifier | 平台 | 安装包 | PATH 注册方式 |
-|---|---|---|---|
-| `linux-x64` | Linux x64 | `tar.gz` + `.deb` | `.deb` 安装到 `/usr/bin/salmon-egg`,由 dpkg 拥有 |
-| `win-x64` | Windows x64 | `zip` + per-user `.msi` | MSI 的 `Environment` 元素追加安装目录到**用户** PATH,卸载时自动移除 |
-| `osx-arm64` | macOS Apple Silicon | `tar.gz` + Homebrew formula | `brew install` 链接到 Homebrew `bin`,已在 PATH 上 |
+命令在 bundle 内的位置由 Uno 决定:实测 v1.4.2 的已发布 bundle,`GenerateAppBundle` 把 apphost、`deps.json`/`runtimeconfig.json` 与全部 `.dylib` 放进 `Contents/MacOS`(19 个文件),托管程序集、卫星资源与 `Assets/` 子目录放进 `Contents/Resources`(589 个文件,保留相对路径)。`cli/` 子目录里一个无扩展名的 Mach-O 不精确匹配任何一侧,且该切分不是文档化契约,因此 postinstall、`build-macos-pkg.sh` 与产物契约门禁**都同时探测两个位置并报告命中的那个**——第一次 tag 构建的日志即可定论。
-矩阵之外的 RID(`win-arm64`、`linux-arm64`、`osx-x64` 等)不进入正式支持:能交叉编译不等于有真实运行验证。`--allow-unsupported-rid` 仅供本机验证,产物不得发布。
+---
-产物为 self-contained 单文件,用户无需预装 .NET;`scripts/gates/run-cli-release-artifact-smoke.sh` 会在移除可用 .NET 的环境下运行刚构建出的可执行文件来验证这一点。
+## Linux 发布
-### 构建 CLI 产物
+Linux 只有一种安装物:同时包含 GUI 与 `salmon-egg` 命令的 `.deb`。
```bash
-# 单个 RID:发布、打包、生成 SHA-256
-scripts/release/build-cli-artifacts.sh --rid linux-x64
+scripts/release/publish-cli-binary.sh --rid linux-x64
-# Linux 安装包
-scripts/release/build-cli-deb.sh \
- --executable artifacts/cli-publish/linux-x64/salmon-egg \
- --architecture amd64
+dotnet publish SalmonEgg/SalmonEgg/SalmonEgg.csproj \
+ -c Release -f net10.0-desktop -r linux-x64 --self-contained true \
+ -p:SalmonEggBundledCliExecutable=$PWD/artifacts/cli-bin/linux-x64/salmon-egg \
+ -o publish/linux-desktop
-# Windows 安装包(需要 WiX Toolset)
-./scripts/release/build-cli-msi.ps1 -Executable artifacts/cli-publish/win-x64/salmon-egg.exe
+scripts/release/build-desktop-deb.sh --publish-dir publish/linux-desktop --architecture amd64
+
+# 安装 / PATH / .desktop / 卸载全链门禁(需要 root 或免密 sudo)
+scripts/gates/run-desktop-linux-package-smoke.sh artifacts/desktop/salmon-egg_<版本>_amd64.deb
```
-产物命名:
+包布局与理由:
+
+| 路径 | 内容 | 说明 |
+|---|---|---|
+| `/opt/salmon-egg/` | self-contained publish 输出 | FHS 中 `/opt` 用于自带运行时的附加应用 |
+| `/opt/salmon-egg/cli/salmon-egg` | CLI 二进制 | 由 publish 目录的 `cli/` 子目录带入 |
+| `/usr/bin/salmon-egg` | 指向上一行的相对符号链接(`../../opt/...`) | `/usr/bin` 已在所有登录 PATH 上;链接由 dpkg 拥有,purge 时移除。少一级会解析成 `/usr/opt` 并悬空,而包的文件列表只显示链接文本、看不出这一点 |
+| `/usr/share/applications/salmon-egg.desktop` | 桌面项 | `Exec` 指向 GUI,`MimeType` 注册与 Windows 侧一致的 `s8p` scheme |
+| `/usr/share/icons/hicolor/{16,24,32,48,256}/apps/` | 应用图标 | 取自 Resizetizer 本次构建生成的标准尺寸;源图 200×200 不是 hicolor 尺寸,直接放会被二次缩放或忽略 |
+
+依赖列表无法从构建推导:`dpkg-shlibdeps` 与 `readelf -d` 只看链接期 NEEDED,而 X11、GL、GLib、GStreamer、ICU 都是运行时 `dlopen` 的。当前列表来自实测——headless 跑起已 publish 的应用,从 `/proc//maps` 读出真实映射的库。写成备选依赖(`libicu76 | libicu74 | ...`)而非钉死版本:Microsoft 自家 .NET deb 敢钉死是因为每个发行版单独构建,而这个包只发一份。
+
+`.deb` 声明 `Conflicts`/`Replaces: salmon-egg-cli`,因为 `/usr/bin/salmon-egg` 现在归它所有。
+
+> **已知缺口**:GUI 依赖会把 X11/GL/WebKit 拉进依赖图,因此无 GUI 的服务器无法只装命令。若要恢复该场景,需要把 deb 拆成 CLI 子包与依赖它的 GUI 主包。
+
+---
+
+## 命令行工具(随主程序分发)
-```text
-salmon-egg-cli-1.0.5-win-x64.zip
-salmon-egg-cli-1.0.5-linux-x64.tar.gz
-salmon-egg-cli-1.0.5-osx-arm64.tar.gz
-salmon-egg-cli_1.0.5_amd64.deb
-salmon-egg-cli-1.0.5-win-x64.msi
+`salmon-egg` 不再单独发布。**装了 SalmonEgg 就有这个命令**,四个安装包各用本平台的机制注册它:
+
+| 安装包 | 注册机制 | 由谁验证 |
+|---|---|---|
+| Windows MSIX | 清单里的 `windows.appExecutionAlias`;Windows 在 `%LOCALAPPDATA%\Microsoft\WindowsApps` 生成入口(该目录默认在用户 PATH 上)。打包应用无法改 PATH,这是系统提供的唯一途径 | `run-msix-package-contract-gate.ps1` 读真实包 |
+| Windows MSI(Skia Desktop) | WiX `Environment` 行把 `[CLIFOLDER]`(安装目录下的 `cli`)追加到用户 PATH,卸载时移除 | `DesktopMsiContract.ps1` 读真实包的 `File` 与 `Environment` 表 |
+| Linux `.deb` | dpkg 拥有的 `/usr/bin/salmon-egg` 符号链接 | `run-desktop-linux-package-smoke.sh` 真装真卸 |
+| macOS `.pkg` | postinstall 把命令链接进 `/usr/local/bin`(macOS 默认 PATH) | `run-macos-pkg-contract-gate.sh` 用假根跑真脚本 |
+
+`.dmg` 里也带命令(在 `SalmonEgg.app` 内),但拖拽安装没有安装钩子,用户需自行链接或改用 `.pkg`。
+
+### 应用内查看与管理
+
+设置 → **命令行工具** 会实时解析 PATH 并运行命中的那个可执行文件问版本,给出四种状态:命令可用 / PATH 上是另一个版本(另一个安装挡在前面)/ 命令不可用 / 无法确认。详情里分别列出 PATH 命中的路径与符号链接指向,后者用于发现指向已被替换的 app bundle 的陈旧链接。
+
+该页只在 macOS 提供「链接 / 移除」按钮,因为只有那里应用拥有 PATH 条目;Windows 与 Linux 上它是只读的,并明确说明注册由安装包拥有。这条边界与 AGENTS 第 11 节的「安装器拥有命令的 PATH 注册」规则一致。
+
+### 支持矩阵
+
+事实源是 `src/SalmonEgg.Cli/SalmonEgg.Cli.csproj` 的 `SalmonEggCliSupportedRuntimeIdentifiers`;`publish-cli-binary.sh` 从该属性读回,不另行维护列表。
+
+| Runtime identifier | 消费它的安装包 |
+|---|---|
+| `win-x64` | MSIX、Skia Desktop MSI |
+| `linux-x64` | `.deb` |
+| `osx-arm64` | `.app`、`.dmg`、`.pkg` |
+
+矩阵之外的 RID(`win-arm64`、`linux-arm64`、`osx-x64` 等)不属于正式支持:能交叉编译不等于有真实运行验证,而且没有任何安装包会嵌入它。`--allow-unsupported-rid` 仅供本机验证,产物不得发布。
+
+产物为 self-contained 单文件,用户无需预装 .NET。
+
+### 构建 CLI 二进制
+
+```bash
+# 各打包链都调这一个脚本,所以四个安装包里的命令是同一个二进制
+scripts/release/publish-cli-binary.sh --rid linux-x64
+# 输出:artifacts/cli-bin//salmon-egg[.exe]
```
-Homebrew formula 在 release 聚合阶段由 `scripts/release/build-cli-homebrew-formula.sh` 依据本次产物的 `.sha256` 旁文件生成,不手工维护校验和。
+在 Windows runner 上该脚本跑在 Git Bash 里,因此除 POSIX 路径外还输出 `executable-path-native`(`cygpath -w` 转换):MSBuild 与 WiX 都是原生进程,打不开 `/d/a/...` 形式的路径。
+
+主程序侧通过 `-p:SalmonEggBundledCliExecutable=<路径>` 消费它(MSIX、macOS bundle、Linux deb 走这条),Windows Skia MSI 例外:它的 WiX 作者需要显式命名命令所在目录,而 heat harvest 生成的目录 ID 不稳定,所以 CLI 由 `Product.wxs` 直接声明、且发布目录里**不得**出现 `cli/`。
### 发布前门禁
```bash
# 真实产物行为门禁(退出码契约、凭据边界、事务残留、无需 .NET 运行时)
-scripts/gates/run-cli-release-artifact-smoke.sh artifacts/cli-publish/linux-x64/salmon-egg
+scripts/gates/run-cli-release-artifact-smoke.sh artifacts/cli-bin/linux-x64/salmon-egg
+
+# Linux 安装 / PATH / .desktop / 卸载全链门禁(需要 root 或免密 sudo)
+scripts/gates/run-desktop-linux-package-smoke.sh artifacts/desktop/salmon-egg_<版本>_amd64.deb
+
+# macOS 安装脚本的 PATH 注册门禁(用假根跑真 postinstall,任意平台可跑)
+scripts/gates/run-macos-pkg-contract-gate.sh
-# 安装 / PATH / 卸载门禁(需要 root 或免密 sudo)
-scripts/gates/run-cli-linux-package-smoke.sh artifacts/cli/salmon-egg-cli-1.0.5_amd64.deb
+# MSI PATH 注册规则的正反例门禁(纯字符串逻辑,任意平台可跑,无需 WiX)
+pwsh -NoProfile -File scripts/gates/run-msi-path-contract-gate.ps1
-# Windows MSI PATH 注册规则的正反例门禁(纯字符串逻辑,任意平台可跑,无需 WiX)
-pwsh -NoProfile -File scripts/gates/run-cli-msi-path-contract-gate.ps1
+# MSIX 包契约(含 alias 与 CLI payload)的自检
+pwsh -NoProfile -File scripts/gates/run-msix-package-contract-gate.ps1 -SelfTest
```
-前两个门禁必须使用**本次构建产出**的产物;`dotnet run` 不是有效验证口径。第三个门禁验证的是规则本身而非产物,因此不受此限制。
+前两个门禁必须使用**本次构建产出**的产物;`dotnet run` 不是有效验证口径。后三个验证的是规则本身而非产物,因此不受此限制,也因此能在每次 push 上跑。
### PATH 与凭据约定
-- GUI 安装包不修改 PATH。全局 `salmon-egg` 命令只来自 CLI 安装包。
-- 不允许用脚本编辑 `.bashrc`、`.zshrc` 或用户 PATH 字符串:安装、升级、卸载必须由同一个包管理器拥有。
+- **GUI 安装包负责注册 PATH。** 这与 v1.4.x 之前相反:那时全局命令只来自独立 CLI 安装包,GUI 安装包不碰 PATH。
+- 不允许用脚本编辑 `.bashrc`、`.zshrc` 或用户 PATH 字符串:安装、升级、卸载必须由同一个安装器/包管理器拥有。macOS 是唯一例外——它的包格式没有卸载阶段,`/usr/local/bin/salmon-egg` 需要用户手工 `rm`。
- CLI 凭据写入默认 fail-closed。平台安全存储不可用时写入失败而非降级为明文;需要明文降级必须显式传 `--allow-insecure-storage`。非凭据配置操作不受影响。该策略在 Linux(Secret Service)与 macOS(Keychain)上生效;Windows DPAPI 不依赖 keyring 守护进程、始终可用,因此该 flag 在 Windows 上无实际作用。
-- Windows MSI 的 PATH 注册由 `build-cli-msi.ps1` 直接读取本次构建产出的 MSI 的 `Environment` 表验证:表中必须恰好一行,且该行必须满足下表全部断言。
+- Windows MSI 的 PATH 注册由 `DesktopMsiContract.ps1` 读取本次构建产出的 MSI 的 `Environment` 表验证:表中必须恰好一行,且该行必须满足下表全部断言。
| 断言 | 违规后果 |
|---|---|
@@ -336,10 +390,13 @@ pwsh -NoProfile -File scripts/gates/run-cli-msi-path-contract-gate.ps1
| 前缀含 `-` | 卸载后安装目录永久留在 PATH 上 |
| 前缀不含 `*` | 写的是机器环境而非当前用户 |
| 值以 `[~]` + 分隔符开头 | 缺 `[~]` 会整体覆盖 PATH(MSI 文档明确警告可能导致机器无法启动);`[~]` 出现在结尾则是前置插入,会遮蔽用户原有工具 |
- | 值引用 `[INSTALLFOLDER]` | 加进 PATH 的不是本包的安装目录 |
+ | 值引用 `[CLIFOLDER]` | 加进 PATH 的不是命令所在目录(若写成 `[INSTALLFOLDER]`,等于把应用旁边所有 DLL 一起暴露,且命令仍然不可解析) |
- 规则本身在 `scripts/release/CliMsiPathContract.ps1`,与读取 MSI 的 COM 代码分离,因此 `scripts/gates/run-cli-msi-path-contract-gate.ps1` 能在任意平台(含 Linux)直接用正反例跑这条规则;该门禁在 `ci-core.yml` 的每次 push / PR 上执行,不必等到打 tag 才发现规则被削弱。
-- 上述断言只覆盖包内表结构。真实安装 / 卸载需要交互式 Windows 会话,不在 CI 覆盖范围:发布 Windows 安装包前仍需手工确认安装后新开终端 `where salmon-egg` 命中安装目录、卸载后命令消失、且用户原有 PATH 条目完好无残留重复项。
+ 规则本身在 `scripts/release/MsiPathContract.ps1`,与读取 MSI 的 COM 代码分离,目录 token 是参数,因此 `scripts/gates/run-msi-path-contract-gate.ps1` 能在任意平台(含 Linux)直接用正反例跑这条规则;该门禁在 `ci-core.yml` 的每次 push / PR 上执行,不必等到打 tag 才发现规则被削弱。
+- 上述断言只覆盖包内表结构。真实安装 / 卸载需要交互式会话,不在 CI 覆盖范围。发布前仍需手工确认:
+ - **Windows MSIX**:安装后新开终端 `where salmon-egg` 命中 `WindowsApps` 下的 alias,且命令继承控制台、退出码正确;卸载后 alias 消失。
+ - **Windows MSI**:安装后新开终端 `where salmon-egg` 命中 `...\SalmonEgg\cli`;卸载后命令消失、用户原有 PATH 条目完好无残留重复项。
+ - **macOS**:`.pkg` 安装后新登录 shell 里 `which salmon-egg` 命中 `/usr/local/bin`;且带 CLI 的 bundle 能通过公证(Mach-O 落在 Uno 选定的目录里,是否被签名覆盖需实测)。
---
@@ -364,8 +421,9 @@ git push origin v1.0.0
发布前检查清单:
- [ ] 所有测试通过
-- [ ] CLI 三个支持 RID 的产物均已构建
-- [ ] CLI 真实产物 smoke 与安装 / PATH 门禁通过
+- [ ] 四个安装包都嵌入了本次构建的 CLI(MSIX、Skia MSI、`.deb`、`.app`/`.pkg`)
+- [ ] CLI 真实产物 smoke 通过;Linux `.deb` 的安装 / PATH / 卸载门禁通过
+- [ ] 手工确认 Windows 与 macOS 安装后 `salmon-egg` 可解析(见「PATH 与凭据约定」末尾清单)
- [ ] 确认 GitHub 自动生成的 release notes 或维护中的变更记录已覆盖本版本
- [ ] 更新 README.md(如需要)
- [ ] 构建发布版本
diff --git a/scripts/gates/run-cli-linux-package-smoke.sh b/scripts/gates/run-cli-linux-package-smoke.sh
deleted file mode 100755
index f19414783..000000000
--- a/scripts/gates/run-cli-linux-package-smoke.sh
+++ /dev/null
@@ -1,147 +0,0 @@
-#!/usr/bin/env bash
-# Installs the SalmonEgg CLI Debian package, proves `salmon-egg` becomes a PATH command, then removes the
-# package and proves the command disappears.
-#
-# This is the gate that makes the PATH claim in the documentation true. A package that merely contains a
-# binary is not the same as an installed command, and an install that cannot be reversed is a worse
-# outcome than no packaging at all — so both directions are asserted.
-#
-# Requires root (dpkg writes to /usr/bin). Uses sudo when not already root.
-set -euo pipefail
-
-DEB_PATH="${1:?Path to the salmon-egg-cli .deb is required}"
-
-if [ ! -f "$DEB_PATH" ]; then
- echo "Debian package not found: $DEB_PATH" >&2
- exit 1
-fi
-DEB_PATH="$(cd "$(dirname "$DEB_PATH")" && pwd)/$(basename "$DEB_PATH")"
-
-PACKAGE_NAME="salmon-egg-cli"
-INSTALLED_PATH="/usr/bin/salmon-egg"
-
-if [ "$(id -u)" -eq 0 ]; then
- as_root() { "$@"; }
-elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
- as_root() { sudo -n "$@"; }
-else
- echo "This gate installs a system package and needs root or passwordless sudo." >&2
- exit 1
-fi
-
-failures=0
-checks=0
-
-fail() { echo " [FAIL] $1" >&2; failures=$((failures + 1)); }
-pass() { echo " [ok] $1"; }
-check() { checks=$((checks + 1)); }
-
-# The package must be gone whether the gate passes, fails, or is interrupted: leaving a test build of the
-# CLI installed on the runner would poison every later job on that machine.
-cleanup() {
- if dpkg-query --status "$PACKAGE_NAME" >/dev/null 2>&1; then
- as_root dpkg --purge "$PACKAGE_NAME" >/dev/null 2>&1 || true
- fi
-}
-trap cleanup EXIT
-
-echo "[package-smoke] package: $DEB_PATH"
-
-check
-if dpkg-query --status "$PACKAGE_NAME" >/dev/null 2>&1; then
- fail "$PACKAGE_NAME is already installed; the gate cannot attribute the command to this package"
-else
- pass "$PACKAGE_NAME is not installed before the gate runs"
-fi
-
-check
-if [ -e "$INSTALLED_PATH" ]; then
- fail "$INSTALLED_PATH already exists before installation"
-else
- pass "$INSTALLED_PATH does not exist before installation"
-fi
-
-echo "[package-smoke] 1. install"
-check
-if as_root dpkg --install "$DEB_PATH" >/dev/null; then
- pass "dpkg --install succeeded"
-else
- fail "dpkg --install failed"
-fi
-
-echo "[package-smoke] 2. the command is registered on PATH"
-check
-# `hash -r` clears the shell's own command cache so resolution reflects the filesystem, not this
-# process's memory of it.
-hash -r 2>/dev/null || true
-resolved="$(command -v salmon-egg || true)"
-if [ "$resolved" = "$INSTALLED_PATH" ]; then
- pass "command -v salmon-egg resolves to $resolved"
-else
- fail "command -v salmon-egg resolved to '${resolved:-nothing}', expected $INSTALLED_PATH"
-fi
-
-check
-if as_root dpkg-query --listfiles "$PACKAGE_NAME" | grep -qx "$INSTALLED_PATH"; then
- pass "dpkg owns $INSTALLED_PATH, so removal is reversible"
-else
- fail "dpkg does not own $INSTALLED_PATH"
-fi
-
-echo "[package-smoke] 3. the installed command runs"
-check
-# A login shell with a default PATH: this is what a real user gets, not the PATH this script inherited.
-if version="$(env -i HOME="$HOME" bash -lc 'salmon-egg --version' 2>/dev/null)"; then
- if printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+'; then
- pass "salmon-egg --version works in a clean login shell ($version)"
- else
- fail "salmon-egg --version produced unexpected output: [$version]"
- fi
-else
- fail "salmon-egg --version failed in a clean login shell"
-fi
-
-check
-package_version="$(dpkg-query --show --showformat='${Version}' "$PACKAGE_NAME")"
-reported_version="$(printf '%s' "$version" | cut -d'+' -f1)"
-case "$reported_version" in
- "$package_version"*) pass "reported version $reported_version matches package version $package_version" ;;
- *) fail "reported version '$reported_version' does not match package version '$package_version'" ;;
-esac
-
-echo "[package-smoke] 4. removal takes the command with it"
-check
-if as_root dpkg --purge "$PACKAGE_NAME" >/dev/null; then
- pass "dpkg --purge succeeded"
-else
- fail "dpkg --purge failed"
-fi
-
-check
-hash -r 2>/dev/null || true
-if [ -e "$INSTALLED_PATH" ]; then
- fail "$INSTALLED_PATH still exists after purge"
-else
- pass "$INSTALLED_PATH was removed"
-fi
-
-check
-if env -i HOME="$HOME" bash -lc 'command -v salmon-egg' >/dev/null 2>&1; then
- fail "salmon-egg is still resolvable after purge"
-else
- pass "salmon-egg is no longer resolvable"
-fi
-
-echo
-if [ "$failures" -ne 0 ]; then
- echo "[package-smoke] FAILED: $failures of $checks checks failed." >&2
- exit 1
-fi
-
-if [ "$checks" -lt 10 ]; then
- # Guards against a silently short run: a gate that exited early would otherwise report success.
- echo "[package-smoke] FAILED: only $checks checks ran; expected at least 10." >&2
- exit 1
-fi
-
-echo "[package-smoke] PASSED: $checks checks."
diff --git a/scripts/gates/run-desktop-linux-package-smoke.sh b/scripts/gates/run-desktop-linux-package-smoke.sh
new file mode 100755
index 000000000..656915457
--- /dev/null
+++ b/scripts/gates/run-desktop-linux-package-smoke.sh
@@ -0,0 +1,239 @@
+#!/usr/bin/env bash
+# Installs the SalmonEgg desktop Debian package, proves the app and the salmon-egg command both become
+# usable, then removes the package and proves both disappear.
+#
+# This is the gate that makes the claim "installing SalmonEgg installs the CLI" true on Linux. A package
+# that merely contains two executables is not the same as an installed app plus a PATH command, and the
+# two ways this can be silently wrong are exactly what is asserted: a symlink whose relative depth
+# resolves outside the filesystem root, and a desktop entry whose Exec names a file that is not there.
+#
+# Requires root (dpkg writes to /usr and /opt). Uses sudo when not already root.
+set -euo pipefail
+
+DEB_PATH="${1:?Path to the salmon-egg .deb is required}"
+
+if [ ! -f "$DEB_PATH" ]; then
+ echo "Debian package not found: $DEB_PATH" >&2
+ exit 1
+fi
+DEB_PATH="$(cd "$(dirname "$DEB_PATH")" && pwd)/$(basename "$DEB_PATH")"
+
+PACKAGE_NAME="salmon-egg"
+COMMAND_PATH="/usr/bin/salmon-egg"
+INSTALL_ROOT="/opt/salmon-egg"
+APP_PATH="$INSTALL_ROOT/SalmonEgg"
+CLI_PATH="$INSTALL_ROOT/cli/salmon-egg"
+DESKTOP_ENTRY="/usr/share/applications/salmon-egg.desktop"
+ICON_PATH="/usr/share/icons/hicolor/256x256/apps/salmon-egg.png"
+
+if [ "$(id -u)" -eq 0 ]; then
+ as_root() { "$@"; }
+elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
+ as_root() { sudo -n "$@"; }
+else
+ echo "This gate installs a system package and needs root or passwordless sudo." >&2
+ exit 1
+fi
+
+failures=0
+checks=0
+
+fail() { echo " [FAIL] $1" >&2; failures=$((failures + 1)); }
+pass() { echo " [ok] $1"; }
+check() { checks=$((checks + 1)); }
+
+# The package must be gone whether the gate passes, fails, or is interrupted: leaving a test build
+# installed would poison every later job on the machine and every later run of this gate.
+cleanup() {
+ if dpkg-query --status "$PACKAGE_NAME" >/dev/null 2>&1; then
+ as_root dpkg --purge "$PACKAGE_NAME" >/dev/null 2>&1 || true
+ fi
+}
+trap cleanup EXIT
+
+echo "[desktop-package-smoke] package: $DEB_PATH"
+
+check
+if dpkg-query --status "$PACKAGE_NAME" >/dev/null 2>&1; then
+ fail "$PACKAGE_NAME is already installed; the gate cannot attribute anything to this package"
+else
+ pass "$PACKAGE_NAME is not installed before the gate runs"
+fi
+
+check
+if [ -e "$COMMAND_PATH" ] || [ -e "$INSTALL_ROOT" ]; then
+ fail "$COMMAND_PATH or $INSTALL_ROOT already exists before installation"
+else
+ pass "neither $COMMAND_PATH nor $INSTALL_ROOT exists before installation"
+fi
+
+echo "[desktop-package-smoke] 1. install"
+check
+# apt rather than dpkg, when it is available: apt resolves the package's own Depends, which is both what a
+# user's `apt install ./salmon-egg.deb` does and a stronger assertion than dpkg gives. A misspelled or
+# non-existent package name in Depends installs fine under `dpkg --install` on a machine that happens to
+# have the libraries, and fails here.
+if command -v apt-get >/dev/null 2>&1; then
+ install_command="apt-get install --yes --quiet"
+ # The runner image's package lists name versions the archive has since replaced, and the mirror
+ # serves only current ones -- apt then 404s mid-install ("maybe run apt-get update"). Refreshing
+ # the lists is part of installing cleanly, so it belongs inside the gate rather than in a workflow
+ # step the script cannot see.
+ if as_root env DEBIAN_FRONTEND=noninteractive apt-get update --quiet >/dev/null; then
+ pass "apt-get update refreshed the package lists"
+ else
+ fail "apt-get update failed"
+ fi
+ if as_root env DEBIAN_FRONTEND=noninteractive apt-get install --yes --quiet "$DEB_PATH" >/dev/null; then
+ pass "apt-get install resolved every declared dependency and installed the package"
+ else
+ fail "apt-get install failed"
+ fi
+else
+ install_command="dpkg --install"
+ if as_root dpkg --install "$DEB_PATH" >/dev/null; then
+ pass "dpkg --install succeeded"
+ else
+ fail "dpkg --install failed"
+ fi
+fi
+echo "[desktop-package-smoke] (installed with: $install_command)"
+
+echo "[desktop-package-smoke] 2. the app is installed and executable"
+check
+if [ -x "$APP_PATH" ]; then
+ pass "$APP_PATH is present and executable"
+else
+ fail "$APP_PATH is missing or not executable"
+fi
+
+echo "[desktop-package-smoke] 3. the command is registered on PATH"
+check
+# `hash -r` clears this shell's command cache so resolution reflects the filesystem, not its memory of it.
+hash -r 2>/dev/null || true
+resolved="$(command -v salmon-egg || true)"
+if [ "$resolved" = "$COMMAND_PATH" ]; then
+ pass "command -v salmon-egg resolves to $resolved"
+else
+ fail "command -v salmon-egg resolved to '${resolved:-nothing}', expected $COMMAND_PATH"
+fi
+
+check
+# The symlink's own target, not just "the command runs": a relative link one level too shallow resolves to
+# /usr/opt/... and dangles. That is invisible in the built package's file list, which shows only the text.
+link_target="$(readlink "$COMMAND_PATH" 2>/dev/null || true)"
+resolved_target="$(readlink -f "$COMMAND_PATH" 2>/dev/null || true)"
+if [ "$resolved_target" = "$CLI_PATH" ]; then
+ pass "$COMMAND_PATH -> '$link_target' resolves to $CLI_PATH"
+else
+ fail "$COMMAND_PATH -> '$link_target' resolves to '${resolved_target:-nothing}', expected $CLI_PATH"
+fi
+
+check
+if as_root dpkg-query --listfiles "$PACKAGE_NAME" | grep -qx "$COMMAND_PATH"; then
+ pass "dpkg owns $COMMAND_PATH, so removal is reversible"
+else
+ fail "dpkg does not own $COMMAND_PATH"
+fi
+
+echo "[desktop-package-smoke] 4. the installed command runs"
+check
+# A login shell with a default PATH and an isolated app-data root: this is what a real user's shell
+# resolves, not the PATH this script inherited, and it must not touch real user configuration.
+smoke_appdata="$(mktemp -d)"
+if version="$(env -i HOME="$HOME" SALMONEGG_APPDATA_ROOT="$smoke_appdata" \
+ bash -lc 'salmon-egg --version' 2>/dev/null)"; then
+ if printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+'; then
+ pass "salmon-egg --version works in a clean login shell ($version)"
+ else
+ fail "salmon-egg --version produced unexpected output: [$version]"
+ fi
+else
+ fail "salmon-egg --version failed in a clean login shell"
+fi
+rm -rf "$smoke_appdata"
+
+check
+package_version="$(dpkg-query --show --showformat='${Version}' "$PACKAGE_NAME")"
+reported_version="$(printf '%s' "${version:-}" | cut -d'+' -f1)"
+case "$reported_version" in
+ "$package_version"*) pass "reported version $reported_version matches package version $package_version" ;;
+ *) fail "reported version '$reported_version' does not match package version '$package_version'" ;;
+esac
+
+echo "[desktop-package-smoke] 5. the app is launchable from the desktop shell"
+check
+if [ -f "$DESKTOP_ENTRY" ]; then
+ pass "$DESKTOP_ENTRY is installed"
+else
+ fail "$DESKTOP_ENTRY is missing"
+fi
+
+check
+# An Exec naming a file that is not there is a launcher that does nothing when clicked, and neither dpkg
+# nor desktop-file-validate would notice.
+exec_line="$(sed -n 's/^Exec=//p' "$DESKTOP_ENTRY" 2>/dev/null | head -1)"
+exec_target="${exec_line%% *}"
+if [ -n "$exec_target" ] && [ -x "$exec_target" ]; then
+ pass "the desktop entry's Exec ($exec_target) is present and executable"
+else
+ fail "the desktop entry's Exec resolves to '${exec_target:-nothing}', which is not an executable"
+fi
+
+check
+icon_name="$(sed -n 's/^Icon=//p' "$DESKTOP_ENTRY" 2>/dev/null | head -1)"
+if [ -n "$icon_name" ] && [ -f "/usr/share/icons/hicolor/256x256/apps/$icon_name.png" ]; then
+ pass "the desktop entry's Icon ($icon_name) has a 256x256 hicolor icon"
+else
+ fail "the desktop entry's Icon '${icon_name:-}' has no matching hicolor icon"
+fi
+
+check
+if [ -f "$ICON_PATH" ]; then
+ pass "$ICON_PATH is installed"
+else
+ fail "$ICON_PATH is missing"
+fi
+
+echo "[desktop-package-smoke] 6. removal takes all of it with it"
+check
+if as_root dpkg --purge "$PACKAGE_NAME" >/dev/null; then
+ pass "dpkg --purge succeeded"
+else
+ fail "dpkg --purge failed"
+fi
+
+check
+hash -r 2>/dev/null || true
+leftovers=""
+for path in "$COMMAND_PATH" "$APP_PATH" "$CLI_PATH" "$DESKTOP_ENTRY" "$ICON_PATH" "$INSTALL_ROOT"; do
+ if [ -e "$path" ] || [ -L "$path" ]; then
+ leftovers="$leftovers $path"
+ fi
+done
+if [ -z "$leftovers" ]; then
+ pass "install root, command, desktop entry and icon were all removed"
+else
+ fail "purge left behind:$leftovers"
+fi
+
+check
+if env -i HOME="$HOME" bash -lc 'command -v salmon-egg' >/dev/null 2>&1; then
+ fail "salmon-egg is still resolvable after purge"
+else
+ pass "salmon-egg is no longer resolvable"
+fi
+
+echo
+if [ "$failures" -ne 0 ]; then
+ echo "[desktop-package-smoke] FAILED: $failures of $checks checks failed." >&2
+ exit 1
+fi
+
+if [ "$checks" -lt 14 ]; then
+ # Guards against a silently short run: a gate that exited early would otherwise report success.
+ echo "[desktop-package-smoke] FAILED: only $checks checks ran; expected at least 14." >&2
+ exit 1
+fi
+
+echo "[desktop-package-smoke] PASSED: $checks checks."
diff --git a/scripts/gates/run-desktop-msi-contract-gate.ps1 b/scripts/gates/run-desktop-msi-contract-gate.ps1
index dc3854287..2b89a4422 100755
--- a/scripts/gates/run-desktop-msi-contract-gate.ps1
+++ b/scripts/gates/run-desktop-msi-contract-gate.ps1
@@ -58,12 +58,24 @@ class FakeMsiDatabase
{
[string[]]$FileNames
[string]$ProductVersion
+ [object[]]$EnvironmentRows
[System.Collections.Generic.List[string]]$Queries
+ # The two-argument form carries the PATH row a correct package has, so a case only spells the
+ # Environment table out when the row itself is what it is testing.
FakeMsiDatabase([string[]]$fileNames, [string]$productVersion)
{
$this.FileNames = $fileNames
$this.ProductVersion = $productVersion
+ $this.EnvironmentRows = @([pscustomobject]@{ Name = '=-PATH'; Value = '[~];[CLIFOLDER]' })
+ $this.Queries = [System.Collections.Generic.List[string]]::new()
+ }
+
+ FakeMsiDatabase([string[]]$fileNames, [string]$productVersion, [object[]]$environmentRows)
+ {
+ $this.FileNames = $fileNames
+ $this.ProductVersion = $productVersion
+ $this.EnvironmentRows = $environmentRows
$this.Queries = [System.Collections.Generic.List[string]]::new()
}
@@ -91,6 +103,17 @@ class FakeMsiDatabase
return [FakeMsiView]::new($rows)
}
+ if ($query -match '(?i)FROM\s+`Environment`')
+ {
+ $rows = @()
+ foreach ($pair in $this.EnvironmentRows)
+ {
+ $rows += [FakeMsiRecord]::new(@($pair.Name, $pair.Value))
+ }
+
+ return [FakeMsiView]::new($rows)
+ }
+
if ($query -match '(?i)FROM\s+`Property`')
{
if ([string]::IsNullOrEmpty($this.ProductVersion)) { return [FakeMsiView]::new(@()) }
@@ -108,7 +131,7 @@ function Add-Failure { param([string]$Message) $script:failures += $Message }
$cases = @(
@{
Description = 'the package a successful harvest produces'
- FileNames = @('SalmonEgg.exe', 'SalmonEgg.dll', 'SkiaSharp.dll')
+ FileNames = @('SalmonEgg.exe', 'salmon-egg.exe', 'SalmonEgg.dll', 'SkiaSharp.dll')
Version = '1.3.0'
Expected = $null
}
@@ -129,7 +152,7 @@ $cases = @(
# heat emits 'SHORT~1.EXE|Long.exe' whenever a name needs an 8.3 alias, so the rule has to read
# both halves. Matching the whole cell would miss the executable and fail for the wrong reason.
Description = 'a package whose File rows carry short|long name pairs'
- FileNames = @('SALMON~1.EXE|SalmonEgg.exe', 'SKIASH~1.DLL|SkiaSharp.dll')
+ FileNames = @('SALMON~1.EXE|SalmonEgg.exe', 'SALMON~2.EXE|salmon-egg.exe', 'SKIASH~1.DLL|SkiaSharp.dll')
Version = '1.3.0'
Expected = $null
}
@@ -151,7 +174,7 @@ $cases = @(
# v1.3.0 attempt: PowerShell refuses to bind a [string[]] containing an empty string unless the
# parameter declares AllowEmptyString. No fake produced one, so no gate could have caught it.
Description = 'a package whose File table carries empty FileName cells alongside real ones'
- FileNames = @('', 'SalmonEgg.exe', '', 'SkiaSharp.dll')
+ FileNames = @('', 'SalmonEgg.exe', '', 'salmon-egg.exe', 'SkiaSharp.dll')
Version = '1.3.0'
Expected = $null
}
@@ -163,27 +186,87 @@ $cases = @(
}
@{
Description = 'a package whose ProductVersion was never substituted'
- FileNames = @('SalmonEgg.exe')
+ FileNames = @('SalmonEgg.exe', 'salmon-egg.exe')
Version = '$(SalmonEggDisplayVersion)'
Expected = 'InvalidVersion'
}
@{
Description = 'a package carrying a four-part version MajorUpgrade cannot compare'
- FileNames = @('SalmonEgg.exe')
+ FileNames = @('SalmonEgg.exe', 'salmon-egg.exe')
Version = '1.3.0.0'
Expected = 'InvalidVersion'
}
@{
Description = 'a package with no ProductVersion row at all'
- FileNames = @('SalmonEgg.exe')
+ FileNames = @('SalmonEgg.exe', 'salmon-egg.exe')
Version = ''
Expected = 'InvalidVersion'
}
+ @{
+ # The defect the bundled-CLI publish exists to prevent: the app installs, the PATH row names a cli
+ # directory, and there is nothing in it to run.
+ Description = 'a package that installs the app but never carried the command'
+ FileNames = @('SalmonEgg.exe', 'SkiaSharp.dll')
+ Version = '1.3.0'
+ Expected = 'MissingCommandExe'
+ }
+ @{
+ Description = 'a package shipping the command with no PATH row at all'
+ FileNames = @('SalmonEgg.exe', 'salmon-egg.exe')
+ Version = '1.3.0'
+ Environment = @()
+ Expected = 'NoPathRegistration'
+ }
+ @{
+ # A second environment write reaching the package would be applied too, unreviewed.
+ Description = 'a package carrying a second, unreviewed environment row'
+ FileNames = @('SalmonEgg.exe', 'salmon-egg.exe')
+ Version = '1.3.0'
+ Environment = @(
+ [pscustomobject]@{ Name = '=-PATH'; Value = '[~];[CLIFOLDER]' },
+ [pscustomobject]@{ Name = '=-*PATH'; Value = '[~];[CLIFOLDER]' }
+ )
+ Expected = 'MultiplePathRegistrations'
+ }
+ @{
+ # Identifiers from the shared PATH rule pass through, so the diagnosis names the encoding defect
+ # rather than a generic "bad PATH row".
+ Description = 'a package whose PATH row prepends the command directory'
+ FileNames = @('SalmonEgg.exe', 'salmon-egg.exe')
+ Version = '1.3.0'
+ Environment = @([pscustomobject]@{ Name = '=-PATH'; Value = '[CLIFOLDER];[~]' })
+ Expected = 'NotAppended'
+ }
+ @{
+ Description = 'a package whose PATH row survives uninstall'
+ FileNames = @('SalmonEgg.exe', 'salmon-egg.exe')
+ Version = '1.3.0'
+ Environment = @([pscustomobject]@{ Name = '=PATH'; Value = '[~];[CLIFOLDER]' })
+ Expected = 'NotRemovedOnUninstall'
+ }
+ @{
+ # Registering the app folder rather than the cli subdirectory puts every shipped DLL on PATH and
+ # still leaves the command unresolvable.
+ Description = 'a package registering the app folder instead of the command folder'
+ FileNames = @('SalmonEgg.exe', 'salmon-egg.exe')
+ Version = '1.3.0'
+ Environment = @([pscustomobject]@{ Name = '=-PATH'; Value = '[~];[INSTALLFOLDER]' })
+ Expected = 'MissingDirectoryToken'
+ }
)
foreach ($case in $cases)
{
- $database = [FakeMsiDatabase]::new($case.FileNames, $case.Version)
+ # ContainsKey rather than a null check: Set-StrictMode rejects reading a key a hashtable does not have.
+ $database = if ($case.ContainsKey('Environment'))
+ {
+ [FakeMsiDatabase]::new($case.FileNames, $case.Version, $case.Environment)
+ }
+ else
+ {
+ [FakeMsiDatabase]::new($case.FileNames, $case.Version)
+ }
+
$violation = Get-DesktopMsiContractViolation -Database $database
$actual = if ($null -eq $violation) { $null } else { $violation.Id }
@@ -201,7 +284,9 @@ foreach ($case in $cases)
# Every query the contract issues must be one Windows Installer can parse. Asserting on the queries the
# conforming run actually made is what catches a future `SELECT COUNT(*)` on the pushing commit.
-$database = [FakeMsiDatabase]::new(@('SalmonEgg.exe'), '1.3.0')
+# A conforming package, so the contract runs to the end and issues every query it has -- including the
+# Environment one. A package rejected early would leave the later queries unchecked.
+$database = [FakeMsiDatabase]::new(@('SalmonEgg.exe', 'salmon-egg.exe'), '1.3.0')
[void](Get-DesktopMsiContractViolation -Database $database)
if ($database.Queries.Count -lt 1)
{
@@ -226,7 +311,7 @@ foreach ($query in $database.Queries)
{
try
{
- [void]([FakeMsiDatabase]::new(@('SalmonEgg.exe'), '1.3.0')).OpenView($query)
+ [void]([FakeMsiDatabase]::new(@('SalmonEgg.exe', 'salmon-egg.exe'), '1.3.0')).OpenView($query)
}
catch
{
@@ -328,7 +413,7 @@ Write-Host '[desktop-msi-gate] the File-table diagnostic survives empty, paired
# conforming package through -- a silent Get-* consumer would defeat the whole gate.
try
{
- Assert-DesktopMsiContract -Database ([FakeMsiDatabase]::new(@('SalmonEgg.exe', 'SalmonEgg.dll'), '1.3.0'))
+ Assert-DesktopMsiContract -Database ([FakeMsiDatabase]::new(@('SalmonEgg.exe', 'salmon-egg.exe', 'SalmonEgg.dll'), '1.3.0'))
}
catch
{
diff --git a/scripts/gates/run-macos-pkg-contract-gate.sh b/scripts/gates/run-macos-pkg-contract-gate.sh
new file mode 100755
index 000000000..23b878905
--- /dev/null
+++ b/scripts/gates/run-macos-pkg-contract-gate.sh
@@ -0,0 +1,187 @@
+#!/usr/bin/env bash
+# Exercises the macOS installer's PATH registration, including every failure case, on any platform.
+#
+# The real thing runs inside `installer -pkg`, which needs macOS and an admin prompt. That made the one
+# script standing between "the app is installed" and "salmon-egg is a command" unrehearsable. This gate runs
+# that exact file — scripts/release/macos-pkg-postinstall.sh, the same copy pkgbuild embeds — against fake
+# roots, and asserts both the link it creates and the situations in which it must refuse.
+set -euo pipefail
+
+# The gate's subject is symlink surgery, and an MSYS host (Git Bash on Windows) must be told to perform
+# real ones. Left alone, MSYS `ln -s` quietly copies the target when it exists -- producing the exact
+# "regular file on PATH" defect this gate exists to catch -- and errors out when the target is missing,
+# which is what the dangling-link case is made of. nativestrict makes `ln -s` either create a real
+# symlink (runners have Developer Mode, so no elevation is needed) or fail; the probe below turns that
+# failure into an honest skip. On a POSIX host this variable is inert.
+export MSYS=winsymlinks:nativestrict
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+POSTINSTALL="$REPO_ROOT/scripts/release/macos-pkg-postinstall.sh"
+
+if [ ! -f "$POSTINSTALL" ]; then
+ echo "[macos-pkg-gate] FAIL postinstall script not found: $POSTINSTALL" >&2
+ exit 1
+fi
+
+failures=0
+checks=0
+
+fail() { echo " [FAIL] $1" >&2; failures=$((failures + 1)); }
+pass() { echo " [ok] $1"; }
+check() { checks=$((checks + 1)); }
+
+WORK_DIR="$(mktemp -d)"
+trap 'rm -rf "$WORK_DIR"' EXIT
+
+# If this host cannot create a real symlink at all, every assertion below would be measuring the
+# copy-or-fail substitute instead, and a green run would prove nothing about the postinstall. Say so
+# and stop, rather than rehearse a fiction. The conditional (not an early `ln` alone) is what keeps
+# `set -e` from treating the probe's own failure as a script-killing error.
+probe_target="$WORK_DIR/probe-target"
+probe_link="$WORK_DIR/probe-link"
+: > "$probe_target"
+if ! { ln -s "$probe_target" "$probe_link" 2>/dev/null &&
+ [ "$(readlink "$probe_link" 2>/dev/null)" = "$probe_target" ]; }; then
+ echo "[macos-pkg-gate] SKIP: this host cannot create real symlinks, so the PATH-link contract cannot be rehearsed here. Run this gate on POSIX or a Developer-Mode Windows host." >&2
+ exit 0
+fi
+rm -f "$probe_link" "$probe_target"
+
+# Builds the tree the installer would have laid down before postinstall runs: the app bundle in place, with
+# or without the bundled command.
+new_fake_root() {
+ local name="$1" with_command="$2" area="${3:-MacOS}"
+ local root="$WORK_DIR/$name"
+ mkdir -p "$root/Applications/SalmonEgg.app/Contents/MacOS" \
+ "$root/Applications/SalmonEgg.app/Contents/Resources"
+ if [ "$with_command" = "with-command" ]; then
+ mkdir -p "$root/Applications/SalmonEgg.app/Contents/$area/cli"
+ printf '#!/bin/sh\necho 1.0.0\n' > "$root/Applications/SalmonEgg.app/Contents/$area/cli/salmon-egg"
+ chmod +x "$root/Applications/SalmonEgg.app/Contents/$area/cli/salmon-egg"
+ fi
+ printf '%s' "$root"
+}
+
+# The installer invokes postinstall as `postinstall `.
+run_postinstall() {
+ local root="$1"
+ sh "$POSTINSTALL" "/tmp/SalmonEgg.pkg" "$root" "$root"
+}
+
+# Both bundle areas Uno might place the command in. Which one it picks is its decision, not this
+# repository's, so the installer has to work either way -- see the comment in macos-pkg-postinstall.sh.
+for area in MacOS Resources; do
+ echo "[macos-pkg-gate] 1. a bundle carrying the command under Contents/$area gets a link on PATH"
+ root="$(new_fake_root "install-$area" with-command "$area")"
+ check
+ if run_postinstall "$root" >/dev/null 2>&1; then
+ pass "postinstall succeeded"
+ else
+ fail "postinstall failed on a bundle carrying the command under Contents/$area"
+ fi
+
+ link="$root/usr/local/bin/salmon-egg"
+ expected_target="$root/Applications/SalmonEgg.app/Contents/$area/cli/salmon-egg"
+
+ check
+ if [ -L "$link" ]; then
+ pass "$link is a symlink"
+ else
+ fail "$link is not a symlink"
+ fi
+
+ check
+ actual_target="$(readlink "$link" 2>/dev/null || true)"
+ if [ "$actual_target" = "$expected_target" ]; then
+ pass "the link points at the bundled command under Contents/$area"
+ else
+ fail "the link points at '${actual_target:-nothing}', expected $expected_target"
+ fi
+
+ check
+ # /usr/local/bin is on the default macOS PATH, so resolving the link is what makes the command work.
+ # Running it proves the link is usable rather than merely present.
+ if [ "$("$link" 2>/dev/null || true)" = "1.0.0" ]; then
+ pass "the linked command executes"
+ else
+ fail "the linked command did not execute"
+ fi
+done
+
+echo "[macos-pkg-gate] 2. an install with no command in the bundle is refused"
+root="$(new_fake_root missing-command without-command)"
+check
+if run_postinstall "$root" >/dev/null 2>&1; then
+ fail "postinstall succeeded for a bundle with no bundled command"
+else
+ pass "postinstall failed, as it must"
+fi
+
+check
+# The consequence this guards: a link to nothing shadows any salmon-egg the user installs later.
+if [ -e "$root/usr/local/bin/salmon-egg" ] || [ -L "$root/usr/local/bin/salmon-egg" ]; then
+ fail "a link was created even though the bundle carries no command"
+else
+ pass "no link was left behind"
+fi
+
+echo "[macos-pkg-gate] 3. a dangling link from an earlier version is replaced"
+root="$(new_fake_root dangling with-command)"
+mkdir -p "$root/usr/local/bin"
+ln -s "$root/Applications/SalmonEgg.app/Contents/MacOS/cli/gone" "$root/usr/local/bin/salmon-egg"
+check
+if run_postinstall "$root" >/dev/null 2>&1; then
+ pass "postinstall succeeded over a dangling link"
+else
+ fail "postinstall failed when a dangling link was present"
+fi
+
+check
+# -e follows the link, so a dangling one reads as absent: without the -L test in the script, `ln -s` would
+# fail here and the upgrade would silently leave the broken link in place.
+actual_target="$(readlink "$root/usr/local/bin/salmon-egg" 2>/dev/null || true)"
+if [ "$actual_target" = "$root/Applications/SalmonEgg.app/Contents/MacOS/cli/salmon-egg" ]; then
+ pass "the dangling link was replaced with a working one"
+else
+ fail "the link still points at '${actual_target:-nothing}'"
+fi
+
+echo "[macos-pkg-gate] 4. a real file at the link path is replaced"
+root="$(new_fake_root occupied with-command)"
+mkdir -p "$root/usr/local/bin"
+printf 'not a link\n' > "$root/usr/local/bin/salmon-egg"
+check
+if run_postinstall "$root" >/dev/null 2>&1; then
+ pass "postinstall succeeded over an existing regular file"
+else
+ fail "postinstall failed when a regular file occupied the link path"
+fi
+
+check
+if [ -L "$root/usr/local/bin/salmon-egg" ]; then
+ pass "the regular file was replaced by the link"
+else
+ fail "the link path is still a regular file"
+fi
+
+echo "[macos-pkg-gate] 5. the script is idempotent"
+root="$(new_fake_root idempotent with-command)"
+check
+if run_postinstall "$root" >/dev/null 2>&1 && run_postinstall "$root" >/dev/null 2>&1; then
+ pass "running postinstall twice succeeds, as a reinstall over an existing install does"
+else
+ fail "the second run failed"
+fi
+
+echo
+if [ "$failures" -ne 0 ]; then
+ echo "[macos-pkg-gate] FAILED: $failures of $checks checks failed." >&2
+ exit 1
+fi
+
+if [ "$checks" -lt 15 ]; then
+ echo "[macos-pkg-gate] FAILED: only $checks checks ran; expected at least 15." >&2
+ exit 1
+fi
+
+echo "[macos-pkg-gate] PASSED: $checks checks."
diff --git a/scripts/gates/run-cli-msi-path-contract-gate.ps1 b/scripts/gates/run-msi-path-contract-gate.ps1
similarity index 59%
rename from scripts/gates/run-cli-msi-path-contract-gate.ps1
rename to scripts/gates/run-msi-path-contract-gate.ps1
index 49c9ba2fe..6b35a302b 100755
--- a/scripts/gates/run-cli-msi-path-contract-gate.ps1
+++ b/scripts/gates/run-msi-path-contract-gate.ps1
@@ -1,12 +1,12 @@
#requires -Version 7.0
<#
.SYNOPSIS
- Exercises the SalmonEgg CLI MSI PATH-registration contract, including every failure case.
+ Exercises the SalmonEgg MSI PATH-registration contract, including every failure case.
.DESCRIPTION
- build-cli-msi.ps1 can only run its assertion while building a real MSI, which needs Windows and WiX.
- That makes the assertion itself unrehearsable: nobody finds out it is wrong until a release build
- either fails for the wrong reason or, worse, passes a package that overwrites the user's PATH.
+ A script can only run this assertion while building a real MSI, which needs Windows and WiX. That
+ makes the assertion itself unrehearsable: nobody finds out it is wrong until a release build either
+ fails for the wrong reason or, worse, passes a package that overwrites the user's PATH.
This gate drives the same rule directly with hand-written Environment-table rows and hard-asserts the
exact violation identifier each one produces, so a check that gets weakened or dropped fails here
@@ -19,13 +19,14 @@ Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..' '..')).Path
-. (Join-Path $repoRoot 'scripts/release/CliMsiPathContract.ps1')
+. (Join-Path $repoRoot 'scripts/release/MsiPathContract.ps1')
# Name/Value pairs exactly as WiX v3 emits them: Name = Action + uninstall + System + variable, and
# Part="last" rewrites Value to "[~]" + Separator + Value. The conforming row is what
# Action="set" Part="last" System="no" Permanent="no" on Name="PATH" Value="[INSTALLFOLDER]" produces.
$conformingName = '=-PATH'
$conformingValue = '[~];[INSTALLFOLDER]'
+$conformingToken = '[INSTALLFOLDER]'
$cases = @(
@{
@@ -85,62 +86,83 @@ $cases = @(
Expected = 'NotSetOnInstall'
}
@{
- Description = 'a row appending some directory other than the install folder'
+ Description = 'a row appending some directory other than the one the command lands in'
Name = $conformingName
Value = '[~];C:\Tools'
- Expected = 'MissingInstallFolder'
+ Expected = 'MissingDirectoryToken'
+ }
+ @{
+ # The desktop MSI's shape: the app installs into INSTALLFOLDER and the command into a `cli`
+ # subdirectory, so the row must name that subdirectory rather than the app's own folder.
+ Description = 'the row the desktop MSI emits for its cli subdirectory'
+ Name = $conformingName
+ Value = '[~];[CLIFOLDER]'
+ Token = '[CLIFOLDER]'
+ Expected = $null
+ }
+ @{
+ # Registering the app's own folder instead of the command's would put every DLL beside the app on
+ # PATH and still leave `salmon-egg` unresolvable.
+ Description = 'a desktop MSI row registering the app folder instead of the command folder'
+ Name = $conformingName
+ Value = $conformingValue
+ Token = '[CLIFOLDER]'
+ Expected = 'MissingDirectoryToken'
}
)
$failures = @()
foreach ($case in $cases) {
- $violation = Get-CliMsiPathContractViolation -Name $case.Name -Value $case.Value
+ # Only the desktop-MSI cases name a token; the rest exercise the CLI-only shape. ContainsKey rather
+ # than a null check on the property: Set-StrictMode rejects reading a key a hashtable does not have.
+ $token = if ($case.ContainsKey('Token')) { $case.Token } else { $conformingToken }
+ $violation = Get-MsiPathContractViolation -Name $case.Name -Value $case.Value -DirectoryToken $token
$actual = if ($null -eq $violation) { $null } else { $violation.Id }
if ($actual -ne $case.Expected) {
$expectedLabel = if ($null -eq $case.Expected) { '(conforming)' } else { $case.Expected }
$actualLabel = if ($null -eq $actual) { '(conforming)' } else { $actual }
$failures += ("$($case.Description): expected $expectedLabel but got $actualLabel " +
- "(Name='$($case.Name)' Value='$($case.Value)')")
+ "(Name='$($case.Name)' Value='$($case.Value)' Token='$token')")
continue
}
$outcome = if ($null -eq $actual) { 'conforms' } else { "rejected as $actual" }
- Write-Host "[cli-msi-gate] $($case.Description): $outcome"
+ Write-Host "[msi-path-gate] $($case.Description): $outcome"
}
-# Assert-CliMsiPathContract is what build-cli-msi.ps1 actually calls, so verify it converts a violation
+# Assert-MsiPathContract is what the MSI build scripts actually call, so verify it converts a violation
# into a throw and lets a conforming row through — a silent Get-* consumer would defeat the whole gate.
try {
- Assert-CliMsiPathContract -Name $conformingName -Value $conformingValue
+ Assert-MsiPathContract -Name $conformingName -Value $conformingValue -DirectoryToken $conformingToken
}
catch {
- $failures += "Assert-CliMsiPathContract threw for the conforming row: $($_.Exception.Message)"
+ $failures += "Assert-MsiPathContract threw for the conforming row: $($_.Exception.Message)"
}
$assertThrew = $false
try {
- Assert-CliMsiPathContract -Name $conformingName -Value '[INSTALLFOLDER]'
+ Assert-MsiPathContract -Name $conformingName -Value '[INSTALLFOLDER]' -DirectoryToken $conformingToken
}
catch {
$assertThrew = $true
if ($_.Exception.Message -notlike '*ReplacesExistingValue*') {
- $failures += ("Assert-CliMsiPathContract threw without naming the violation: " +
+ $failures += ("Assert-MsiPathContract threw without naming the violation: " +
"$($_.Exception.Message)")
}
}
if (-not $assertThrew) {
- $failures += 'Assert-CliMsiPathContract accepted a row that replaces PATH wholesale.'
+ $failures += 'Assert-MsiPathContract accepted a row that replaces PATH wholesale.'
}
if ($failures.Count -gt 0) {
Write-Host ''
foreach ($failure in $failures) {
- Write-Host "[cli-msi-gate] FAIL $failure"
+ Write-Host "[msi-path-gate] FAIL $failure"
}
- throw "CLI MSI PATH contract gate failed with $($failures.Count) violation(s)."
+ throw "MSI PATH contract gate failed with $($failures.Count) violation(s)."
}
-Write-Host "[cli-msi-gate] passed: $($cases.Count) contract cases plus 2 assertion-surface checks"
+Write-Host "[msi-path-gate] passed: $($cases.Count) contract cases plus 2 assertion-surface checks"
diff --git a/scripts/gates/run-msix-package-contract-gate.ps1 b/scripts/gates/run-msix-package-contract-gate.ps1
index 2e60df14a..37cd5b89c 100644
--- a/scripts/gates/run-msix-package-contract-gate.ps1
+++ b/scripts/gates/run-msix-package-contract-gate.ps1
@@ -15,6 +15,12 @@
the manifest references is present as a package entry. It also asserts identity and a three-part
numeric version so a manifest whose version token was never substituted cannot ship.
+ It asserts the same thing about the bundled CLI: the package registers exactly one app execution
+ alias, under the product's command name, entered as a full-trust process, pointing at an executable
+ the package really carries. That is the whole of "installing SalmonEgg puts salmon-egg on PATH" on
+ Windows, and each half of it fails silently — a package with the alias but no payload, or the payload
+ but no alias, installs and looks correct.
+
Pure zip + XML inspection: no Windows APIs, no MSIX tooling, runs on any platform with pwsh. That is
deliberate — the rule must be rehearsable off Windows, or it becomes another assertion nobody can
test until a tag build.
@@ -75,6 +81,16 @@ $script:AssetAttributeNames = @(
'Image'
)
+# The command the package puts on PATH. A packaged app cannot append to PATH -- the OS is the installer
+# and writes no environment variables on the app's behalf -- so Windows exposes the command through an
+# app execution alias, a stub it materializes under %LOCALAPPDATA%\Microsoft\WindowsApps, a directory
+# already on the per-user PATH. Three independent things have to hold for `salmon-egg` to work, and each
+# fails silently on its own: the alias name is what the user types, the entry point is what makes the
+# launch a classic full-trust process (a UWP activation loses the caller's console, arguments and exit
+# code), and the executable is a package-relative path that has to resolve to a file that is really there.
+$script:ExpectedExecutionAlias = 'salmon-egg.exe'
+$script:ExpectedAliasEntryPoint = 'Windows.FullTrustApplication'
+
# An asset path can be an attribute value (uap:VisualElements Square44x44Logo="...") or element text
# (Assets\...\iconLogo.png under Properties, which is how the real manifest declares the
# package logo). An attribute-only scan silently passes a package missing that logo — the self-test for
@@ -173,6 +189,66 @@ function Test-PackageCarriesAsset
return $false
}
+# Asserts the package both registers the bundled CLI as an app execution alias and carries the executable
+# that alias names. Windows validates only that the referenced path exists in the package; the alias name,
+# the entry point and whether the extension is declared at all are ours to check, and every one of them
+# fails as "salmon-egg: command not found" on a user's machine rather than at packaging time.
+function Get-ExecutionAliasViolation
+{
+ param(
+ [Parameter(Mandatory = $true)][xml]$Manifest,
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Entries
+ )
+
+ # Matched by local name: the manifest authors this as uap3:Extension, but uap5 and uap10 declare the
+ # same category, and a package moved to either of those must still satisfy the contract.
+ $extensions = @($Manifest.SelectNodes("//*[local-name()='Extension' and @Category='windows.appExecutionAlias']"))
+ if ($extensions.Count -eq 0)
+ {
+ return [pscustomobject]@{ Id = 'AppExecutionAliasMissing'; Detail = 'no windows.appExecutionAlias extension' }
+ }
+
+ # Windows allows one alias extension per Application. With more than one, which command the user ends
+ # up with is decided by registration order instead of by this manifest.
+ if ($extensions.Count -ne 1)
+ {
+ return [pscustomobject]@{ Id = 'AppExecutionAliasAmbiguous'; Detail = "$($extensions.Count) alias extensions" }
+ }
+
+ $extension = $extensions[0]
+ $entryPoint = $extension.GetAttribute('EntryPoint')
+ if ($entryPoint -cne $script:ExpectedAliasEntryPoint)
+ {
+ return [pscustomobject]@{ Id = 'AppExecutionAliasEntryPointMismatch'; Detail = $entryPoint }
+ }
+
+ $aliases = @($extension.SelectNodes(".//*[local-name()='ExecutionAlias']") | ForEach-Object { $_.GetAttribute('Alias') })
+ if ($aliases.Count -ne 1 -or $aliases[0] -ine $script:ExpectedExecutionAlias)
+ {
+ return [pscustomobject]@{ Id = 'AppExecutionAliasNameMismatch'; Detail = ($aliases -join ', ') }
+ }
+
+ $executable = $extension.GetAttribute('Executable')
+ if ([string]::IsNullOrWhiteSpace($executable))
+ {
+ return [pscustomobject]@{ Id = 'AppExecutionAliasTargetMissing'; Detail = '(no Executable attribute)' }
+ }
+
+ # The alias names a package-relative path with Windows separators while package entries use forward
+ # slashes. Exact match only: unlike a shell asset, an executable carries no resource qualifiers, so a
+ # name that merely resembles the target is a command that does not start.
+ $target = ConvertTo-PackagePath $executable
+ foreach ($entry in $Entries)
+ {
+ if ($entry -ieq $target)
+ {
+ return $null
+ }
+ }
+
+ return [pscustomobject]@{ Id = 'AppExecutionAliasTargetMissing'; Detail = $target }
+}
+
function Get-MsixContractViolation
{
param(
@@ -246,6 +322,12 @@ function Get-MsixContractViolation
return [pscustomobject]@{ Id = 'VersionPlaceholder'; Detail = $version }
}
+ $aliasViolation = Get-ExecutionAliasViolation -Manifest $manifest -Entries $entries
+ if ($null -ne $aliasViolation)
+ {
+ return $aliasViolation
+ }
+
$assetReferences = Get-ManifestAssetReference -Manifest $manifest
if ($assetReferences.Count -eq 0)
{
@@ -304,13 +386,38 @@ function New-TestManifest
[string]$IdentityName = 'SalmonEgg.SalmonEgg',
[string]$Publisher = 'CN=0B694F0E-510C-433A-A6F7-1484D6A39E19',
[string]$Version = '1.2.0.0',
- [string]$Logo = 'Assets\Icons\Windows\iconLogo.png'
+ [string]$Logo = 'Assets\Icons\Windows\iconLogo.png',
+ [string]$AliasExecutable = 'cli\salmon-egg.exe',
+ [string]$Alias = 'salmon-egg.exe',
+ [string]$AliasEntryPoint = 'Windows.FullTrustApplication',
+ [switch]$OmitAlias,
+ [switch]$DuplicateAlias
)
+ $extensionsXml = ''
+ if (-not $OmitAlias)
+ {
+ $aliasBlock = @"
+
+
+
+
+
+"@
+ $aliasBlocks = if ($DuplicateAlias) { "$aliasBlock`n$aliasBlock" } else { $aliasBlock }
+ $extensionsXml = @"
+
+$aliasBlocks
+
+"@
+ }
+
return @"
+ xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
+ xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
+ xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10">
$Logo
@@ -319,6 +426,7 @@ function New-TestManifest
+$extensionsXml
@@ -356,6 +464,7 @@ function Invoke-SelfTest
{
$allAssets = @{
'AppxManifest.xml' = New-TestManifest
+ 'cli/salmon-egg.exe' = 'MZ'
'Assets/Icons/Windows/iconLogo.png' = 'png'
'Assets/Icons/Windows/iconLogo44.png' = 'png'
'Assets/Icons/Windows/iconLogo150.png' = 'png'
@@ -367,6 +476,7 @@ function Invoke-SelfTest
# regression from returning.
$mrtAssets = @{
'AppxManifest.xml' = New-TestManifest
+ 'cli/salmon-egg.exe' = 'MZ'
'Assets/Icons/Windows/iconLogo.scale-200.png' = 'png'
'Assets/Icons/Windows/iconLogo.targetsize-32.png' = 'png'
'Assets/Icons/Windows/iconLogo44.scale-200.png' = 'png'
@@ -391,6 +501,7 @@ function Invoke-SelfTest
Description = 'a family missing one variant but otherwise complete'
Entries = @{
'AppxManifest.xml' = New-TestManifest
+ 'cli/salmon-egg.exe' = 'MZ'
'Assets/Icons/Windows/iconLogo.scale-200.png' = 'png'
'Assets/Icons/Windows/iconLogo44.scale-100.png' = 'png'
'Assets/Icons/Windows/iconLogo150.scale-400.png' = 'png'
@@ -403,6 +514,7 @@ function Invoke-SelfTest
Description = 'a package whose entire qualified logo family was dropped'
Entries = @{
'AppxManifest.xml' = New-TestManifest
+ 'cli/salmon-egg.exe' = 'MZ'
'Assets/Icons/Windows/iconLogo.scale-200.png' = 'png'
'Assets/Icons/Windows/iconLogo150.scale-400.png' = 'png'
}
@@ -414,6 +526,7 @@ function Invoke-SelfTest
Description = 'a package where a similarly-named asset stands in for a missing one'
Entries = @{
'AppxManifest.xml' = New-TestManifest
+ 'cli/salmon-egg.exe' = 'MZ'
'Assets/Icons/Windows/iconLogo150.scale-200.png' = 'png'
'Assets/Icons/Windows/iconLogo44.scale-200.png' = 'png'
}
@@ -425,6 +538,7 @@ function Invoke-SelfTest
Description = 'a package whose declared shell logo was never included'
Entries = @{
'AppxManifest.xml' = New-TestManifest
+ 'cli/salmon-egg.exe' = 'MZ'
'Assets/Icons/Windows/iconLogo44.png' = 'png'
'Assets/Icons/Windows/iconLogo150.png' = 'png'
}
@@ -434,11 +548,57 @@ function Invoke-SelfTest
Description = 'a package whose VisualElements tile logo was never included'
Entries = @{
'AppxManifest.xml' = New-TestManifest
+ 'cli/salmon-egg.exe' = 'MZ'
'Assets/Icons/Windows/iconLogo.png' = 'png'
'Assets/Icons/Windows/iconLogo44.png' = 'png'
}
Expected = 'DeclaredAssetMissing'
}
+ @{
+ # The alias resolves a package-relative path, so registering the command while the publish
+ # never carried the binary produces an alias that starts nothing.
+ Description = 'a package registering the alias whose CLI payload was never included'
+ Entries = @{
+ 'AppxManifest.xml' = New-TestManifest
+ 'Assets/Icons/Windows/iconLogo.png' = 'png'
+ 'Assets/Icons/Windows/iconLogo44.png' = 'png'
+ 'Assets/Icons/Windows/iconLogo150.png' = 'png'
+ }
+ Expected = 'AppExecutionAliasTargetMissing'
+ }
+ @{
+ # Shipping the binary without the alias is the other half of the same defect: the package
+ # is complete and the command is still not on PATH.
+ Description = 'a package carrying the CLI but registering no alias for it'
+ Entries = (Merge-TestEntries -Base $allAssets -Override @{ 'AppxManifest.xml' = New-TestManifest -OmitAlias })
+ Expected = 'AppExecutionAliasMissing'
+ }
+ @{
+ Description = 'a package whose alias name drifted from the product command'
+ Entries = (Merge-TestEntries -Base $allAssets -Override @{ 'AppxManifest.xml' = New-TestManifest -Alias 'salmonegg.exe' })
+ Expected = 'AppExecutionAliasNameMismatch'
+ }
+ @{
+ # Without Windows.FullTrustApplication the alias becomes a UWP activation, which drops the
+ # caller's console, arguments and exit code: a command that appears to do nothing.
+ Description = 'a package whose alias activates the app instead of launching the command'
+ Entries = (Merge-TestEntries -Base $allAssets -Override @{ 'AppxManifest.xml' = New-TestManifest -AliasEntryPoint 'SalmonEgg.App' })
+ Expected = 'AppExecutionAliasEntryPointMismatch'
+ }
+ @{
+ # Two extensions mean registration order, not this manifest, decides which command the
+ # user ends up with.
+ Description = 'a package registering two alias extensions'
+ Entries = (Merge-TestEntries -Base $allAssets -Override @{ 'AppxManifest.xml' = New-TestManifest -DuplicateAlias })
+ Expected = 'AppExecutionAliasAmbiguous'
+ }
+ @{
+ # The content item's TargetPath and the manifest's Executable have to agree; a payload
+ # present under some other path packages cleanly and then fails to launch.
+ Description = 'a package whose alias path disagrees with where the CLI was placed'
+ Entries = (Merge-TestEntries -Base $allAssets -Override @{ 'AppxManifest.xml' = New-TestManifest -AliasExecutable 'salmon-egg.exe' })
+ Expected = 'AppExecutionAliasTargetMissing'
+ }
@{
Description = 'a package whose version token was never substituted'
Entries = (Merge-TestEntries -Base $allAssets -Override @{ 'AppxManifest.xml' = New-TestManifest -Version '__SALMONEGG_PACKAGE_VERSION__' })
@@ -543,4 +703,4 @@ if ($null -ne $violation)
throw "MSIX package contract violated [$($violation.Id)]: $($violation.Detail) (package: $Package)"
}
-Write-Host "[msix-gate] passed: $Package satisfies identity, version, and declared-asset presence"
+Write-Host "[msix-gate] passed: $Package satisfies identity, version, declared-asset presence, and the bundled CLI's execution alias"
diff --git a/scripts/gates/run-release-artifact-contract-gate.sh b/scripts/gates/run-release-artifact-contract-gate.sh
index 928b85b52..875d57732 100755
--- a/scripts/gates/run-release-artifact-contract-gate.sh
+++ b/scripts/gates/run-release-artifact-contract-gate.sh
@@ -3,7 +3,7 @@
# Asserts that a locally produced release artifact actually contains what it must, before it is uploaded.
#
# The CLI already holds this standard: run-cli-release-artifact-smoke.sh executes the binary it just
-# built, and run-cli-linux-package-smoke.sh installs the .deb it just built. The other artifacts had no
+# built, and run-desktop-linux-package-smoke.sh installs the .deb it just built. The other artifacts had no
# equivalent — a successful `dotnet publish` was the entire evidence that the WASM bundle or the macOS
# app bundle was usable. Those two shapes can fail in ways a green build never reveals:
#
@@ -130,7 +130,10 @@ except Exception:
value = data.get(key)
if isinstance(value, str) and value:
- print(value)
+ # Raw bytes, not print(): a Windows interpreter translates text-mode newlines to \r\n,
+ # and the command substitution that captures this output strips only the \n -- leaving a
+ # trailing \r on the executable name, which then never matches the file on disk.
+ sys.stdout.buffer.write(value.encode("utf-8"))
' "$plist" "$key"
}
@@ -161,7 +164,27 @@ verify_macos_bundle() {
identifier="$(read_plist_string "$plist" CFBundleIdentifier)"
[ -n "$identifier" ] || { fail "Info.plist declares no CFBundleIdentifier"; return 1; }
- echo "[artifact-gate] macos-bundle: $(basename "$app") declares $identifier, executable '$executable' present"
+ # The bundled command. Installing SalmonEgg installs salmon-egg, and on macOS the only thing that puts it
+ # on PATH is the pkg's postinstall symlinking it -- so a bundle without it produces an installer that
+ # copies the app and then fails, or a .dmg whose command silently does not exist.
+ #
+ # Both bundle areas are accepted, in the same order the postinstall probes them: Uno's GenerateAppBundle
+ # sends the apphost and .dylib files to Contents/MacOS and everything else to Contents/Resources with
+ # relative paths intact, and a cli/ subdirectory holding one extension-less Mach-O matches neither pattern
+ # exactly. The area is reported so the first real bundle settles the question.
+ local command_path="" area=""
+ for area in MacOS Resources; do
+ if [ -f "$app/Contents/$area/cli/salmon-egg" ]; then
+ command_path="$app/Contents/$area/cli/salmon-egg"
+ break
+ fi
+ done
+ [ -n "$command_path" ] || { fail "the bundle carries no salmon-egg under Contents/MacOS/cli or Contents/Resources/cli"; return 1; }
+ # Executability, not just presence: the postinstall tests -x and refuses otherwise, so a mode bit dropped
+ # by a copy would fail the install rather than this gate.
+ [ -x "$command_path" ] || { fail "$command_path is present but not executable"; return 1; }
+
+ echo "[artifact-gate] macos-bundle: $(basename "$app") declares $identifier, executable '$executable' present, bundled salmon-egg under Contents/$area/cli"
}
# --- self-test ----------------------------------------------------------------------------------------
@@ -178,14 +201,15 @@ run_self_test() {
expect() {
local description="$1" expected="$2"
shift 2
- if "$@" >/dev/null 2>&1; then
- local actual="pass"
- else
- local actual="fail"
- fi
+ # The check's own output is hidden on the happy path, but shown when a case turns out
+ # unexpectedly: a verdict alone ("expected pass but got fail") cannot be diagnosed from a
+ # CI log, and this self-test exists so the gate never needs a debugger to fix.
+ local output
+ output="$("$@" 2>&1)" && actual="pass" || actual="fail"
if [ "$actual" != "$expected" ]; then
echo "[artifact-gate] FAIL self-test: $description expected $expected but got $actual"
+ [ -n "$output" ] && printf '%s\n' "$output" | sed 's/^/[artifact-gate] /'
failures=$((failures + 1))
else
echo "[artifact-gate] self-test: $description -> $actual (as intended)"
@@ -212,7 +236,7 @@ run_self_test() {
# Written as a real binary plist, which is the form the packaging tool emits.
make_good_bundle() {
- local app="$1" exe="${2:-SalmonEgg}"
+ local app="$1" exe="${2:-SalmonEgg}" area="${3:-MacOS}"
mkdir -p "$app/Contents/MacOS"
"$PYTHON_BIN" -c '
import plistlib
@@ -227,6 +251,14 @@ with open(path, "wb") as handle:
)
' "$app/Contents/Info.plist" "$exe"
printf 'bin' > "$app/Contents/MacOS/$exe"
+ # The bundled command, which a conforming release bundle carries and the pkg's postinstall links.
+ # Executability has two host spellings and this self-test rehearses on all of them: a POSIX host
+ # reads the mode bit set below, while a Windows host under Git Bash has no mode bits for chmod to
+ # set -- there test -x falls back to the file's first bytes, accepting '#!' (cygwin path.h
+ # has_exec_chars). Writing both satisfies either host without branching on which one is running.
+ mkdir -p "$app/Contents/$area/cli"
+ printf '#!/bin/sh\nbin' > "$app/Contents/$area/cli/salmon-egg"
+ chmod +x "$app/Contents/$area/cli/salmon-egg"
}
write_partial_plist() {
@@ -325,8 +357,11 @@ with open(path, "wb") as handle:
# macos: an XML plist must also be readable -- the format is an implementation detail of the toolchain
# and could change back.
- mkdir -p "$work/XmlPlist.app/Contents/MacOS"
+ mkdir -p "$work/XmlPlist.app/Contents/MacOS/cli"
printf 'bin' > "$work/XmlPlist.app/Contents/MacOS/SalmonEgg"
+ # Same shebang as the fixture above: on a mode-bit-less host the content is what test -x reads.
+ printf '#!/bin/sh\nbin' > "$work/XmlPlist.app/Contents/MacOS/cli/salmon-egg"
+ chmod +x "$work/XmlPlist.app/Contents/MacOS/cli/salmon-egg"
cat > "$work/XmlPlist.app/Contents/Info.plist" <<'PLIST'
@@ -346,6 +381,26 @@ PLIST
printf 'not a plist' > "$work/Garbage.app/Contents/Info.plist"
expect "a bundle whose Info.plist is unreadable" fail verify_macos_bundle "$work/Garbage.app"
+ # macos: the same bundle with the command in the other area Uno might choose. Rejecting this would make
+ # the gate fail on a bundle that installs correctly.
+ make_good_bundle "$work/ResourcesCli.app" SalmonEgg Resources
+ expect "a bundle carrying salmon-egg under Contents/Resources" pass verify_macos_bundle "$work/ResourcesCli.app"
+
+ # macos: the app is complete but the bundled command was never embedded. The .dmg would install an app
+ # whose `salmon-egg` does not exist, and the .pkg's postinstall would fail after copying it.
+ make_good_bundle "$work/NoCli.app"
+ rm -rf "$work/NoCli.app/Contents/MacOS/cli" "$work/NoCli.app/Contents/Resources/cli"
+ expect "a bundle carrying no bundled salmon-egg" fail verify_macos_bundle "$work/NoCli.app"
+
+ # macos: present but not executable, which is what a copy through a filesystem that drops the mode bit
+ # produces. The postinstall tests -x, so the installer would refuse it.
+ make_good_bundle "$work/UnexecutableCli.app"
+ chmod -x "$work/UnexecutableCli.app/Contents/MacOS/cli/salmon-egg"
+ # That chmod is a no-op on a host without mode bits, so there the file must also drop the shebang
+ # that made its content executable. On POSIX the rewrite cannot resurrect the cleared mode bit.
+ printf 'bin' > "$work/UnexecutableCli.app/Contents/MacOS/cli/salmon-egg"
+ expect "a bundle whose salmon-egg is not executable" fail verify_macos_bundle "$work/UnexecutableCli.app"
+
# macos: a plain directory that is not a bundle
mkdir -p "$work/not-a-bundle"
expect "a directory that is not a .app" fail verify_macos_bundle "$work/not-a-bundle"
diff --git a/scripts/release/DesktopMsiContract.ps1 b/scripts/release/DesktopMsiContract.ps1
index 3f565adb6..597dc8cc4 100644
--- a/scripts/release/DesktopMsiContract.ps1
+++ b/scripts/release/DesktopMsiContract.ps1
@@ -25,6 +25,15 @@
Set-StrictMode -Version Latest
+# The PATH-row encoding is the same contract the CLI-only MSI enforced, so it stays in one place rather
+# than being restated here. Only the directory differs: this package installs the app into INSTALLFOLDER
+# and the command into a `cli` subdirectory, so the row must name that subdirectory.
+. (Join-Path $PSScriptRoot 'MsiPathContract.ps1')
+
+$script:DesktopMsiCommandDirectoryToken = '[CLIFOLDER]'
+$script:DesktopMsiAppExecutableName = 'SalmonEgg.exe'
+$script:DesktopMsiCommandExecutableName = 'salmon-egg.exe'
+
# Aggregate functions, GROUP BY, JOIN, ORDER BY, DISTINCT and LIKE are all absent from Windows Installer's
# SQL dialect: it fails inside OpenView rather than returning a wrong answer. The grammar's WHERE clause
# allows only column-to-column comparison, {column} {comparator} {constant}, IS [NOT] NULL -- and for
@@ -147,6 +156,42 @@ function Get-MsiColumn
return , $values.ToArray()
}
+function Get-MsiRowPair
+{
+ <#
+ .SYNOPSIS
+ Returns every row of a two-column query as Name/Value pairs.
+
+ .DESCRIPTION
+ The Environment table has to be read as pairs, not as two independent columns: which value belongs
+ to which variable is the whole point, and two Get-MsiColumn calls would silently pair row 1's name
+ with row 1's value only for as long as the table has one row.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ $Database,
+
+ [Parameter(Mandatory = $true)]
+ [string]$Query
+ )
+
+ Assert-MsiQuerySupported -Query $Query
+
+ $view = $Database.OpenView($Query)
+ [void]$view.Execute()
+
+ $rows = [System.Collections.Generic.List[psobject]]::new()
+ while ($null -ne ($record = $view.Fetch()))
+ {
+ $rows.Add([pscustomobject]@{ Name = $record.StringData(1); Value = $record.StringData(2) })
+ }
+
+ # Unary comma for the same reason as Get-MsiColumn: an empty result is the violation this exists to
+ # catch, and PowerShell would unroll it to $null.
+ return , $rows.ToArray()
+}
+
function Measure-MsiRows
{
<#
@@ -180,16 +225,19 @@ function Measure-MsiRows
return $count
}
-function Get-MsiAppExecutableName
+function Find-MsiFileName
{
<#
.SYNOPSIS
- Returns the harvested name of the app executable, or $null when the package does not carry it.
+ Returns the harvested cell naming the wanted file, or $null when the package does not carry it.
.DESCRIPTION
The File table's FileName column holds either a long name or the pair 'SHORTNA~1.EXE|LongName.exe'.
Both halves are checked, because which form heat emits depends on whether the name needs a 8.3
alias -- and a rule that only understood one form would pass or fail for the wrong reason.
+
+ Two files matter to this package: the app executable it exists to deliver, and the command it puts
+ on PATH. Taking the wanted name as a parameter is what lets one rule cover both.
#>
[CmdletBinding()]
param(
@@ -200,7 +248,10 @@ function Get-MsiAppExecutableName
[Parameter(Mandatory = $true)]
[AllowEmptyCollection()]
[AllowEmptyString()]
- [string[]]$FileNames
+ [string[]]$FileNames,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ExpectedName
)
foreach ($fileName in $FileNames)
@@ -209,7 +260,7 @@ function Get-MsiAppExecutableName
foreach ($candidate in $fileName.Split('|'))
{
- if ($candidate -eq 'SalmonEgg.exe') { return $fileName }
+ if ($candidate -eq $ExpectedName) { return $fileName }
}
}
@@ -250,6 +301,31 @@ function Write-MsiFileTableShape
}
}
+function Write-MsiEnvironmentTableShape
+{
+ <#
+ .SYNOPSIS
+ Reports the PATH rows the contract is about to judge.
+
+ .DESCRIPTION
+ Same reason as Write-MsiFileTableShape: the encoding of an Environment row is dense enough that
+ "the contract was violated" is not actionable without seeing the row. Prefix characters and the
+ null marker are invisible in a WiX source file, because WiX composes both columns itself.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyCollection()]
+ [psobject[]]$Rows
+ )
+
+ Write-Host "[desktop-msi] Environment table: $($Rows.Count) row(s)"
+ foreach ($row in $Rows)
+ {
+ Write-Host "[desktop-msi] Name='$($row.Name)' Value='$($row.Value)'"
+ }
+}
+
function Get-DesktopMsiContractViolation
{
<#
@@ -279,10 +355,24 @@ function Get-DesktopMsiContractViolation
}
}
- $appExe = Get-MsiAppExecutableName -FileNames $fileNames
+ $appExe = Find-MsiFileName -FileNames $fileNames -ExpectedName $script:DesktopMsiAppExecutableName
if ([string]::IsNullOrWhiteSpace($appExe))
{
- return [pscustomobject]@{ Id = 'MissingAppExe'; Detail = "$fileCount file rows, none of them SalmonEgg.exe" }
+ return [pscustomobject]@{
+ Id = 'MissingAppExe'
+ Detail = "$fileCount file rows, none of them $($script:DesktopMsiAppExecutableName)"
+ }
+ }
+
+ # Installing the app installs the command. A package that lost the CLI still installs a working app,
+ # and the PATH entry below would then point at a directory with nothing in it.
+ $commandExe = Find-MsiFileName -FileNames $fileNames -ExpectedName $script:DesktopMsiCommandExecutableName
+ if ([string]::IsNullOrWhiteSpace($commandExe))
+ {
+ return [pscustomobject]@{
+ Id = 'MissingCommandExe'
+ Detail = "$fileCount file rows, none of them $($script:DesktopMsiCommandExecutableName)"
+ }
}
$version = Get-MsiScalar -Database $Database `
@@ -292,6 +382,36 @@ function Get-DesktopMsiContractViolation
return [pscustomobject]@{ Id = 'InvalidVersion'; Detail = "ProductVersion '$version'" }
}
+ # Shipping the command without registering it leaves the user with a file they cannot invoke, which is
+ # indistinguishable from a working install until they type the command.
+ $environmentRows = Get-MsiRowPair -Database $Database -Query 'SELECT `Name`, `Value` FROM `Environment`'
+ if ($environmentRows.Count -eq 0)
+ {
+ return [pscustomobject]@{
+ Id = 'NoPathRegistration'
+ Detail = 'the Environment table is empty, so nothing puts the command on PATH'
+ }
+ }
+
+ # More than one row means a second, unreviewed environment write reached the package -- a machine PATH
+ # entry, say. Windows Installer would apply both.
+ if ($environmentRows.Count -ne 1)
+ {
+ $described = ($environmentRows | ForEach-Object { "Name='$($_.Name)' Value='$($_.Value)'" }) -join '; '
+ return [pscustomobject]@{ Id = 'MultiplePathRegistrations'; Detail = $described }
+ }
+
+ # The row's own encoding is judged by the shared rule, and its identifiers are passed through so a
+ # failure names the specific defect (prepending, machine scope, permanence) rather than "bad PATH row".
+ $pathViolation = Get-MsiPathContractViolation `
+ -Name $environmentRows[0].Name `
+ -Value $environmentRows[0].Value `
+ -DirectoryToken $script:DesktopMsiCommandDirectoryToken
+ if ($null -ne $pathViolation)
+ {
+ return [pscustomobject]@{ Id = $pathViolation.Id; Detail = $pathViolation.Message }
+ }
+
return $null
}
@@ -311,6 +431,7 @@ function Assert-DesktopMsiContract
# reading, so a log that only ever shows the verdict leaves the next surprise undiagnosable.
$fileNames = Get-MsiColumn -Database $Database -Query 'SELECT `FileName` FROM `File`'
Write-MsiFileTableShape -FileNames $fileNames
+ Write-MsiEnvironmentTableShape -Rows (Get-MsiRowPair -Database $Database -Query 'SELECT `Name`, `Value` FROM `Environment`')
$violation = Get-DesktopMsiContractViolation -Database $Database
if ($null -ne $violation)
@@ -320,7 +441,10 @@ function Assert-DesktopMsiContract
$version = Get-MsiScalar -Database $Database `
-Query "SELECT ``Value`` FROM ``Property`` WHERE ``Property``='ProductVersion'"
- $appExe = Get-MsiAppExecutableName -FileNames $fileNames
+ $appExe = Find-MsiFileName -FileNames $fileNames -ExpectedName $script:DesktopMsiAppExecutableName
+ $commandExe = Find-MsiFileName -FileNames $fileNames -ExpectedName $script:DesktopMsiCommandExecutableName
- Write-Host "[desktop-msi] verified: $($fileNames.Count) file row(s), ProductVersion $version, $appExe present"
+ Write-Host ("[desktop-msi] verified: $($fileNames.Count) file row(s), ProductVersion $version, " +
+ "$appExe and $commandExe present, one conforming PATH row for " +
+ "$($script:DesktopMsiCommandDirectoryToken)")
}
diff --git a/scripts/release/CliMsiPathContract.ps1 b/scripts/release/MsiPathContract.ps1
similarity index 61%
rename from scripts/release/CliMsiPathContract.ps1
rename to scripts/release/MsiPathContract.ps1
index 4d73efa0a..dc2e19115 100644
--- a/scripts/release/CliMsiPathContract.ps1
+++ b/scripts/release/MsiPathContract.ps1
@@ -1,14 +1,19 @@
#requires -Version 7.0
<#
.SYNOPSIS
- The PATH-registration contract that a built SalmonEgg CLI MSI must satisfy.
+ The PATH-registration contract a built SalmonEgg MSI must satisfy.
.DESCRIPTION
- This lives apart from build-cli-msi.ps1 because reading an MSI needs Windows and WiX, while the rule
- being enforced is pure string logic over one Environment-table row. Splitting them lets
- scripts/gates/run-cli-msi-path-contract-gate.ps1 exercise the rule — including every failure case — on
+ This lives apart from the scripts that build MSIs because reading an MSI needs Windows and WiX, while
+ the rule being enforced is pure string logic over one Environment-table row. Splitting them lets
+ scripts/gates/run-msi-path-contract-gate.ps1 exercise the rule — including every failure case — on
any platform, instead of the rule only ever running inside a release build nobody can rehearse.
+ The rule is not specific to one package: the directory whose token must appear in the value is a
+ parameter, because the desktop MSI installs the command into a `cli` subdirectory of the app while the
+ CLI-only MSI made its install folder the command directory itself. Everything else — which variable,
+ which prefix characters, where the null marker sits — is the same contract either way.
+
Encoding per the MSI Environment table reference:
https://learn.microsoft.com/windows/win32/msi/environment-table
- Name carries prefix characters, and the reference states there is "no effect in the ordering of
@@ -29,11 +34,10 @@
Set-StrictMode -Version Latest
# Prefix characters Windows Installer recognises on an Environment row's Name.
-$script:CliMsiPathNamePrefixCharacters = [char[]]@('=', '+', '-', '!', '*')
+$script:MsiPathNamePrefixCharacters = [char[]]@('=', '+', '-', '!', '*')
-$script:CliMsiPathVariable = 'PATH'
-$script:CliMsiPathNullMarker = '[~]'
-$script:CliMsiPathInstallFolderToken = '[INSTALLFOLDER]'
+$script:MsiPathVariable = 'PATH'
+$script:MsiPathNullMarker = '[~]'
function Split-MsiEnvironmentName
{
@@ -49,7 +53,7 @@ function Split-MsiEnvironmentName
$prefixLength = 0
while ($prefixLength -lt $Name.Length -and
- $script:CliMsiPathNamePrefixCharacters -contains $Name[$prefixLength])
+ $script:MsiPathNamePrefixCharacters -contains $Name[$prefixLength])
{
$prefixLength++
}
@@ -60,7 +64,7 @@ function Split-MsiEnvironmentName
}
}
-function New-CliMsiPathContractViolation
+function New-MsiPathContractViolation
{
[CmdletBinding()]
[OutputType([psobject])]
@@ -72,7 +76,7 @@ function New-CliMsiPathContractViolation
return [pscustomobject]@{ Id = $Id; Message = $Message }
}
-function Get-CliMsiPathContractViolation
+function Get-MsiPathContractViolation
{
<#
.SYNOPSIS
@@ -86,7 +90,11 @@ function Get-CliMsiPathContractViolation
[OutputType([psobject])]
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Name,
- [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value,
+ # The directory the command lives in, as the MSI formats it: '[INSTALLFOLDER]' when the install
+ # folder is itself the command directory, '[CLIFOLDER]' when the command sits in a subdirectory of
+ # a larger package. Passing it in is what keeps this rule usable by more than one package.
+ [Parameter(Mandatory = $true)][string]$DirectoryToken
)
$parsed = Split-MsiEnvironmentName -Name $Name
@@ -94,66 +102,66 @@ function Get-CliMsiPathContractViolation
$variable = $parsed.Variable
# Windows environment variable names are case-insensitive, so 'Path' is the same variable as 'PATH'.
- if ($variable -ine $script:CliMsiPathVariable)
+ if ($variable -ine $script:MsiPathVariable)
{
- return New-CliMsiPathContractViolation 'UnexpectedVariable' (
- "the row targets '$variable' rather than $($script:CliMsiPathVariable), so the CLI would not " +
+ return New-MsiPathContractViolation 'UnexpectedVariable' (
+ "the row targets '$variable' rather than $($script:MsiPathVariable), so the CLI would not " +
'become discoverable.')
}
if ($prefix.Contains('*'))
{
- return New-CliMsiPathContractViolation 'MachineEnvironment' (
+ return New-MsiPathContractViolation 'MachineEnvironment' (
'the row targets the machine environment, but this is a per-user package installed with ' +
'limited privileges; the write would fail or affect every account on the machine.')
}
if ($prefix.Contains('!'))
{
- return New-CliMsiPathContractViolation 'RemovedOnInstall' (
+ return New-MsiPathContractViolation 'RemovedOnInstall' (
'the row removes the variable during installation instead of setting it.')
}
if (-not $prefix.Contains('='))
{
- return New-CliMsiPathContractViolation 'NotSetOnInstall' (
+ return New-MsiPathContractViolation 'NotSetOnInstall' (
'the row is not set on install, so the install folder would never reach PATH.')
}
if (-not $prefix.Contains('-'))
{
- return New-CliMsiPathContractViolation 'NotRemovedOnUninstall' (
+ return New-MsiPathContractViolation 'NotRemovedOnUninstall' (
'the row is permanent, so uninstalling would leave the install folder on PATH forever.')
}
- if (-not $Value.Contains($script:CliMsiPathNullMarker))
+ if (-not $Value.Contains($script:MsiPathNullMarker))
{
- return New-CliMsiPathContractViolation 'ReplacesExistingValue' (
- "the value carries no $($script:CliMsiPathNullMarker) marker, so it replaces PATH wholesale " +
+ return New-MsiPathContractViolation 'ReplacesExistingValue' (
+ "the value carries no $($script:MsiPathNullMarker) marker, so it replaces PATH wholesale " +
'rather than extending it — the reference warns this can leave a machine unbootable.')
}
# Leading marker plus separator is the documented append form. The trailing form prepends, which would
# let this install folder shadow every tool the user already has on PATH.
- if (-not $Value.StartsWith($script:CliMsiPathNullMarker, [System.StringComparison]::Ordinal) -or
- $Value.Length -le $script:CliMsiPathNullMarker.Length)
+ if (-not $Value.StartsWith($script:MsiPathNullMarker, [System.StringComparison]::Ordinal) -or
+ $Value.Length -le $script:MsiPathNullMarker.Length)
{
- return New-CliMsiPathContractViolation 'NotAppended' (
- "the value does not lead with $($script:CliMsiPathNullMarker) plus a separator, so it does " +
+ return New-MsiPathContractViolation 'NotAppended' (
+ "the value does not lead with $($script:MsiPathNullMarker) plus a separator, so it does " +
'not append the install folder to the end of the existing PATH.')
}
- if (-not $Value.Contains($script:CliMsiPathInstallFolderToken))
+ if (-not $Value.Contains($DirectoryToken))
{
- return New-CliMsiPathContractViolation 'MissingInstallFolder' (
- "the value does not reference $($script:CliMsiPathInstallFolderToken), so whatever it adds to " +
- 'PATH is not the directory this package installs into.')
+ return New-MsiPathContractViolation 'MissingDirectoryToken' (
+ "the value does not reference $DirectoryToken, so whatever it adds to PATH is not the " +
+ 'directory this package installs the command into.')
}
return $null
}
-function Assert-CliMsiPathContract
+function Assert-MsiPathContract
{
<#
.SYNOPSIS
@@ -162,10 +170,11 @@ function Assert-CliMsiPathContract
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Name,
- [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value,
+ [Parameter(Mandatory = $true)][string]$DirectoryToken
)
- $violation = Get-CliMsiPathContractViolation -Name $Name -Value $Value
+ $violation = Get-MsiPathContractViolation -Name $Name -Value $Value -DirectoryToken $DirectoryToken
if ($null -ne $violation)
{
throw ("MSI PATH registration contract violated [$($violation.Id)]: $($violation.Message) " +
diff --git a/scripts/release/build-cli-deb.sh b/scripts/release/build-cli-deb.sh
deleted file mode 100755
index 8fc692b07..000000000
--- a/scripts/release/build-cli-deb.sh
+++ /dev/null
@@ -1,130 +0,0 @@
-#!/usr/bin/env bash
-# Builds a Debian package that installs the SalmonEgg CLI to /usr/bin/salmon-egg.
-#
-# PATH ownership: /usr/bin is already on every login PATH, so the package registers the command by
-# placing the binary there and dpkg removes it on purge. Nothing edits .bashrc, .zshrc or any user PATH
-# variable — an installer-managed file is reversible, an edited shell profile is not.
-set -euo pipefail
-
-REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
-DOTNET_BIN="${DOTNET_BIN:-dotnet}"
-
-EXECUTABLE=""
-VERSION=""
-ARCHITECTURE="amd64"
-OUTPUT_DIR="$REPO_ROOT/artifacts/cli"
-
-usage() {
- cat <<'USAGE'
-Usage: build-cli-deb.sh --executable [options]
-
-Options:
- --executable Published single-file salmon-egg binary.
- --version Package version. Default: the repository display version.
- --architecture Debian architecture. Default: amd64.
- --output Output directory. Default: artifacts/cli.
- -h, --help Show this help.
-USAGE
-}
-
-while [ "$#" -gt 0 ]; do
- case "$1" in
- --executable) EXECUTABLE="${2:?--executable requires a value}"; shift 2 ;;
- --executable=*) EXECUTABLE="${1#*=}"; shift ;;
- --version) VERSION="${2:?--version requires a value}"; shift 2 ;;
- --version=*) VERSION="${1#*=}"; shift ;;
- --architecture) ARCHITECTURE="${2:?--architecture requires a value}"; shift 2 ;;
- --architecture=*) ARCHITECTURE="${1#*=}"; shift ;;
- --output) OUTPUT_DIR="${2:?--output requires a value}"; shift 2 ;;
- --output=*) OUTPUT_DIR="${1#*=}"; shift ;;
- -h|--help) usage; exit 0 ;;
- *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;;
- esac
-done
-
-if [ -z "$EXECUTABLE" ]; then
- echo "--executable is required." >&2
- usage >&2
- exit 2
-fi
-
-if [ ! -f "$EXECUTABLE" ]; then
- echo "Executable not found: $EXECUTABLE" >&2
- exit 1
-fi
-
-if ! command -v dpkg-deb >/dev/null 2>&1; then
- echo "dpkg-deb is required to build the Debian package." >&2
- exit 1
-fi
-
-if [ -z "$VERSION" ]; then
- # -t:MinVer runs the MinVer target so the property holds the tag-derived version, not a default.
- VERSION="$("$DOTNET_BIN" msbuild "$REPO_ROOT/src/SalmonEgg.Cli/SalmonEgg.Cli.csproj" \
- -restore -t:MinVer -getProperty:SalmonEggDisplayVersion -nologo | tr -d '\r' | tail -n 1)"
-fi
-
-case "$VERSION" in
- [0-9]*.[0-9]*.[0-9]*) ;;
- *) echo "Package version must be a three-part numeric version, got: '$VERSION'" >&2; exit 1 ;;
-esac
-
-STAGING_DIR="$REPO_ROOT/artifacts/cli-deb/$ARCHITECTURE"
-rm -rf "$STAGING_DIR"
-mkdir -p "$STAGING_DIR/DEBIAN" \
- "$STAGING_DIR/usr/bin" \
- "$STAGING_DIR/usr/share/doc/salmon-egg-cli" \
- "$OUTPUT_DIR"
-
-install -m 0755 "$EXECUTABLE" "$STAGING_DIR/usr/bin/salmon-egg"
-
-INSTALLED_SIZE_KB="$(du -sk "$STAGING_DIR/usr" | cut -f1)"
-
-cat > "$STAGING_DIR/DEBIAN/control" <
-Installed-Size: $INSTALLED_SIZE_KB
-Homepage: https://github.com/salmonloop/salmon-egg
-Description: Salmon Egg configuration management CLI
- Command-line tool for managing Salmon Egg ACP server configurations and
- credentials. Ships as a self-contained build, so no .NET runtime is required.
- Credentials are stored through the Secret Service; when it is unavailable the
- write fails rather than downgrading to plaintext, unless the operator passes
- --allow-insecure-storage.
-EOF
-
-if [ -f "$REPO_ROOT/LICENSE" ]; then
- install -m 0644 "$REPO_ROOT/LICENSE" "$STAGING_DIR/usr/share/doc/salmon-egg-cli/copyright"
-fi
-
-# dpkg refuses to install a package whose files are not owned by root, and the release runner is not
-# root. fakeroot is what dpkg-deb's own documentation recommends for exactly this case.
-DEB_PATH="$OUTPUT_DIR/salmon-egg-cli_${VERSION}_${ARCHITECTURE}.deb"
-rm -f "$DEB_PATH" "$DEB_PATH.sha256"
-if command -v fakeroot >/dev/null 2>&1; then
- fakeroot dpkg-deb --build --root-owner-group "$STAGING_DIR" "$DEB_PATH" >/dev/null
-else
- dpkg-deb --build --root-owner-group "$STAGING_DIR" "$DEB_PATH" >/dev/null
-fi
-
-# macOS ships `shasum` rather than GNU `sha256sum`; both emit the same "" sidecar format.
-(
- cd "$OUTPUT_DIR"
- deb_file="$(basename "$DEB_PATH")"
- if command -v sha256sum >/dev/null 2>&1; then
- sha256sum "$deb_file" > "$deb_file.sha256"
- else
- shasum -a 256 "$deb_file" > "$deb_file.sha256"
- fi
-)
-
-echo "[cli-deb] package: $DEB_PATH"
-echo "[cli-deb] checksum: $DEB_PATH.sha256"
-
-if [ -n "${GITHUB_OUTPUT:-}" ]; then
- echo "deb-path=$DEB_PATH" >> "$GITHUB_OUTPUT"
-fi
diff --git a/scripts/release/build-cli-homebrew-formula.sh b/scripts/release/build-cli-homebrew-formula.sh
deleted file mode 100755
index 32de3d9d9..000000000
--- a/scripts/release/build-cli-homebrew-formula.sh
+++ /dev/null
@@ -1,131 +0,0 @@
-#!/usr/bin/env bash
-# Renders a Homebrew formula for the SalmonEgg CLI from the checksums of the archives actually built by
-# this release run.
-#
-# PATH ownership: `brew install` symlinks the binary into the Homebrew prefix's bin directory, which is
-# already on PATH for any Homebrew user, and `brew uninstall` removes the symlink. The formula therefore
-# never edits a shell profile.
-#
-# The SHA-256 values are read from the .sha256 sidecars produced by build-cli-artifacts.sh rather than
-# recomputed here, so a formula can only ever describe archives that exist.
-set -euo pipefail
-
-REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
-
-ARTIFACT_DIR="$REPO_ROOT/artifacts/cli"
-OUTPUT_PATH=""
-VERSION=""
-RELEASE_TAG=""
-REPOSITORY="salmonloop/salmon-egg"
-
-usage() {
- cat <<'USAGE'
-Usage: build-cli-homebrew-formula.sh --version [options]
-
-Options:
- --version CLI display version (three-part numeric).
- --release-tag Release tag the assets are attached to. Default: v.
- --artifact-dir Directory holding the archives and .sha256 sidecars. Default: artifacts/cli.
- --repository GitHub repository. Default: salmonloop/salmon-egg.
- --output Formula output path. Default: /salmon-egg-cli.rb.
- -h, --help Show this help.
-USAGE
-}
-
-while [ "$#" -gt 0 ]; do
- case "$1" in
- --version) VERSION="${2:?--version requires a value}"; shift 2 ;;
- --version=*) VERSION="${1#*=}"; shift ;;
- --release-tag) RELEASE_TAG="${2:?--release-tag requires a value}"; shift 2 ;;
- --release-tag=*) RELEASE_TAG="${1#*=}"; shift ;;
- --artifact-dir) ARTIFACT_DIR="${2:?--artifact-dir requires a value}"; shift 2 ;;
- --artifact-dir=*) ARTIFACT_DIR="${1#*=}"; shift ;;
- --repository) REPOSITORY="${2:?--repository requires a value}"; shift 2 ;;
- --repository=*) REPOSITORY="${1#*=}"; shift ;;
- --output) OUTPUT_PATH="${2:?--output requires a value}"; shift 2 ;;
- --output=*) OUTPUT_PATH="${1#*=}"; shift ;;
- -h|--help) usage; exit 0 ;;
- *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;;
- esac
-done
-
-if [ -z "$VERSION" ]; then
- echo "--version is required." >&2
- usage >&2
- exit 2
-fi
-
-case "$VERSION" in
- [0-9]*.[0-9]*.[0-9]*) ;;
- *) echo "Version must be a three-part numeric version, got: '$VERSION'" >&2; exit 1 ;;
-esac
-
-RELEASE_TAG="${RELEASE_TAG:-v$VERSION}"
-OUTPUT_PATH="${OUTPUT_PATH:-$ARTIFACT_DIR/salmon-egg-cli.rb}"
-
-read_checksum() {
- local archive_name="$1"
- local sidecar="$ARTIFACT_DIR/$archive_name.sha256"
- if [ ! -f "$sidecar" ]; then
- echo "Checksum sidecar not found: $sidecar" >&2
- return 1
- fi
-
- local checksum
- checksum="$(awk '{print $1}' "$sidecar" | head -n 1)"
- if [ "${#checksum}" -ne 64 ]; then
- echo "Malformed SHA-256 in $sidecar: '$checksum'" >&2
- return 1
- fi
-
- printf '%s' "$checksum"
-}
-
-MAC_ARM_ARCHIVE="salmon-egg-cli-$VERSION-osx-arm64.tar.gz"
-LINUX_X64_ARCHIVE="salmon-egg-cli-$VERSION-linux-x64.tar.gz"
-
-MAC_ARM_SHA="$(read_checksum "$MAC_ARM_ARCHIVE")"
-LINUX_X64_SHA="$(read_checksum "$LINUX_X64_ARCHIVE")"
-
-BASE_URL="https://github.com/$REPOSITORY/releases/download/$RELEASE_TAG"
-
-mkdir -p "$(dirname "$OUTPUT_PATH")"
-cat > "$OUTPUT_PATH" <> "$GITHUB_OUTPUT"
-fi
diff --git a/scripts/release/build-cli-msi.ps1 b/scripts/release/build-cli-msi.ps1
deleted file mode 100644
index 2423992fe..000000000
--- a/scripts/release/build-cli-msi.ps1
+++ /dev/null
@@ -1,167 +0,0 @@
-#requires -Version 7.0
-<#
-.SYNOPSIS
- Builds a per-user Windows MSI that installs the SalmonEgg CLI and registers it on PATH.
-
-.DESCRIPTION
- Windows has no /usr/bin equivalent, so the command is made discoverable by appending the install
- folder to the *user* PATH through a WiX Environment element. Windows Installer owns that value: it is
- written on install and removed on uninstall, which a script editing the registry or calling setx
- cannot guarantee. Nothing here touches the machine PATH, and the GUI installers are untouched.
-#>
-[CmdletBinding()]
-param(
- [Parameter(Mandatory = $true)][string]$Executable,
- [string]$Version,
- [string]$OutputDirectory
-)
-
-Set-StrictMode -Version Latest
-$ErrorActionPreference = 'Stop'
-
-$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..' '..')).Path
-
-if (-not (Test-Path -LiteralPath $Executable)) {
- throw "Executable not found: $Executable"
-}
-$executablePath = (Resolve-Path -LiteralPath $Executable).Path
-
-if ([string]::IsNullOrWhiteSpace($Version)) {
- $cliProject = Join-Path $repoRoot 'src/SalmonEgg.Cli/SalmonEgg.Cli.csproj'
- # -t:MinVer runs the MinVer target so the property holds the tag-derived version.
- $Version = (dotnet msbuild $cliProject -restore -t:MinVer -getProperty:SalmonEggDisplayVersion -nologo).Trim()
-}
-
-if ($Version -notmatch '^\d+\.\d+\.\d+$') {
- throw "Package version must be a three-part numeric version, got: $Version"
-}
-
-if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
- $OutputDirectory = Join-Path $repoRoot 'artifacts/cli'
-}
-New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
-$outputDirectoryPath = (Resolve-Path -LiteralPath $OutputDirectory).Path
-
-$installerDir = Join-Path $repoRoot 'artifacts/cli-msi'
-if (Test-Path -LiteralPath $installerDir) {
- Remove-Item -LiteralPath $installerDir -Recurse -Force
-}
-New-Item -ItemType Directory -Force -Path $installerDir | Out-Null
-
-# A stable UpgradeCode is what lets a new version replace the old one instead of installing beside it and
-# leaving two salmon-egg.exe entries competing on PATH.
-$upgradeCode = '4B4D0B4E-9E0A-4C3C-9E64-3D0B69B3A0F1'
-
-$productPath = Join-Path $installerDir 'Product.wxs'
-$productXml = @(
- ''
- ''
- " "
- ' '
- ' '
- ' '
- ' '
- ' '
- ' '
- ' '
- ' '
- ' '
- ' '
- ' '
- ' '
- ' '
- ' '
- ' '
- ' '
- ' '
- " "
- ' '
- ' '
- ' '
- ' '
- ' '
- ''
-)
-Set-Content -Path $productPath -Encoding UTF8 -Value $productXml
-
-$wixObjDir = Join-Path $installerDir 'obj'
-New-Item -ItemType Directory -Force -Path $wixObjDir | Out-Null
-
-& candle -out (Join-Path $wixObjDir '') $productPath
-if ($LASTEXITCODE -ne 0) { throw "candle failed with exit code $LASTEXITCODE." }
-
-$msiPath = Join-Path $outputDirectoryPath "salmon-egg-cli-$Version-win-x64.msi"
-if (Test-Path -LiteralPath $msiPath) {
- Remove-Item -LiteralPath $msiPath -Force
-}
-
-& light -cultures:en-us -sice:ICE38 -sice:ICE64 -sice:ICE91 -out $msiPath (Join-Path $wixObjDir 'Product.wixobj')
-if ($LASTEXITCODE -ne 0) { throw "light failed with exit code $LASTEXITCODE." }
-
-if (-not (Test-Path -LiteralPath $msiPath)) {
- throw "MSI was not produced: $msiPath"
-}
-
-# Verify the PATH registration landed in the built package rather than trusting the authoring above. A
-# real install/uninstall check needs an interactive Windows session and stays a manual release step (see
-# docs/release-guide.md); what can be asserted here is the package's own Environment table.
-#
-# The rule itself lives in CliMsiPathContract.ps1, which documents the MSI encoding it enforces, so that
-# scripts/gates/run-cli-msi-path-contract-gate.ps1 can exercise the rule — and each of its failure
-# cases — without WiX or a Windows session.
-. (Join-Path $PSScriptRoot 'CliMsiPathContract.ps1')
-
-$installer = New-Object -ComObject WindowsInstaller.Installer
-$database = $installer.OpenDatabase($msiPath, 0)
-try {
- $view = $database.OpenView('SELECT `Name`, `Value` FROM `Environment`')
- $view.Execute()
-
- # Every row is read rather than just the first: a second row introduced later — a machine PATH entry,
- # say — would ship unchecked if the read stopped after one.
- $rows = @()
- while ($true) {
- $record = $view.Fetch()
- if ($null -eq $record) {
- break
- }
-
- $rows += [pscustomobject]@{
- Name = $record.StringData(1)
- Value = $record.StringData(2)
- }
- }
-
- if ($rows.Count -eq 0) {
- throw 'The built MSI has no Environment table row: the CLI would not be registered on PATH.'
- }
-
- if ($rows.Count -ne 1) {
- $described = ($rows | ForEach-Object { "Name='$($_.Name)' Value='$($_.Value)'" }) -join '; '
- throw ("The built MSI has $($rows.Count) Environment table rows, but this package registers " +
- "exactly one PATH entry. Rows: $described")
- }
-
- Assert-CliMsiPathContract -Name $rows[0].Name -Value $rows[0].Value
- Write-Host "[cli-msi] verified PATH registration: Name='$($rows[0].Name)' Value='$($rows[0].Value)'"
-}
-finally {
- [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($database)
- [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($installer)
-}
-
-$hash = (Get-FileHash -LiteralPath $msiPath -Algorithm SHA256).Hash.ToLowerInvariant()
-"$hash $(Split-Path -Leaf $msiPath)" | Set-Content -Path "$msiPath.sha256" -Encoding ascii
-
-Write-Host "[cli-msi] package: $msiPath"
-Write-Host "[cli-msi] checksum: $msiPath.sha256"
-
-if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_OUTPUT)) {
- "msi-path=$msiPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
-}
diff --git a/scripts/release/build-desktop-deb.sh b/scripts/release/build-desktop-deb.sh
new file mode 100755
index 000000000..8b4fa31e1
--- /dev/null
+++ b/scripts/release/build-desktop-deb.sh
@@ -0,0 +1,274 @@
+#!/usr/bin/env bash
+# Builds the Debian package that installs the SalmonEgg desktop app and the salmon-egg command.
+#
+# Layout, and why:
+# /opt/salmon-egg/ the self-contained publish output. /opt is where the FHS puts
+# add-on application packages that ship their own runtime, which is
+# what a self-contained .NET publish is.
+# /usr/bin/salmon-egg a symlink into that tree. /usr/bin is on every login PATH already,
+# so nothing has to edit a shell profile: an installer-managed file
+# is reversible, an edited .bashrc is not. dpkg owns the symlink and
+# removes it on purge.
+# /usr/share/applications/... the desktop entry, so the GUI is launchable from the shell's menu
+# and registered for the s8p scheme the app answers.
+# /usr/share/icons/hicolor/... the app icon at every size Resizetizer generated for this build.
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+DOTNET_BIN="${DOTNET_BIN:-dotnet}"
+
+PUBLISH_DIR=""
+VERSION=""
+ARCHITECTURE="amd64"
+OUTPUT_DIR="$REPO_ROOT/artifacts/desktop"
+
+PACKAGE_NAME="salmon-egg"
+INSTALL_ROOT="/opt/salmon-egg"
+APP_EXECUTABLE="SalmonEgg"
+COMMAND_NAME="salmon-egg"
+
+usage() {
+ cat <<'USAGE'
+Usage: build-desktop-deb.sh --publish-dir [options]
+
+Options:
+ --publish-dir Self-contained Linux desktop publish output. Must contain the app executable
+ and cli/salmon-egg.
+ --version Package version. Default: the repository display version.
+ --architecture Debian architecture. Default: amd64.
+ --output Output directory. Default: artifacts/desktop.
+ -h, --help Show this help.
+USAGE
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --publish-dir) PUBLISH_DIR="${2:?--publish-dir requires a value}"; shift 2 ;;
+ --publish-dir=*) PUBLISH_DIR="${1#*=}"; shift ;;
+ --version) VERSION="${2:?--version requires a value}"; shift 2 ;;
+ --version=*) VERSION="${1#*=}"; shift ;;
+ --architecture) ARCHITECTURE="${2:?--architecture requires a value}"; shift 2 ;;
+ --architecture=*) ARCHITECTURE="${1#*=}"; shift ;;
+ --output) OUTPUT_DIR="${2:?--output requires a value}"; shift 2 ;;
+ --output=*) OUTPUT_DIR="${1#*=}"; shift ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;;
+ esac
+done
+
+if [ -z "$PUBLISH_DIR" ]; then
+ echo "--publish-dir is required." >&2
+ usage >&2
+ exit 2
+fi
+
+if [ ! -d "$PUBLISH_DIR" ]; then
+ echo "Publish directory not found: $PUBLISH_DIR" >&2
+ exit 1
+fi
+PUBLISH_DIR="$(cd "$PUBLISH_DIR" && pwd)"
+
+if ! command -v dpkg-deb >/dev/null 2>&1; then
+ echo "dpkg-deb is required to build the Debian package." >&2
+ exit 1
+fi
+
+# Both halves of what this package promises. The command is what the PATH symlink points at, so a publish
+# that lost it would produce a package whose /usr/bin/salmon-egg dangles.
+if [ ! -f "$PUBLISH_DIR/$APP_EXECUTABLE" ]; then
+ echo "The publish output has no app executable at $PUBLISH_DIR/$APP_EXECUTABLE." >&2
+ exit 1
+fi
+
+if [ ! -f "$PUBLISH_DIR/cli/$COMMAND_NAME" ]; then
+ echo "The publish output has no bundled CLI at $PUBLISH_DIR/cli/$COMMAND_NAME." >&2
+ echo "Publish it with scripts/release/publish-cli-binary.sh and pass -p:SalmonEggBundledCliExecutable." >&2
+ exit 1
+fi
+
+if [ -z "$VERSION" ]; then
+ # -t:MinVer runs the MinVer target so the property holds the tag-derived version, not a default.
+ VERSION="$("$DOTNET_BIN" msbuild "$REPO_ROOT/src/SalmonEgg.Cli/SalmonEgg.Cli.csproj" \
+ -restore -t:MinVer -getProperty:SalmonEggDisplayVersion -nologo | tr -d '\r' | tail -n 1)"
+fi
+
+case "$VERSION" in
+ [0-9]*.[0-9]*.[0-9]*) ;;
+ *) echo "Package version must be a three-part numeric version, got: '$VERSION'" >&2; exit 1 ;;
+esac
+
+STAGING_DIR="$REPO_ROOT/artifacts/desktop-deb/$ARCHITECTURE"
+rm -rf "$STAGING_DIR"
+mkdir -p "$STAGING_DIR/DEBIAN" \
+ "$STAGING_DIR$INSTALL_ROOT" \
+ "$STAGING_DIR/usr/bin" \
+ "$STAGING_DIR/usr/share/applications" \
+ "$STAGING_DIR/usr/share/doc/$PACKAGE_NAME" \
+ "$OUTPUT_DIR"
+
+cp -a "$PUBLISH_DIR/." "$STAGING_DIR$INSTALL_ROOT/"
+chmod 0755 "$STAGING_DIR$INSTALL_ROOT/$APP_EXECUTABLE" "$STAGING_DIR$INSTALL_ROOT/cli/$COMMAND_NAME"
+
+# A relative symlink so the package stays correct under a chroot, a container image build, or any prefix a
+# downstream rebuild uses. Two levels up, because the link lives in /usr/bin and the target is under /opt:
+# one level would resolve to /usr/opt and dangle. dpkg records it as a package file either way, which is
+# what makes purge remove the command instead of leaving a broken link behind.
+ln -s "../..$INSTALL_ROOT/cli/$COMMAND_NAME" "$STAGING_DIR/usr/bin/$COMMAND_NAME"
+
+# The app icon at every size the shell asks for. Uno's Resizetizer generates these from the single source
+# artwork during publish and each one lands on a size the hicolor theme defines, so nothing here scales an
+# image: the source artwork is 200x200, which is not a hicolor size, and a package shipping one odd-sized
+# icon gets it rescaled twice by the theme engine or ignored outright.
+ICON_SOURCE_DIR="$PUBLISH_DIR/Assets/Icons"
+INSTALLED_ICON_SIZES=""
+for size in 16 24 32 48 256; do
+ icon="$ICON_SOURCE_DIR/iconLogo.targetsize-$size.png"
+ [ -f "$icon" ] || continue
+ icon_dir="$STAGING_DIR/usr/share/icons/hicolor/${size}x${size}/apps"
+ mkdir -p "$icon_dir"
+ install -m 0644 "$icon" "$icon_dir/$PACKAGE_NAME.png"
+ INSTALLED_ICON_SIZES="$INSTALLED_ICON_SIZES $size"
+done
+
+# 256 is the size docks and the app grid actually render, so its absence is a package whose icon is a
+# generic placeholder wherever it matters most.
+case "$INSTALLED_ICON_SIZES" in
+ *" 256"*) ;;
+ *)
+ echo "No generated 256x256 app icon under $ICON_SOURCE_DIR." >&2
+ echo "Expected iconLogo.targetsize-256.png from Uno's Resizetizer in the publish output." >&2
+ exit 1
+ ;;
+esac
+
+# MimeType registers the same s8p scheme the Windows package declares as a protocol, so a link opens the
+# app on either platform. StartupWMClass is deliberately absent: a wrong value silently breaks the
+# taskbar's window-to-launcher association, and the Skia host's class is not something this script knows.
+cat > "$STAGING_DIR/usr/share/applications/$PACKAGE_NAME.desktop" </maps while it ran headless, and
+# the smoke gate re-checks that an installed package resolves all of it.
+#
+# Alternative dependencies rather than pinned ones: Microsoft's own .NET debs name a single libicu because
+# they build one deb per distribution, while this package ships once for every glibc-compatible
+# distribution. The t64 variants are Ubuntu 24.04's 64-bit-time transition, so both spellings appear.
+#
+# libc6 is bounded because .NET 10 supports Ubuntu 22.04 (glibc 2.35) and newer; installing on anything
+# older would succeed and then fail to start.
+DEPENDS="libc6 (>= 2.35), libgcc-s1, libstdc++6, zlib1g, libbrotli1"
+DEPENDS="$DEPENDS, libicu76 | libicu74 | libicu72 | libicu71 | libicu70 | libicu67 | libicu66"
+DEPENDS="$DEPENDS, libssl3t64 | libssl3 | libssl1.1"
+DEPENDS="$DEPENDS, libx11-6, libxext6, libxi6, libxrandr2, libxcursor1, libxrender1, libxfixes3"
+DEPENDS="$DEPENDS, libgl1, libegl1, libfreetype6"
+DEPENDS="$DEPENDS, libglib2.0-0t64 | libglib2.0-0"
+DEPENDS="$DEPENDS, libgstreamer1.0-0, libgstreamer-plugins-base1.0-0"
+DEPENDS="$DEPENDS, libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37"
+
+cat > "$STAGING_DIR/DEBIAN/control" <
+Installed-Size: $INSTALLED_SIZE_KB
+Depends: $DEPENDS
+Conflicts: salmon-egg-cli
+Replaces: salmon-egg-cli
+Homepage: https://github.com/salmonloop/salmon-egg
+Description: Salmon Egg AI agent client
+ Desktop client for AI coding agents speaking the Agent Client Protocol, with the
+ salmon-egg command-line tool for managing server configurations and credentials.
+ Ships as a self-contained build, so no .NET runtime is required.
+ Credentials are stored through the Secret Service; when it is unavailable the
+ write fails rather than downgrading to plaintext.
+EOF
+
+# The desktop and icon caches are indexes, not files this package owns: they have to be refreshed after
+# install and after removal, and a machine without the tools is not an error. Both hooks are idempotent.
+cat > "$STAGING_DIR/DEBIAN/postinst" <<'EOF'
+#!/bin/sh
+set -e
+
+if [ "$1" = "configure" ]; then
+ if command -v update-desktop-database >/dev/null 2>&1; then
+ update-desktop-database -q /usr/share/applications || true
+ fi
+ if command -v gtk-update-icon-cache >/dev/null 2>&1; then
+ gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
+ fi
+fi
+
+exit 0
+EOF
+
+cat > "$STAGING_DIR/DEBIAN/postrm" <<'EOF'
+#!/bin/sh
+set -e
+
+if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
+ if command -v update-desktop-database >/dev/null 2>&1; then
+ update-desktop-database -q /usr/share/applications || true
+ fi
+ if command -v gtk-update-icon-cache >/dev/null 2>&1; then
+ gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
+ fi
+fi
+
+exit 0
+EOF
+
+chmod 0755 "$STAGING_DIR/DEBIAN/postinst" "$STAGING_DIR/DEBIAN/postrm"
+
+# dpkg refuses to install a package whose files are not owned by root, and the release runner is not root.
+# fakeroot is what dpkg-deb's own documentation recommends for exactly this case.
+DEB_PATH="$OUTPUT_DIR/${PACKAGE_NAME}_${VERSION}_${ARCHITECTURE}.deb"
+rm -f "$DEB_PATH" "$DEB_PATH.sha256"
+if command -v fakeroot >/dev/null 2>&1; then
+ fakeroot dpkg-deb --build --root-owner-group "$STAGING_DIR" "$DEB_PATH" >/dev/null
+else
+ dpkg-deb --build --root-owner-group "$STAGING_DIR" "$DEB_PATH" >/dev/null
+fi
+
+# macOS ships `shasum` rather than GNU `sha256sum`; both emit the same "" sidecar format.
+(
+ cd "$OUTPUT_DIR"
+ deb_file="$(basename "$DEB_PATH")"
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$deb_file" > "$deb_file.sha256"
+ else
+ shasum -a 256 "$deb_file" > "$deb_file.sha256"
+ fi
+)
+
+echo "[desktop-deb] icons: ${INSTALLED_ICON_SIZES# } (hicolor)"
+echo "[desktop-deb] package: $DEB_PATH"
+echo "[desktop-deb] checksum: $DEB_PATH.sha256"
+
+if [ -n "${GITHUB_OUTPUT:-}" ]; then
+ {
+ echo "deb-path=$DEB_PATH"
+ echo "display-version=$VERSION"
+ } >> "$GITHUB_OUTPUT"
+fi
diff --git a/scripts/release/build-macos-pkg.sh b/scripts/release/build-macos-pkg.sh
new file mode 100755
index 000000000..7b2959c97
--- /dev/null
+++ b/scripts/release/build-macos-pkg.sh
@@ -0,0 +1,161 @@
+#!/usr/bin/env bash
+# Builds the macOS installer package that installs SalmonEgg.app and registers the salmon-egg command.
+#
+# Why a .pkg exists alongside the .dmg: a dragged .dmg has no install hook, so nothing can put the bundled
+# command on PATH. Uno can produce a .pkg (PackageFormat=pkg), but its PackageAppBundle task takes no
+# scripts parameter, so the package it builds cannot carry a postinstall either. This script therefore runs
+# pkgbuild directly, with scripts/release/macos-pkg-postinstall.sh as the postinstall — the same file
+# scripts/gates/run-macos-pkg-contract-gate.sh exercises on every push.
+#
+# macOS only: pkgbuild and productsign ship with Xcode's command line tools.
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+DOTNET_BIN="${DOTNET_BIN:-dotnet}"
+
+APP_BUNDLE=""
+VERSION=""
+OUTPUT_DIR="$REPO_ROOT/artifacts/macos"
+SIGNING_KEY=""
+
+usage() {
+ cat <<'USAGE'
+Usage: build-macos-pkg.sh --app-bundle [options]
+
+Options:
+ --app-bundle The .app bundle to package. Must contain Contents/MacOS/cli/salmon-egg.
+ --version Package version. Default: the repository display version.
+ --output Output directory. Default: artifacts/macos.
+ --signing-key Developer ID Installer identity. Unsigned when omitted.
+ -h, --help Show this help.
+USAGE
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --app-bundle) APP_BUNDLE="${2:?--app-bundle requires a value}"; shift 2 ;;
+ --app-bundle=*) APP_BUNDLE="${1#*=}"; shift ;;
+ --version) VERSION="${2:?--version requires a value}"; shift 2 ;;
+ --version=*) VERSION="${1#*=}"; shift ;;
+ --output) OUTPUT_DIR="${2:?--output requires a value}"; shift 2 ;;
+ --output=*) OUTPUT_DIR="${1#*=}"; shift ;;
+ --signing-key) SIGNING_KEY="${2:?--signing-key requires a value}"; shift 2 ;;
+ --signing-key=*) SIGNING_KEY="${1#*=}"; shift ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;;
+ esac
+done
+
+if [ -z "$APP_BUNDLE" ]; then
+ echo "--app-bundle is required." >&2
+ usage >&2
+ exit 2
+fi
+
+if [ ! -d "$APP_BUNDLE" ]; then
+ echo "App bundle not found: $APP_BUNDLE" >&2
+ exit 1
+fi
+APP_BUNDLE="$(cd "$APP_BUNDLE" && pwd)"
+
+case "$APP_BUNDLE" in
+ *.app) ;;
+ *) echo "Expected a path ending in .app, got: $APP_BUNDLE" >&2; exit 1 ;;
+esac
+
+if ! command -v pkgbuild >/dev/null 2>&1; then
+ echo "pkgbuild is required and ships with the Xcode command line tools; this script runs on macOS." >&2
+ exit 1
+fi
+
+# The command the postinstall links. Without it the installer would run, succeed at copying the app, and
+# then fail in postinstall — a worse failure than not building the package.
+COMMAND_PATH="$APP_BUNDLE/Contents/MacOS/cli/salmon-egg"
+if [ ! -x "$COMMAND_PATH" ]; then
+ echo "The app bundle has no executable bundled CLI at Contents/MacOS/cli/salmon-egg." >&2
+ echo "Publish it with scripts/release/publish-cli-binary.sh and pass -p:SalmonEggBundledCliExecutable." >&2
+ exit 1
+fi
+
+# The bundle identifier is the package identifier: two identifiers for one product would let the installer
+# treat an upgrade as a second, independent install.
+PLIST="$APP_BUNDLE/Contents/Info.plist"
+if [ ! -f "$PLIST" ]; then
+ echo "The app bundle has no Contents/Info.plist." >&2
+ exit 1
+fi
+IDENTIFIER="$(python3 -c "
+import plistlib, sys
+with open('$PLIST', 'rb') as handle:
+ data = plistlib.load(handle)
+identifier = data.get('CFBundleIdentifier')
+if not identifier:
+ sys.exit('Info.plist declares no CFBundleIdentifier')
+print(identifier)
+")"
+
+if [ -z "$VERSION" ]; then
+ # -t:MinVer runs the MinVer target so the property holds the tag-derived version, not a default.
+ VERSION="$("$DOTNET_BIN" msbuild "$REPO_ROOT/src/SalmonEgg.Cli/SalmonEgg.Cli.csproj" \
+ -restore -t:MinVer -getProperty:SalmonEggDisplayVersion -nologo | tr -d '\r' | tail -n 1)"
+fi
+
+case "$VERSION" in
+ [0-9]*.[0-9]*.[0-9]*) ;;
+ *) echo "Package version must be a three-part numeric version, got: '$VERSION'" >&2; exit 1 ;;
+esac
+
+STAGING_DIR="$REPO_ROOT/artifacts/macos-pkg"
+rm -rf "$STAGING_DIR"
+mkdir -p "$STAGING_DIR/root/Applications" "$STAGING_DIR/scripts" "$OUTPUT_DIR"
+
+# -R rather than a move: the .app is also uploaded on its own, and pkgbuild reads its payload from here.
+cp -R "$APP_BUNDLE" "$STAGING_DIR/root/Applications/"
+
+# pkgbuild requires the postinstall to be named exactly that, and executable.
+install -m 0755 "$REPO_ROOT/scripts/release/macos-pkg-postinstall.sh" "$STAGING_DIR/scripts/postinstall"
+
+UNSIGNED_PKG="$STAGING_DIR/SalmonEgg-unsigned.pkg"
+pkgbuild \
+ --root "$STAGING_DIR/root" \
+ --scripts "$STAGING_DIR/scripts" \
+ --identifier "$IDENTIFIER" \
+ --version "$VERSION" \
+ --install-location / \
+ "$UNSIGNED_PKG"
+
+PKG_PATH="$OUTPUT_DIR/SalmonEgg-$VERSION.pkg"
+rm -f "$PKG_PATH" "$PKG_PATH.sha256"
+
+if [ -n "$SIGNING_KEY" ]; then
+ # A Developer ID Installer identity, which is a different certificate from the app and disk-image ones.
+ # Unsigned packages still install after the user overrides Gatekeeper, so signing stays optional here and
+ # the release workflow supplies the key only when the secret is configured.
+ productsign --sign "$SIGNING_KEY" "$UNSIGNED_PKG" "$PKG_PATH"
+ echo "[macos-pkg] signed with: $SIGNING_KEY"
+else
+ cp "$UNSIGNED_PKG" "$PKG_PATH"
+ echo "[macos-pkg] unsigned: no installer signing identity was supplied"
+fi
+
+(
+ cd "$OUTPUT_DIR"
+ pkg_file="$(basename "$PKG_PATH")"
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$pkg_file" > "$pkg_file.sha256"
+ else
+ shasum -a 256 "$pkg_file" > "$pkg_file.sha256"
+ fi
+)
+
+echo "[macos-pkg] identifier: $IDENTIFIER"
+echo "[macos-pkg] command: ${COMMAND_PATH#$APP_BUNDLE/}"
+echo "[macos-pkg] package: $PKG_PATH"
+echo "[macos-pkg] checksum: $PKG_PATH.sha256"
+
+if [ -n "${GITHUB_OUTPUT:-}" ]; then
+ {
+ echo "pkg-path=$PKG_PATH"
+ echo "display-version=$VERSION"
+ } >> "$GITHUB_OUTPUT"
+fi
diff --git a/scripts/release/macos-pkg-postinstall.sh b/scripts/release/macos-pkg-postinstall.sh
new file mode 100755
index 000000000..39670f452
--- /dev/null
+++ b/scripts/release/macos-pkg-postinstall.sh
@@ -0,0 +1,64 @@
+#!/bin/sh
+# Installer postinstall: puts the bundled salmon-egg command on PATH.
+#
+# This script is the reason the .pkg exists. A .dmg is dragged, so it has no install hook at all, and Uno's
+# PackageAppBundle task accepts no scripts parameter — there is no other place on macOS to register a
+# command. /usr/local/bin is on the default PATH (macOS ships it in /etc/paths), so a symlink there is the
+# smallest thing that makes `salmon-egg` resolve without editing anyone's shell profile.
+#
+# Installer argument contract: $1 is the package path, $2 the target location, $3 the target volume. Only
+# $2 is read, which is also what lets scripts/gates/run-macos-pkg-contract-gate.sh run this exact file
+# against a fake root on any platform instead of only ever running inside a real installation.
+#
+# Removal is the user's: macOS packages have no uninstall phase, so the link outlives a dragged-to-trash
+# app the same way VS Code's `code` does. docs/release-guide.md says how to remove it.
+set -eu
+
+DESTINATION="${2:-/}"
+DESTINATION="${DESTINATION%/}"
+
+APP_BUNDLE="$DESTINATION/Applications/SalmonEgg.app"
+BIN_DIR="$DESTINATION/usr/local/bin"
+LINK_PATH="$BIN_DIR/salmon-egg"
+
+# Two candidate locations, because which one the command lands in is Uno's decision, not ours. Dissecting
+# the shipped v1.4.2 bundle shows how GenerateAppBundle splits a publish directory: the apphost, its
+# deps.json/runtimeconfig.json and every .dylib go to Contents/MacOS (19 files), while managed assemblies,
+# satellite resource directories and asset subdirectories go to Contents/Resources with their relative paths
+# intact. A `cli/` subdirectory holding one extension-less Mach-O matches neither pattern exactly, and the
+# split is not a documented contract, so both are probed rather than assumed. MacOS first: that is where
+# Apple expects auxiliary executables, so if Uno ever classifies it that way it is the one to prefer.
+COMMAND_SOURCE=""
+for candidate in \
+ "$APP_BUNDLE/Contents/MacOS/cli/salmon-egg" \
+ "$APP_BUNDLE/Contents/Resources/cli/salmon-egg"
+do
+ if [ -x "$candidate" ]; then
+ COMMAND_SOURCE="$candidate"
+ break
+ fi
+done
+
+# Fail rather than leave a link to nothing. No command in the bundle means the publish never embedded one,
+# and a dangling /usr/local/bin entry is worse than an absent one: it shadows any other salmon-egg the user
+# installs later.
+if [ -z "$COMMAND_SOURCE" ]; then
+ echo "salmon-egg is not present in the installed bundle at $APP_BUNDLE." >&2
+ echo "Looked in Contents/MacOS/cli and Contents/Resources/cli." >&2
+ exit 1
+fi
+
+# /usr/local/bin does not exist on a fresh macOS install.
+mkdir -p "$BIN_DIR"
+
+# Remove first rather than relying on `ln -sf`: when the existing path is a symlink to a directory, -f
+# makes ln create the new link *inside* it. -L tests the link itself rather than its target, so a link left
+# dangling by a previous version is replaced instead of being mistaken for absent.
+if [ -L "$LINK_PATH" ] || [ -e "$LINK_PATH" ]; then
+ rm -f "$LINK_PATH"
+fi
+
+ln -s "$COMMAND_SOURCE" "$LINK_PATH"
+
+echo "salmon-egg linked: $LINK_PATH -> $COMMAND_SOURCE"
+exit 0
diff --git a/scripts/release/build-cli-artifacts.sh b/scripts/release/publish-cli-binary.sh
similarity index 54%
rename from scripts/release/build-cli-artifacts.sh
rename to scripts/release/publish-cli-binary.sh
index 0a6fada86..083a3975a 100755
--- a/scripts/release/build-cli-artifacts.sh
+++ b/scripts/release/publish-cli-binary.sh
@@ -1,10 +1,13 @@
#!/usr/bin/env bash
-# Publishes the SalmonEgg CLI as a self-contained single-file executable for one supported runtime
-# identifier and packages it as a release archive with a SHA-256 sidecar.
+# Publishes the SalmonEgg CLI as the single self-contained executable that every SalmonEgg installer
+# embeds. There is deliberately no archive step: the command is no longer distributed on its own, so the
+# only consumers are packaging chains that copy this file into their own payload (Windows MSIX, Windows
+# desktop MSI, macOS app bundle, Linux deb). Keeping the publish in one script is what makes the command
+# inside all four installers the same binary rather than four independently produced ones.
#
# The runtime identifier allow-list lives in src/SalmonEgg.Cli/SalmonEgg.Cli.csproj
# (SalmonEggCliSupportedRuntimeIdentifiers). This script reads it back instead of repeating it so the
-# project stays the single source of truth for which platforms are officially supported.
+# project stays the single source of truth for which platforms ship a bundled command.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
@@ -13,19 +16,24 @@ DOTNET_BIN="${DOTNET_BIN:-dotnet}"
RID=""
CONFIGURATION="Release"
-OUTPUT_DIR="$REPO_ROOT/artifacts/cli"
+OUTPUT_DIR=""
ALLOW_UNSUPPORTED_RID="false"
usage() {
cat <<'USAGE'
-Usage: build-cli-artifacts.sh --rid [options]
+Usage: publish-cli-binary.sh --rid [options]
Options:
--rid Runtime identifier to publish (win-x64, linux-x64, osx-arm64).
--configuration Build configuration. Default: Release.
- --output Artifact output directory. Default: artifacts/cli.
+ --output Publish directory. Default: artifacts/cli-bin/.
--allow-unsupported-rid Publish a RID outside the support matrix for local verification only.
-h, --help Show this help.
+
+Outputs (stdout and, when set, $GITHUB_OUTPUT):
+ executable-path Absolute path of the published executable.
+ executable-path-native Same path in the host's native form (a Windows drive path under Git Bash).
+ display-version Three-part release version derived from the git tag by MinVer.
USAGE
}
@@ -59,20 +67,6 @@ read_release_version() {
"$DOTNET_BIN" msbuild "$CLI_PROJECT" -restore -t:MinVer "-getProperty:$1" -nologo | tr -d '\r' | tail -n 1
}
-# macOS ships `shasum`, not GNU `sha256sum`. Both print "", so the sidecar format is
-# identical either way and `shasum -c` / `sha256sum -c` can both verify it.
-write_sha256() {
- local file_name="$1"
- if command -v sha256sum >/dev/null 2>&1; then
- sha256sum "$file_name" > "$file_name.sha256"
- elif command -v shasum >/dev/null 2>&1; then
- shasum -a 256 "$file_name" > "$file_name.sha256"
- else
- echo "Neither sha256sum nor shasum is available to checksum $file_name" >&2
- return 1
- fi
-}
-
DISPLAY_VERSION="$(read_release_version SalmonEggDisplayVersion)"
SUPPORTED_RIDS="$(read_project_property SalmonEggCliSupportedRuntimeIdentifiers)"
@@ -93,25 +87,24 @@ case ";$SUPPORTED_RIDS;" in
esac
case "$RID" in
- win-*) EXECUTABLE_NAME="salmon-egg.exe"; ARCHIVE_FORMAT="zip" ;;
- *) EXECUTABLE_NAME="salmon-egg"; ARCHIVE_FORMAT="tar.gz" ;;
+ win-*) EXECUTABLE_NAME="salmon-egg.exe" ;;
+ *) EXECUTABLE_NAME="salmon-egg" ;;
esac
-PACKAGE_NAME="salmon-egg-cli-$DISPLAY_VERSION-$RID"
-PUBLISH_DIR="$REPO_ROOT/artifacts/cli-publish/$RID"
-STAGING_ROOT="$REPO_ROOT/artifacts/cli-staging/$RID"
-STAGING_DIR="$STAGING_ROOT/$PACKAGE_NAME"
+if [ -z "$OUTPUT_DIR" ]; then
+ OUTPUT_DIR="$REPO_ROOT/artifacts/cli-bin/$RID"
+fi
-rm -rf "$PUBLISH_DIR" "$STAGING_ROOT"
-mkdir -p "$PUBLISH_DIR" "$STAGING_DIR" "$OUTPUT_DIR"
+rm -rf "$OUTPUT_DIR"
+mkdir -p "$OUTPUT_DIR"
-echo "[cli-release] Publish $RID ($CONFIGURATION) version $DISPLAY_VERSION"
+echo "[cli-binary] Publish $RID ($CONFIGURATION) version $DISPLAY_VERSION"
publish_args=(
publish "$CLI_PROJECT"
-c "$CONFIGURATION"
-r "$RID"
-p:IsCliReleaseBuild=true
- -o "$PUBLISH_DIR"
+ -o "$OUTPUT_DIR"
-v minimal
)
if [ "$ALLOW_UNSUPPORTED_RID" = "true" ]; then
@@ -119,57 +112,42 @@ if [ "$ALLOW_UNSUPPORTED_RID" = "true" ]; then
fi
"$DOTNET_BIN" "${publish_args[@]}"
-EXECUTABLE_PATH="$PUBLISH_DIR/$EXECUTABLE_NAME"
+EXECUTABLE_PATH="$OUTPUT_DIR/$EXECUTABLE_NAME"
if [ ! -f "$EXECUTABLE_PATH" ]; then
echo "Published executable not found: $EXECUTABLE_PATH" >&2
exit 1
fi
-# A self-contained single-file publish must contain exactly the one executable: loose managed
+# A self-contained single-file publish must contain exactly the one executable. Loose managed
# assemblies, symbols or native libraries beside it would be a silent regression back to a
-# framework-style layout, and every install package below only ever carries that single file.
-unexpected="$(find "$PUBLISH_DIR" -mindepth 1 ! -name "$EXECUTABLE_NAME" -print)"
+# framework-style layout, and every installer below embeds that single file — an MSIX app execution
+# alias and a /usr/bin symlink both name one path and would launch a broken command.
+unexpected="$(find "$OUTPUT_DIR" -mindepth 1 ! -name "$EXECUTABLE_NAME" -print)"
if [ -n "$unexpected" ]; then
echo "Unexpected files in single-file publish output:" >&2
echo "$unexpected" >&2
exit 1
fi
-cp "$EXECUTABLE_PATH" "$STAGING_DIR/$EXECUTABLE_NAME"
-chmod +x "$STAGING_DIR/$EXECUTABLE_NAME"
-for doc in LICENSE README.md README.en.md; do
- if [ -f "$REPO_ROOT/$doc" ]; then
- cp "$REPO_ROOT/$doc" "$STAGING_DIR/$doc"
- fi
-done
+chmod +x "$EXECUTABLE_PATH"
-ARCHIVE_PATH="$OUTPUT_DIR/$PACKAGE_NAME.$ARCHIVE_FORMAT"
-rm -f "$ARCHIVE_PATH" "$ARCHIVE_PATH.sha256"
-
-if [ "$ARCHIVE_FORMAT" = "zip" ]; then
- if command -v zip >/dev/null 2>&1; then
- (cd "$STAGING_ROOT" && zip -q -r "$ARCHIVE_PATH" "$PACKAGE_NAME")
- elif command -v python3 >/dev/null 2>&1; then
- # Windows runners and this repository's Linux toolchain both ship python3; zip(1) is not universal.
- (cd "$STAGING_ROOT" && python3 -m zipfile -c "$ARCHIVE_PATH" "$PACKAGE_NAME")
- else
- echo "Neither zip nor python3 is available to create $ARCHIVE_PATH" >&2
- exit 1
- fi
-else
- tar -czf "$ARCHIVE_PATH" -C "$STAGING_ROOT" "$PACKAGE_NAME"
+# On a Windows runner this script runs under Git Bash, where the path above is an MSYS one
+# (/d/a/repo/...). MSBuild and WiX are native Windows processes and cannot open it, so the native form
+# is reported alongside it. Doing the conversion here rather than in each consuming workflow step keeps
+# one implementation: the same translation used to be inlined as a PowerShell regex per call site.
+NATIVE_EXECUTABLE_PATH="$EXECUTABLE_PATH"
+if command -v cygpath >/dev/null 2>&1; then
+ NATIVE_EXECUTABLE_PATH="$(cygpath -w "$EXECUTABLE_PATH")"
fi
-(cd "$OUTPUT_DIR" && write_sha256 "$PACKAGE_NAME.$ARCHIVE_FORMAT")
-
-echo "[cli-release] executable: $EXECUTABLE_PATH"
-echo "[cli-release] archive: $ARCHIVE_PATH"
-echo "[cli-release] checksum: $ARCHIVE_PATH.sha256"
+echo "[cli-binary] executable: $EXECUTABLE_PATH"
+echo "[cli-binary] native: $NATIVE_EXECUTABLE_PATH"
+echo "[cli-binary] version: $DISPLAY_VERSION"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
- echo "display-version=$DISPLAY_VERSION"
echo "executable-path=$EXECUTABLE_PATH"
- echo "archive-path=$ARCHIVE_PATH"
+ echo "executable-path-native=$NATIVE_EXECUTABLE_PATH"
+ echo "display-version=$DISPLAY_VERSION"
} >> "$GITHUB_OUTPUT"
fi
diff --git a/src/SalmonEgg.Cli/SalmonEgg.Cli.csproj b/src/SalmonEgg.Cli/SalmonEgg.Cli.csproj
index adcd3144a..7f860ce7d 100644
--- a/src/SalmonEgg.Cli/SalmonEgg.Cli.csproj
+++ b/src/SalmonEgg.Cli/SalmonEgg.Cli.csproj
@@ -30,10 +30,12 @@