From d84ed2cde1ec02158b8da63317585d42b59c28f5 Mon Sep 17 00:00:00 2001 From: Shangxin Date: Fri, 4 Sep 2026 01:17:07 +0000 Subject: [PATCH 01/18] refactor(release): extract the CLI publish every installer will embed The self-contained single-file publish is about to be consumed by four packaging chains -- Windows MSIX, the Windows desktop MSI, the macOS app bundle and a Linux deb -- and not only by the standalone archive it was written for. Leaving the publish inside the archive script would mean each chain grew its own copy, and four independently produced binaries that all build successfully is exactly the shape in which they drift apart. publish-cli-binary.sh now owns the publish, the runtime-identifier allow-list check and the single-file assertion; build-cli-artifacts.sh owns only staging, archiving and the checksum sidecar. The publish reports its executable path and MinVer-derived version through the same key=value protocol GitHub Actions uses for step outputs, so the wrapper reads them back from a temporary file instead of parsing log lines, and its own GITHUB_OUTPUT is untouched because the assignment is scoped to the child. Verified on this host with --rid linux-arm64 --allow-unsupported-rid: the archive and sidecar land where they did before and the executable path is unchanged, so the release workflow's step outputs still resolve. Reverse-verified that an unsupported RID rejected inside the publish script fails the wrapper too and leaves no archive behind. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/release/build-cli-artifacts.sh | 104 +++++++----------- scripts/release/publish-cli-binary.sh | 141 +++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 68 deletions(-) create mode 100755 scripts/release/publish-cli-binary.sh diff --git a/scripts/release/build-cli-artifacts.sh b/scripts/release/build-cli-artifacts.sh index 0a6fada86..6b8d133d5 100755 --- a/scripts/release/build-cli-artifacts.sh +++ b/scripts/release/build-cli-artifacts.sh @@ -1,15 +1,12 @@ #!/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. +# Packages the SalmonEgg CLI as a standalone release archive with a SHA-256 sidecar. # -# 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. +# The publish itself lives in publish-cli-binary.sh, because the same self-contained single-file +# executable is embedded by every SalmonEgg installer. Duplicating the publish here would let the +# standalone archive and the bundled command drift apart while both still built successfully. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -CLI_PROJECT="$REPO_ROOT/src/SalmonEgg.Cli/SalmonEgg.Cli.csproj" -DOTNET_BIN="${DOTNET_BIN:-dotnet}" RID="" CONFIGURATION="Release" @@ -49,16 +46,6 @@ if [ -z "$RID" ]; then exit 2 fi -read_project_property() { - "$DOTNET_BIN" msbuild "$CLI_PROJECT" "-getProperty:$1" -nologo | tr -d '\r' | tail -n 1 -} - -# Release identity is derived from the git tag by MinVer, so the property only holds a version once -# the MinVer target has executed; a plain -getProperty evaluation would return the pre-MinVer default. -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() { @@ -73,67 +60,48 @@ write_sha256() { fi } -DISPLAY_VERSION="$(read_release_version SalmonEggDisplayVersion)" -SUPPORTED_RIDS="$(read_project_property SalmonEggCliSupportedRuntimeIdentifiers)" +# The publish step reports the executable path and release version through the same key=value protocol +# GitHub Actions uses for step outputs, so pointing GITHUB_OUTPUT at a temporary file reads them back +# without parsing human-readable log lines. The assignment is scoped to the child process, so this +# script's own GITHUB_OUTPUT (when running in CI) is untouched. +PUBLISH_METADATA="$(mktemp)" +trap 'rm -f "$PUBLISH_METADATA"' EXIT -case "$DISPLAY_VERSION" in - [0-9]*.[0-9]*.[0-9]*) ;; - *) echo "SalmonEggDisplayVersion must be a three-part numeric version, got: '$DISPLAY_VERSION'" >&2; exit 1 ;; -esac +publish_args=( + --rid "$RID" + --configuration "$CONFIGURATION" + --output "$REPO_ROOT/artifacts/cli-publish/$RID" +) +if [ "$ALLOW_UNSUPPORTED_RID" = "true" ]; then + publish_args+=(--allow-unsupported-rid) +fi +GITHUB_OUTPUT="$PUBLISH_METADATA" "$REPO_ROOT/scripts/release/publish-cli-binary.sh" "${publish_args[@]}" -case ";$SUPPORTED_RIDS;" in - *";$RID;"*) ;; - *) - if [ "$ALLOW_UNSUPPORTED_RID" != "true" ]; then - echo "Unsupported runtime identifier '$RID'. Supported values: $SUPPORTED_RIDS" >&2 - exit 1 - fi - echo "[warn] '$RID' is outside the support matrix ($SUPPORTED_RIDS); output is for local verification only." >&2 - ;; -esac +read_publish_metadata() { + local key="$1" value + value="$(sed -n "s/^$key=//p" "$PUBLISH_METADATA")" + if [ -z "$value" ]; then + echo "publish-cli-binary.sh did not report '$key'." >&2 + return 1 + fi + printf '%s\n' "$value" +} + +EXECUTABLE_PATH="$(read_publish_metadata executable-path)" +DISPLAY_VERSION="$(read_publish_metadata display-version)" +EXECUTABLE_NAME="$(basename "$EXECUTABLE_PATH")" case "$RID" in - win-*) EXECUTABLE_NAME="salmon-egg.exe"; ARCHIVE_FORMAT="zip" ;; - *) EXECUTABLE_NAME="salmon-egg"; ARCHIVE_FORMAT="tar.gz" ;; + win-*) ARCHIVE_FORMAT="zip" ;; + *) ARCHIVE_FORMAT="tar.gz" ;; 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" -rm -rf "$PUBLISH_DIR" "$STAGING_ROOT" -mkdir -p "$PUBLISH_DIR" "$STAGING_DIR" "$OUTPUT_DIR" - -echo "[cli-release] Publish $RID ($CONFIGURATION) version $DISPLAY_VERSION" -publish_args=( - publish "$CLI_PROJECT" - -c "$CONFIGURATION" - -r "$RID" - -p:IsCliReleaseBuild=true - -o "$PUBLISH_DIR" - -v minimal -) -if [ "$ALLOW_UNSUPPORTED_RID" = "true" ]; then - publish_args+=(-p:SalmonEggCliAllowUnsupportedRuntimeIdentifier=true) -fi -"$DOTNET_BIN" "${publish_args[@]}" - -EXECUTABLE_PATH="$PUBLISH_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 -# 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)" -if [ -n "$unexpected" ]; then - echo "Unexpected files in single-file publish output:" >&2 - echo "$unexpected" >&2 - exit 1 -fi +rm -rf "$STAGING_ROOT" +mkdir -p "$STAGING_DIR" "$OUTPUT_DIR" cp "$EXECUTABLE_PATH" "$STAGING_DIR/$EXECUTABLE_NAME" chmod +x "$STAGING_DIR/$EXECUTABLE_NAME" diff --git a/scripts/release/publish-cli-binary.sh b/scripts/release/publish-cli-binary.sh new file mode 100755 index 000000000..68e8484a2 --- /dev/null +++ b/scripts/release/publish-cli-binary.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# 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 ship a bundled command. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CLI_PROJECT="$REPO_ROOT/src/SalmonEgg.Cli/SalmonEgg.Cli.csproj" +DOTNET_BIN="${DOTNET_BIN:-dotnet}" + +RID="" +CONFIGURATION="Release" +OUTPUT_DIR="" +ALLOW_UNSUPPORTED_RID="false" + +usage() { + cat <<'USAGE' +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 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. + display-version Three-part release version derived from the git tag by MinVer. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --rid) RID="${2:?--rid requires a value}"; shift 2 ;; + --rid=*) RID="${1#*=}"; shift ;; + --configuration) CONFIGURATION="${2:?--configuration requires a value}"; shift 2 ;; + --configuration=*) CONFIGURATION="${1#*=}"; shift ;; + --output) OUTPUT_DIR="${2:?--output requires a value}"; shift 2 ;; + --output=*) OUTPUT_DIR="${1#*=}"; shift ;; + --allow-unsupported-rid) ALLOW_UNSUPPORTED_RID="true"; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [ -z "$RID" ]; then + echo "--rid is required." >&2 + usage >&2 + exit 2 +fi + +read_project_property() { + "$DOTNET_BIN" msbuild "$CLI_PROJECT" "-getProperty:$1" -nologo | tr -d '\r' | tail -n 1 +} + +# Release identity is derived from the git tag by MinVer, so the property only holds a version once +# the MinVer target has executed; a plain -getProperty evaluation would return the pre-MinVer default. +read_release_version() { + "$DOTNET_BIN" msbuild "$CLI_PROJECT" -restore -t:MinVer "-getProperty:$1" -nologo | tr -d '\r' | tail -n 1 +} + +DISPLAY_VERSION="$(read_release_version SalmonEggDisplayVersion)" +SUPPORTED_RIDS="$(read_project_property SalmonEggCliSupportedRuntimeIdentifiers)" + +case "$DISPLAY_VERSION" in + [0-9]*.[0-9]*.[0-9]*) ;; + *) echo "SalmonEggDisplayVersion must be a three-part numeric version, got: '$DISPLAY_VERSION'" >&2; exit 1 ;; +esac + +case ";$SUPPORTED_RIDS;" in + *";$RID;"*) ;; + *) + if [ "$ALLOW_UNSUPPORTED_RID" != "true" ]; then + echo "Unsupported runtime identifier '$RID'. Supported values: $SUPPORTED_RIDS" >&2 + exit 1 + fi + echo "[warn] '$RID' is outside the support matrix ($SUPPORTED_RIDS); output is for local verification only." >&2 + ;; +esac + +case "$RID" in + win-*) EXECUTABLE_NAME="salmon-egg.exe" ;; + *) EXECUTABLE_NAME="salmon-egg" ;; +esac + +if [ -z "$OUTPUT_DIR" ]; then + OUTPUT_DIR="$REPO_ROOT/artifacts/cli-bin/$RID" +fi + +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +echo "[cli-binary] Publish $RID ($CONFIGURATION) version $DISPLAY_VERSION" +publish_args=( + publish "$CLI_PROJECT" + -c "$CONFIGURATION" + -r "$RID" + -p:IsCliReleaseBuild=true + -o "$OUTPUT_DIR" + -v minimal +) +if [ "$ALLOW_UNSUPPORTED_RID" = "true" ]; then + publish_args+=(-p:SalmonEggCliAllowUnsupportedRuntimeIdentifier=true) +fi +"$DOTNET_BIN" "${publish_args[@]}" + +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 +# assemblies, symbols or native libraries beside it would be a silent regression back to a +# 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 + +chmod +x "$EXECUTABLE_PATH" + +echo "[cli-binary] executable: $EXECUTABLE_PATH" +echo "[cli-binary] version: $DISPLAY_VERSION" + +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "executable-path=$EXECUTABLE_PATH" + echo "display-version=$DISPLAY_VERSION" + } >> "$GITHUB_OUTPUT" +fi From b8ba76cd73b89e5c6ba109fc1d4b894081983245 Mon Sep 17 00:00:00 2001 From: Shangxin Date: Fri, 4 Sep 2026 01:55:30 +0000 Subject: [PATCH 02/18] feat(msix): ship the CLI in the app package and put it on PATH Installing SalmonEgg now installs the salmon-egg command. A packaged app cannot append to PATH -- the OS is the installer and writes no environment variables on the app's behalf -- so the MSIX registers a windows.appExecutionAlias instead. Windows materializes a stub under %LOCALAPPDATA%\Microsoft\WindowsApps, a directory it already keeps on the per-user PATH, and launching the stub starts the executable the extension names. EntryPoint="Windows.FullTrustApplication" is what makes that a classic process launch: as a UWP activation the command would lose the caller's console, arguments and exit code, which reads as a CLI that does nothing. The payload is passed in as SalmonEggBundledCliExecutable rather than added as a ProjectReference. It is a self-contained single-file publish for one runtime identifier; a ProjectReference would merge the CLI's assemblies into the app's own output, where they collide with the app's copies of the same shared projects and cannot be launched as a separate command at all. TargetPath uses a forward slash while Link uses a backslash, matching the font items above it: a backslash is an ordinary file-name character on Linux and macOS, so the same item would otherwise publish one file literally named "cli\salmon-egg" once the other packaging chains start using it. Three independent things have to hold for the command to work, and each fails silently on its own, so each is checked where it can be: - The manifest declaring the alias means MakeAppx rejects a package built without the payload, which is why the declaration is static rather than generated. - ValidateSalmonEggBundledCli fails at the front of packaging with the command to run, instead of leaving MakeAppx to report a missing package file minutes later. It also rejects a payload under any other file name, since the alias names a literal one, and an MSYS path that MSBuild cannot open. - The MSIX contract gate now asserts, on the built package, that exactly one alias extension exists, under the product's command name, with the full-trust entry point, pointing at an executable the package really carries. Verified: the gate's self-test covers 19 package cases, six of them new and each reverse-verified to reject exactly one of these defects (payload absent, alias absent, alias renamed, entry point changed, two alias extensions, alias path disagreeing with where the payload landed). Cross-verified against the real Package.appxmanifest by synthesizing a package from it -- accepted -- and the same manifest with the alias block deleted -- rejected as AppExecutionAliasMissing -- so the gate and the manifest agree rather than each being self-consistent. The content item's TargetPath was read back from MSBuild evaluation. Not verified on this host: installing the package and running salmon-egg from a Windows shell needs Windows. The PR-level MSIX gate builds the package on every change, so the payload and alias assertions run there; the alias resolving through WindowsApps at runtime is a release-time manual check. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/platform-build-gates.yml | 17 ++ .github/workflows/release-packaging.yml | 17 ++ .tools/run-winui3-msix.ps1 | 44 +++++ SalmonEgg/SalmonEgg/Package.appxmanifest | 28 ++- SalmonEgg/SalmonEgg/SalmonEgg.csproj | 56 ++++++ .../gates/run-msix-package-contract-gate.ps1 | 166 +++++++++++++++++- scripts/release/publish-cli-binary.sh | 12 ++ 7 files changed, 336 insertions(+), 4 deletions(-) diff --git a/.github/workflows/platform-build-gates.yml b/.github/workflows/platform-build-gates.yml index c3362466d..b59691d2f 100644 --- a/.github/workflows/platform-build-gates.yml +++ b/.github/workflows/platform-build-gates.yml @@ -160,6 +160,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 +189,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..394147cac 100644 --- a/.github/workflows/release-packaging.yml +++ b/.github/workflows/release-packaging.yml @@ -254,6 +254,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 +317,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 ` 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/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/SalmonEgg.csproj b/SalmonEgg/SalmonEgg/SalmonEgg.csproj index dfa1d9bb6..a030a9645 100644 --- a/SalmonEgg/SalmonEgg/SalmonEgg.csproj +++ b/SalmonEgg/SalmonEgg/SalmonEgg.csproj @@ -304,6 +304,36 @@ + + + + + $(BaseIntermediateOutputPath)generated\windows\ $(SalmonEggGeneratedManifestDirectory)Package.appxmanifest @@ -381,6 +411,32 @@ + + + + + + + + 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/release/publish-cli-binary.sh b/scripts/release/publish-cli-binary.sh index 68e8484a2..083a3975a 100755 --- a/scripts/release/publish-cli-binary.sh +++ b/scripts/release/publish-cli-binary.sh @@ -32,6 +32,7 @@ Options: 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 } @@ -130,12 +131,23 @@ fi chmod +x "$EXECUTABLE_PATH" +# 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 + 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 "executable-path=$EXECUTABLE_PATH" + echo "executable-path-native=$NATIVE_EXECUTABLE_PATH" echo "display-version=$DISPLAY_VERSION" } >> "$GITHUB_OUTPUT" fi From 165854eb494959c5193f4618370a9992247e9853 Mon Sep 17 00:00:00 2001 From: Shangxin Date: Fri, 4 Sep 2026 02:17:31 +0000 Subject: [PATCH 03/18] feat(msi): register the bundled CLI on PATH from the desktop installer The Windows desktop MSI now installs the salmon-egg command alongside the app and appends its directory to the user PATH, so the two Windows installers behave the same way. Windows Installer owns that value: written on install, removed on uninstall, which a script calling setx or editing the registry cannot guarantee. The command goes into a `cli` subdirectory of the install folder and the PATH row names that subdirectory, not the install folder. Registering the install folder would put every DLL shipped beside the app on PATH to expose one executable. That also means the CLI cannot be harvested: heat generates directory identifiers that change with the tree, and the PATH row has to name one, so the file and its Environment element are authored explicitly and the step now fails if a cli directory reaches the harvest at all -- two components declaring the same file is a duplicate light rejects, and an ICE error is a worse way to learn about it. The PATH encoding rule was already written down and rehearsed for the CLI-only MSI, so it moves rather than being restated: CliMsiPathContract.ps1 becomes MsiPathContract.ps1 with the command directory as a parameter, since that is the only part that differs between the two packages. Everything else -- which prefix characters mean set and remove, where the [~] marker has to sit, why a trailing marker prepends -- is one rule with one set of violation identifiers. DesktopMsiContract.ps1 now also asserts, on the built package, that the File table carries salmon-egg.exe and that the Environment table holds exactly one row which satisfies that rule for [CLIFOLDER]. Both halves fail silently on their own: a package that lost the CLI still installs a working app whose PATH entry points at an empty directory, and a package that ships the CLI without the row leaves the user a file they cannot invoke. The Environment table is read as Name/Value pairs rather than two column reads, because which value belongs to which variable is the whole question. Verified: the PATH gate covers 12 rows (two new, for the desktop shape and for registering the app folder by mistake) and the desktop gate 17 packages (six new: missing command payload, no PATH row, a second unreviewed row, prepending, surviving uninstall, wrong directory), each reverse-verified to the exact violation identifier. The Environment query is now part of the set the gate replays through a fake OpenView that rejects out-of-grammar SQL, which is what kept the last two release builds from dying inside OpenView. The authored WiX was parsed out of the workflow and checked as XML: CLIFOLDER nests inside INSTALLFOLDER, the component group targets it, the feature references it, and the Environment element is the one WiX compiles to the row the gate calls conforming. Not verified on this host: building the MSI needs Windows and WiX, and installing it to watch PATH change needs an interactive session. That stays a release-time manual check. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci-core.yml | 9 +- .github/workflows/release-packaging.yml | 56 ++++++- docs/release-guide.md | 4 +- .../gates/run-desktop-msi-contract-gate.ps1 | 105 +++++++++++-- ...ate.ps1 => run-msi-path-contract-gate.ps1} | 60 +++++--- scripts/release/DesktopMsiContract.ps1 | 140 +++++++++++++++++- ...siPathContract.ps1 => MsiPathContract.ps1} | 75 +++++----- scripts/release/build-cli-msi.ps1 | 9 +- 8 files changed, 377 insertions(+), 81 deletions(-) rename scripts/gates/{run-cli-msi-path-contract-gate.ps1 => run-msi-path-contract-gate.ps1} (59%) rename scripts/release/{CliMsiPathContract.ps1 => MsiPathContract.ps1} (61%) diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index c0a861dc9..55a408ff9 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -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 diff --git a/.github/workflows/release-packaging.yml b/.github/workflows/release-packaging.yml index 394147cac..ec30b1a97 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 diff --git a/docs/release-guide.md b/docs/release-guide.md index 1d5af411f..e894f75d1 100644 --- a/docs/release-guide.md +++ b/docs/release-guide.md @@ -317,7 +317,7 @@ scripts/gates/run-cli-release-artifact-smoke.sh artifacts/cli-publish/linux-x64/ scripts/gates/run-cli-linux-package-smoke.sh artifacts/cli/salmon-egg-cli-1.0.5_amd64.deb # Windows MSI PATH 注册规则的正反例门禁(纯字符串逻辑,任意平台可跑,无需 WiX) -pwsh -NoProfile -File scripts/gates/run-cli-msi-path-contract-gate.ps1 +pwsh -NoProfile -File scripts/gates/run-msi-path-contract-gate.ps1 ``` 前两个门禁必须使用**本次构建产出**的产物;`dotnet run` 不是有效验证口径。第三个门禁验证的是规则本身而非产物,因此不受此限制。 @@ -338,7 +338,7 @@ pwsh -NoProfile -File scripts/gates/run-cli-msi-path-contract-gate.ps1 | 值以 `[~]` + 分隔符开头 | 缺 `[~]` 会整体覆盖 PATH(MSI 文档明确警告可能导致机器无法启动);`[~]` 出现在结尾则是前置插入,会遮蔽用户原有工具 | | 值引用 `[INSTALLFOLDER]` | 加进 PATH 的不是本包的安装目录 | - 规则本身在 `scripts/release/CliMsiPathContract.ps1`,与读取 MSI 的 COM 代码分离,因此 `scripts/gates/run-cli-msi-path-contract-gate.ps1` 能在任意平台(含 Linux)直接用正反例跑这条规则;该门禁在 `ci-core.yml` 的每次 push / PR 上执行,不必等到打 tag 才发现规则被削弱。 + 规则本身在 `scripts/release/MsiPathContract.ps1`,与读取 MSI 的 COM 代码分离,因此 `scripts/gates/run-msi-path-contract-gate.ps1` 能在任意平台(含 Linux)直接用正反例跑这条规则;该门禁在 `ci-core.yml` 的每次 push / PR 上执行,不必等到打 tag 才发现规则被削弱。 - 上述断言只覆盖包内表结构。真实安装 / 卸载需要交互式 Windows 会话,不在 CI 覆盖范围:发布 Windows 安装包前仍需手工确认安装后新开终端 `where salmon-egg` 命中安装目录、卸载后命令消失、且用户原有 PATH 条目完好无残留重复项。 --- 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-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/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-msi.ps1 b/scripts/release/build-cli-msi.ps1 index 2423992fe..d0e241724 100644 --- a/scripts/release/build-cli-msi.ps1 +++ b/scripts/release/build-cli-msi.ps1 @@ -112,10 +112,10 @@ if (-not (Test-Path -LiteralPath $msiPath)) { # 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 +# The rule itself lives in MsiPathContract.ps1, which documents the MSI encoding it enforces, so that +# scripts/gates/run-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') +. (Join-Path $PSScriptRoot 'MsiPathContract.ps1') $installer = New-Object -ComObject WindowsInstaller.Installer $database = $installer.OpenDatabase($msiPath, 0) @@ -148,7 +148,8 @@ try { "exactly one PATH entry. Rows: $described") } - Assert-CliMsiPathContract -Name $rows[0].Name -Value $rows[0].Value + # This package's install folder is the command directory itself, so the row must name INSTALLFOLDER. + Assert-MsiPathContract -Name $rows[0].Name -Value $rows[0].Value -DirectoryToken '[INSTALLFOLDER]' Write-Host "[cli-msi] verified PATH registration: Name='$($rows[0].Name)' Value='$($rows[0].Value)'" } finally { From 8347212dbb16ddf2f2a0b9de06c915f54e7910bc Mon Sep 17 00:00:00 2001 From: Shangxin Date: Fri, 4 Sep 2026 02:50:21 +0000 Subject: [PATCH 04/18] feat(linux): package the desktop app and the CLI as one Debian package Linux had no installer for the app at all -- only an unpackaged publish directory -- so there was nothing to register a command with. This adds the package, and installing it installs both halves: the app under /opt/salmon-egg and salmon-egg on PATH. Layout follows the FHS. /opt is where an add-on application that ships its own runtime belongs, which is what a self-contained .NET publish is. /usr/bin/salmon-egg is a symlink into that tree, so nothing edits a shell profile: dpkg owns the link and takes it away on purge, while an edited .bashrc is not reversible. The link is relative and two levels deep, because one level resolves to /usr/opt and dangles -- which the package's file list cannot show, since it records only the link text. Also shipped: a desktop entry, so the GUI is launchable from the shell's menu and registered for the same s8p scheme the Windows package declares as a protocol, and the app icon at all five sizes Uno's Resizetizer produced for this build. Those are used rather than the source artwork because the source is 200x200, which is not a size the hicolor theme defines -- an icon in a non-theme directory gets rescaled twice or ignored. StartupWMClass is deliberately absent: a wrong value silently breaks the taskbar's window-to-launcher association. The dependency list could not be derived from the build. dpkg-shlibdeps and readelf see only link-time NEEDED entries, and everything above libstdc++ is loaded at runtime by P/Invoke or by the graphics stack, so the publish output declares none of it. The list was read out of /proc//maps while the published app ran headless under Xvfb: ICU, OpenSSL, brotli, the X11 family, GL/EGL, GLib, freetype and GStreamer are all mapped at startup. Alternative dependencies rather than pinned ones, because Microsoft's own .NET debs name a single libicu only by building one deb per distribution, and Ubuntu 24.04's t64 transition renamed several of these. Conflicts/Replaces on the old salmon-egg-cli package, whose /usr/bin/salmon-egg this one now owns. Verified end to end on this host, which is the point of doing Linux first: the package installs through apt (so every declared dependency name is real, not merely present), the symlink resolves to the CLI, `salmon-egg --version` runs in a clean login shell and matches the package version, the desktop entry's Exec and Icon both resolve, and purge removes all of it -- 16 checks. Reverse-verified by rebuilding the package with the symlink one level too shallow (4 checks fail, including the resolution assertion) and with the desktop entry naming a renamed executable (exactly that check fails), and by running the build script against a publish output with no bundled CLI (exit 1 with the command to fix it). The smoke gate now runs on every push through platform-build-gates, where the Linux publish switched from framework-dependent to self-contained: that is the shape the package ships, and the old one proved something about a layout nobody installs. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/platform-build-gates.yml | 28 +- .github/workflows/release-packaging.yml | 74 ++++- .../gates/run-desktop-linux-package-smoke.sh | 230 +++++++++++++++ scripts/release/build-desktop-deb.sh | 274 ++++++++++++++++++ 4 files changed, 604 insertions(+), 2 deletions(-) create mode 100755 scripts/gates/run-desktop-linux-package-smoke.sh create mode 100755 scripts/release/build-desktop-deb.sh diff --git a/.github/workflows/platform-build-gates.yml b/.github/workflows/platform-build-gates.yml index b59691d2f..05ed93c7d 100644 --- a/.github/workflows/platform-build-gates.yml +++ b/.github/workflows/platform-build-gates.yml @@ -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, diff --git a/.github/workflows/release-packaging.yml b/.github/workflows/release-packaging.yml index ec30b1a97..91f4cc812 100644 --- a/.github/workflows/release-packaging.yml +++ b/.github/workflows/release-packaging.yml @@ -511,6 +511,65 @@ jobs: name: desktop-macos-dmg path: publish/dmg/*.dmg + 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 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + global-json-file: global.json + + # 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 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 + id: build-deb + run: >- + 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 + run: scripts/gates/run-desktop-linux-package-smoke.sh "${{ steps.build-deb.outputs.deb-path }}" + + - name: Upload Linux desktop package artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: desktop-linux-deb + path: | + artifacts/desktop/*.deb + artifacts/desktop/*.sha256 + if-no-files-found: error + 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 @@ -599,7 +658,7 @@ jobs: 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, package-cli] runs-on: ubuntu-latest timeout-minutes: 20 # Only a tag build publishes. workflow_dispatch remains available for rehearsing the packaging jobs @@ -664,6 +723,13 @@ jobs: name: desktop-macos-dmg path: macos-dmg + - name: Download Linux desktop package artifact + if: needs.package-linux-desktop.result == 'success' + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: desktop-linux-deb + path: linux-deb + - name: Download CLI artifacts uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: @@ -727,6 +793,12 @@ 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" 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..5a581a8e3 --- /dev/null +++ b/scripts/gates/run-desktop-linux-package-smoke.sh @@ -0,0 +1,230 @@ +#!/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" + 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/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 From 7b7b780d8b3d2ab3c2b1b0b439870cea171bcc79 Mon Sep 17 00:00:00 2001 From: Shangxin Date: Fri, 4 Sep 2026 03:09:09 +0000 Subject: [PATCH 05/18] feat(macos): ship the CLI in the app bundle and add a pkg that puts it on PATH macOS had no way to register a command at all. A .dmg is dragged, so it has no install hook, and Uno can build a .pkg but its PackageAppBundle task accepts no scripts parameter, so that package cannot carry a postinstall either. This adds a pkgbuild step whose postinstall symlinks the bundled command into /usr/local/bin, which macOS keeps on the default PATH through /etc/paths -- the smallest thing that makes `salmon-egg` resolve without editing anyone's shell profile. The command reaches the bundle by being part of the publish Uno builds the bundle from, and it has to be there before signing: adding a Mach-O to a signed bundle invalidates the signature, so the .dmg gets it too and its users can link it by hand. Which directory inside the bundle it lands in is Uno's decision, so it is probed rather than assumed. Dissecting the shipped v1.4.2 bundle shows the split GenerateAppBundle performs: the apphost, its deps.json and runtimeconfig.json, and every .dylib go to Contents/MacOS (19 files); managed assemblies, satellite resource directories and asset subdirectories go to Contents/Resources with relative paths intact (589 files). A cli/ subdirectory holding one extension-less Mach-O matches neither pattern exactly, and the split is not documented, so the postinstall, the pkg builder and the bundle contract all accept either location and report which one they found. MacOS is preferred where present, because that is where Apple expects auxiliary executables. The pkg identifier is read from the bundle's own CFBundleIdentifier rather than repeated, since two identifiers for one product would let the installer treat an upgrade as a second independent install. Installer signing needs a Developer ID Installer certificate, which is a different one from the app and disk-image identities, so it is optional the same way the .dmg's is: MACOS_PKG_CODESIGN_KEY when configured, an unsigned package otherwise. Verified: the PATH gate drives the real postinstall against fake roots -- 15 checks, both bundle layouts, and reverse-verified for a bundle with no command (refused, no link left behind), a dangling link from an earlier version (replaced, which is why the script tests -L and not just -e), a regular file occupying the link path (replaced), and a second run (idempotent, as a reinstall is). The bundle contract gate gained the command assertion with cases for both layouts, for an absent command and for one whose mode bit was dropped. Both run on every push through ci-core. Not verified on this host: pkgbuild, productsign and installing the package need macOS. Two release-time checks remain manual: that notarization accepts a Mach-O in whichever directory Uno placed it, and that the link resolves in a fresh login shell. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci-core.yml | 7 + .github/workflows/release-packaging.yml | 65 +++++++ scripts/gates/run-macos-pkg-contract-gate.sh | 165 ++++++++++++++++++ .../run-release-artifact-contract-gate.sh | 49 +++++- scripts/release/build-macos-pkg.sh | 161 +++++++++++++++++ scripts/release/macos-pkg-postinstall.sh | 64 +++++++ 6 files changed, 508 insertions(+), 3 deletions(-) create mode 100755 scripts/gates/run-macos-pkg-contract-gate.sh create mode 100755 scripts/release/build-macos-pkg.sh create mode 100755 scripts/release/macos-pkg-postinstall.sh diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 55a408ff9..d0b6d3669 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -127,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/release-packaging.yml b/.github/workflows/release-packaging.yml index 91f4cc812..3cb9802fe 100644 --- a/.github/workflows/release-packaging.yml +++ b/.github/workflows/release-packaging.yml @@ -408,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 @@ -420,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 = @() @@ -436,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 @@ -452,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 = @() @@ -467,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 @@ -498,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: @@ -716,6 +768,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 @@ -772,6 +831,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) 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..3ffb3f325 --- /dev/null +++ b/scripts/gates/run-macos-pkg-contract-gate.sh @@ -0,0 +1,165 @@ +#!/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 + +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 + +# 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-release-artifact-contract-gate.sh b/scripts/gates/run-release-artifact-contract-gate.sh index 928b85b52..325353030 100755 --- a/scripts/gates/run-release-artifact-contract-gate.sh +++ b/scripts/gates/run-release-artifact-contract-gate.sh @@ -161,7 +161,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 ---------------------------------------------------------------------------------------- @@ -212,7 +232,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 +247,10 @@ 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. + mkdir -p "$app/Contents/$area/cli" + printf 'bin' > "$app/Contents/$area/cli/salmon-egg" + chmod +x "$app/Contents/$area/cli/salmon-egg" } write_partial_plist() { @@ -325,8 +349,10 @@ 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" + printf 'bin' > "$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 +372,23 @@ 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" + 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/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 From bc00ff4e3caa99bfdb851d8a20f2b013f58055f6 Mon Sep 17 00:00:00 2001 From: Shangxin Date: Fri, 4 Sep 2026 03:34:57 +0000 Subject: [PATCH 06/18] feat(release)!: retire the standalone CLI distribution The command now ships inside every SalmonEgg installer, so publishing it a second time as its own package would mean two delivery paths for one binary, two sets of install instructions, and two versions a user could end up with. Removed: the package-cli job and its three-runner matrix, the CLI MSI, the CLI deb, the Homebrew formula, the release archives and their checksum sidecars, and the CLI install smoke gate whose job the desktop package's own smoke now does. BREAKING CHANGE: `salmon-egg` is no longer published on its own. Every platform gets it by installing SalmonEgg. On Linux that means the desktop package pulls in the graphics stack the GUI needs, so a headless server can no longer install only the command -- if that use case matters, the deb would have to be split into a CLI package the GUI depends on, which is a decision this change deliberately does not make on its own. What stayed, because both are now load-bearing for four packaging chains instead of one: publish-cli-binary.sh, which produces the binary each installer embeds, and run-cli-release-artifact-smoke.sh, which proves that binary starts with no .NET on PATH and holds its exit-code and credential contracts. The PATH-encoding rule stayed too, now serving the desktop MSI. The support matrix in SalmonEgg.Cli.csproj kept its three runtime identifiers and changed meaning: they are the platforms whose installer embeds a command, so a RID outside the list has no delivery path at all. The publish job lost its checkout and SDK setup along with the CLI asset step that needed them -- it now works purely on downloaded artifacts and gh. That also retires the ordering hazard those two carried: `Setup .NET` ahead of the checkout resolved global.json against an empty workspace and once killed a tag build after all seven packaging jobs had passed. GitHubWorkflowContractTests replaces the MSYS-path test, which pinned a PowerShell regex at the CLI MSI call site, with one asserting that the translation lives in the publish script and that every Windows consumer takes its native-path output. A new test pins the mechanism each of the four installers uses to register the command, so any one of them silently ceasing to do it fails here. Verified: 16 workflow contract tests pass, and the smoke-script count assertion caught its own off-by-one first, which is how I know it reads the workflow rather than passing vacuously. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release-packaging.yml | 183 ++---------------- README.en.md | 21 +- README.md | 16 +- scripts/gates/run-cli-linux-package-smoke.sh | 147 -------------- .../run-release-artifact-contract-gate.sh | 2 +- scripts/release/build-cli-artifacts.sh | 143 -------------- scripts/release/build-cli-deb.sh | 130 ------------- scripts/release/build-cli-homebrew-formula.sh | 131 ------------- scripts/release/build-cli-msi.ps1 | 168 ---------------- src/SalmonEgg.Cli/SalmonEgg.Cli.csproj | 10 +- .../Build/GitHubWorkflowContractTests.cs | 100 ++++++++-- 11 files changed, 127 insertions(+), 924 deletions(-) delete mode 100755 scripts/gates/run-cli-linux-package-smoke.sh delete mode 100755 scripts/release/build-cli-artifacts.sh delete mode 100755 scripts/release/build-cli-deb.sh delete mode 100755 scripts/release/build-cli-homebrew-formula.sh delete mode 100644 scripts/release/build-cli-msi.ps1 diff --git a/.github/workflows/release-packaging.yml b/.github/workflows/release-packaging.yml index 3cb9802fe..1ce7269b1 100644 --- a/.github/workflows/release-packaging.yml +++ b/.github/workflows/release-packaging.yml @@ -228,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: | @@ -622,95 +622,9 @@ jobs: artifacts/desktop/*.sha256 if-no-files-found: error - 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 - steps: - - name: Checkout - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - fetch-depth: 0 - - - name: Setup .NET - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - 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 }} - - - name: Smoke the published CLI executable - shell: bash - run: scripts/gates/run-cli-release-artifact-smoke.sh "${{ steps.build-cli.outputs.executable-path }}" - - - 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 }} - - - 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 '/', '\')" - } - - ./scripts/release/build-cli-msi.ps1 -Executable $executable -Version $env:CLI_VERSION - - - name: Upload CLI artifacts - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 - with: - name: cli-${{ matrix.rid }} - path: | - artifacts/cli/*.tar.gz - artifacts/cli/*.zip - artifacts/cli/*.deb - artifacts/cli/*.msi - artifacts/cli/*.sha256 - if-no-files-found: error - publish-release-assets: name: Publish Release Assets - needs: [package-wasm, package-desktop, package-windows-msix, package-macos, package-linux-desktop, 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 @@ -720,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 @@ -789,13 +689,6 @@ jobs: name: desktop-linux-deb path: linux-deb - - name: Download CLI artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - with: - pattern: cli-* - merge-multiple: true - path: cli-artifacts - - name: Ensure GitHub release exists shell: bash env: @@ -869,58 +762,6 @@ jobs: 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/README.en.md b/README.en.md index 1c42f0553..a566af4b9 100644 --- a/README.en.md +++ b/README.en.md @@ -79,15 +79,18 @@ 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. - -| 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 | - -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 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). + +| 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` | + +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..e3ab9be8b 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,17 @@ 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` 等不属于正式支持范围:可交叉编译,但没有真实机器验证。 +`win-arm64`、`linux-arm64`、`osx-x64` 等不属于正式支持范围:可交叉编译,但没有真实机器验证。 ```bash salmon-egg --help 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-release-artifact-contract-gate.sh b/scripts/gates/run-release-artifact-contract-gate.sh index 325353030..21582f5ea 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: # diff --git a/scripts/release/build-cli-artifacts.sh b/scripts/release/build-cli-artifacts.sh deleted file mode 100755 index 6b8d133d5..000000000 --- a/scripts/release/build-cli-artifacts.sh +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env bash -# Packages the SalmonEgg CLI as a standalone release archive with a SHA-256 sidecar. -# -# The publish itself lives in publish-cli-binary.sh, because the same self-contained single-file -# executable is embedded by every SalmonEgg installer. Duplicating the publish here would let the -# standalone archive and the bundled command drift apart while both still built successfully. -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" - -RID="" -CONFIGURATION="Release" -OUTPUT_DIR="$REPO_ROOT/artifacts/cli" -ALLOW_UNSUPPORTED_RID="false" - -usage() { - cat <<'USAGE' -Usage: build-cli-artifacts.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. - --allow-unsupported-rid Publish a RID outside the support matrix for local verification only. - -h, --help Show this help. -USAGE -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --rid) RID="${2:?--rid requires a value}"; shift 2 ;; - --rid=*) RID="${1#*=}"; shift ;; - --configuration) CONFIGURATION="${2:?--configuration requires a value}"; shift 2 ;; - --configuration=*) CONFIGURATION="${1#*=}"; shift ;; - --output) OUTPUT_DIR="${2:?--output requires a value}"; shift 2 ;; - --output=*) OUTPUT_DIR="${1#*=}"; shift ;; - --allow-unsupported-rid) ALLOW_UNSUPPORTED_RID="true"; shift ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;; - esac -done - -if [ -z "$RID" ]; then - echo "--rid is required." >&2 - usage >&2 - exit 2 -fi - -# 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 -} - -# The publish step reports the executable path and release version through the same key=value protocol -# GitHub Actions uses for step outputs, so pointing GITHUB_OUTPUT at a temporary file reads them back -# without parsing human-readable log lines. The assignment is scoped to the child process, so this -# script's own GITHUB_OUTPUT (when running in CI) is untouched. -PUBLISH_METADATA="$(mktemp)" -trap 'rm -f "$PUBLISH_METADATA"' EXIT - -publish_args=( - --rid "$RID" - --configuration "$CONFIGURATION" - --output "$REPO_ROOT/artifacts/cli-publish/$RID" -) -if [ "$ALLOW_UNSUPPORTED_RID" = "true" ]; then - publish_args+=(--allow-unsupported-rid) -fi -GITHUB_OUTPUT="$PUBLISH_METADATA" "$REPO_ROOT/scripts/release/publish-cli-binary.sh" "${publish_args[@]}" - -read_publish_metadata() { - local key="$1" value - value="$(sed -n "s/^$key=//p" "$PUBLISH_METADATA")" - if [ -z "$value" ]; then - echo "publish-cli-binary.sh did not report '$key'." >&2 - return 1 - fi - printf '%s\n' "$value" -} - -EXECUTABLE_PATH="$(read_publish_metadata executable-path)" -DISPLAY_VERSION="$(read_publish_metadata display-version)" -EXECUTABLE_NAME="$(basename "$EXECUTABLE_PATH")" - -case "$RID" in - win-*) ARCHIVE_FORMAT="zip" ;; - *) ARCHIVE_FORMAT="tar.gz" ;; -esac - -PACKAGE_NAME="salmon-egg-cli-$DISPLAY_VERSION-$RID" -STAGING_ROOT="$REPO_ROOT/artifacts/cli-staging/$RID" -STAGING_DIR="$STAGING_ROOT/$PACKAGE_NAME" - -rm -rf "$STAGING_ROOT" -mkdir -p "$STAGING_DIR" "$OUTPUT_DIR" - -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 - -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" -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" - -if [ -n "${GITHUB_OUTPUT:-}" ]; then - { - echo "display-version=$DISPLAY_VERSION" - echo "executable-path=$EXECUTABLE_PATH" - echo "archive-path=$ARCHIVE_PATH" - } >> "$GITHUB_OUTPUT" -fi 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 d0e241724..000000000 --- a/scripts/release/build-cli-msi.ps1 +++ /dev/null @@ -1,168 +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 MsiPathContract.ps1, which documents the MSI encoding it enforces, so that -# scripts/gates/run-msi-path-contract-gate.ps1 can exercise the rule — and each of its failure -# cases — without WiX or a Windows session. -. (Join-Path $PSScriptRoot 'MsiPathContract.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") - } - - # This package's install folder is the command directory itself, so the row must name INSTALLFOLDER. - Assert-MsiPathContract -Name $rows[0].Name -Value $rows[0].Value -DirectoryToken '[INSTALLFOLDER]' - 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/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 @@ + + + + + +