From 2ada0f83c32c988e5e64bd791ae68ac71cdc6d7a Mon Sep 17 00:00:00 2001 From: Roger Zhang Date: Fri, 8 May 2026 13:09:45 -0700 Subject: [PATCH 1/5] feat: support nightly release and Windows MSI installer Adds two new capabilities to the native installer path: - "version: nightly" downloads from the sam-cli-nightly tag. The tag mutates daily, so caching is intentionally skipped. The Linux archive ships sam-nightly instead of sam, so a sam symlink is created. - Windows runners are now supported via the official AWS_SAM_CLI_64_PY3.msi. Installs run msiexec silently, treat exit 3010 (reboot required) as success, and resolve the install dir from %ProgramFiles%. For nightly on Windows, sam-nightly.{cmd,exe} is copied to sam.{cmd,exe} so users can call sam consistently across releases. README also documents that SAM CLI is preinstalled on every GitHub-hosted runner image, so users only need this action when they need a specific version, the nightly release, or a self-hosted runner. Closes #129 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 42 +++++++ README.md | 39 ++++++- action.yml | 2 +- dist/index.js | 202 +++++++++++++++++++++++++++++--- lib/setup.js | 202 +++++++++++++++++++++++++++++--- test/setup.test.js | 234 +++++++++++++++++++++++++++++++++++++ 6 files changed, 685 insertions(+), 36 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29ff668..9ff6806 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,6 +56,48 @@ jobs: sam --version | grep -F "$version" shell: bash + - name: Test official installer (nightly version) + uses: ./ + with: + use-installer: true + version: nightly + - run: sam --version + + native-installer-windows: + strategy: + fail-fast: false + matrix: + os: + - windows-latest + - windows-2022 + name: Native installer (MSI) / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + + - name: Test MSI installer (pinned version) + uses: ./ + with: + use-installer: true + version: "1.139.0" + - run: sam --version | findstr /C:"1.139.0" + shell: cmd + + - name: Test MSI installer (latest version) + uses: ./ + with: + use-installer: true + - run: sam --version + shell: cmd + + - name: Test MSI installer (nightly version) + uses: ./ + with: + use-installer: true + version: nightly + - run: sam --version + shell: cmd + integ: strategy: fail-fast: false diff --git a/README.md b/README.md index 2461a2c..575fe7e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,17 @@ Action to set up [AWS SAM CLI](https://docs.aws.amazon.com/serverless-applicatio This action enables you to run AWS SAM CLI commands in order to build, package, and deploy [serverless applications](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html) as part of your workflow. +## Do you need this action? + +The AWS SAM CLI is **preinstalled on every GitHub-hosted runner image** (Ubuntu, Windows, and macOS) — see the [`runner-images`](https://github.com/actions/runner-images) repository (e.g. [Ubuntu 24.04](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md), [Ubuntu 22.04](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2204-Readme.md), [Windows 2025](https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md), [macOS 15](https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md)) for the exact version shipped with each image. If your workflow only needs the version that comes with the runner, you can call `sam` directly without using this action. + +Use this action when you need: + +- A **specific version** of the SAM CLI (pinned via the `version` input). +- The **`nightly` release** of the SAM CLI to validate upcoming changes before they ship. +- A consistent SAM CLI version across runner image upgrades. +- The native installer on a runner where SAM CLI is not preinstalled (e.g. self-hosted runners). + ## Example Assuming you have a [`samconfig.toml`](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html) at the root of your repository: @@ -38,11 +49,29 @@ jobs: See [AWS IAM best practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html) for handling AWS credentials. +### Installing the nightly release + +To validate your project against unreleased changes to the AWS SAM CLI: + +```yaml +- uses: aws-actions/setup-sam@v3 + with: + use-installer: true + version: nightly +- run: sam --version +``` + ## Inputs ### `version` -The AWS SAM CLI version to install. Installs the latest version by default. +The AWS SAM CLI version to install. Installs the latest stable version by default. + +Accepts: + +- An exact version (`x.y.z`, e.g. `1.139.0`) — pinned install. +- A version pattern (`1.*`, `1.139.*`) — only when `use-installer` is `false` (resolved by `pip`). +- `nightly` — installs the latest [nightly release](https://github.com/aws/aws-sam-cli/releases/tag/sam-cli-nightly) of the SAM CLI. Requires `use-installer: true`. Nightly releases are not cached because the `sam-cli-nightly` tag is updated in place each day. ### `use-installer` @@ -50,8 +79,12 @@ The AWS SAM CLI version to install. Installs the latest version by default. > > This is the recommended approach on supported platforms. It does not require Python to be installed, and is faster than the default installation method. > -> Currently supports Linux x86-64 and aarch64 (ARM) runners. For ARM architecture, only versions 1.104.0 and above are supported. -> Set to `true` to set up AWS SAM CLI using a native installer. Defaults to `false`. +> Currently supports: +> +> - Linux x86-64 and aarch64 (ARM) — uses the official archive installer. For ARM, only versions 1.104.0 and above are supported. +> - Windows x86-64 — uses the official MSI installer (`AWS_SAM_CLI_64_PY3.msi`). + +Set to `true` to set up AWS SAM CLI using a native installer. Defaults to `false`. Required when `version` is set to `nightly`. ### `python` diff --git a/action.yml b/action.yml index 5004417..b4d4b30 100644 --- a/action.yml +++ b/action.yml @@ -5,7 +5,7 @@ branding: color: "orange" inputs: version: - description: "The AWS SAM CLI version to install" + description: 'The AWS SAM CLI version to install. Use "nightly" (with use-installer: true) to install the latest nightly release.' required: false python: description: "The Python interpreter to use for AWS SAM CLI" diff --git a/dist/index.js b/dist/index.js index 269aec8..4766ef2 100644 --- a/dist/index.js +++ b/dist/index.js @@ -130,6 +130,16 @@ function isSemver(s) { return /^\d+\.\d+\.\d+$/.test(s); } +const NIGHTLY = "nightly"; +const NIGHTLY_TAG = "sam-cli-nightly"; + +/** + * Returns whether a version string requests the nightly release. + */ +function isNightly(version) { + return version === NIGHTLY; +} + /** * Get latest SAM CLI version from https://api.github.com/repos/aws/aws-sam-cli/releases/latest * @@ -198,10 +208,12 @@ async function downloadAndCache(version, arch, installDir, cacheKey) { * Downloads SAM CLI without caching. * * @param {string} arch - The architecture (x86_64 or arm64). + * @param {string} releaseTag - Optional release tag (e.g. "sam-cli-nightly"). Defaults to latest. * @returns {Promise} The directory SAM CLI is installed in. */ -async function downloadWithoutCache(arch) { - const url = `https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-${arch}.zip`; +async function downloadWithoutCache(arch, releaseTag) { + const tagSegment = releaseTag ? `download/${releaseTag}` : "latest/download"; + const url = `https://github.com/aws/aws-sam-cli/releases/${tagSegment}/aws-sam-cli-linux-${arch}.zip`; const tempDir = mkdirTemp(); try { @@ -215,32 +227,161 @@ async function downloadWithoutCache(arch) { } /** - * Installs SAM CLI using the native installers. + * Builds the URL to the Windows MSI asset for a given release tag or version. * - * See https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html + * @param {string} tagOrVersion - Either a release tag (e.g. "sam-cli-nightly") + * or an empty string to use the "latest" alias. + * @param {boolean} isTag - True if the first arg is a release tag, false if version (x.y.z). + */ +function windowsMsiUrl(tagOrVersion, isTag) { + const asset = "AWS_SAM_CLI_64_PY3.msi"; + if (!tagOrVersion) { + return `https://github.com/aws/aws-sam-cli/releases/latest/download/${asset}`; + } + const tag = isTag ? tagOrVersion : `v${tagOrVersion}`; + return `https://github.com/aws/aws-sam-cli/releases/download/${tag}/${asset}`; +} + +/** + * Runs the MSI silently via msiexec. Throws on failure. * - * @param {string} inputVersion - The SAM CLI version to install. - * @param {string} token - Authentication Token to use for GITHUB Apis. - * @returns {Promise} The directory SAM CLI is installed in. + * @param {string} msiPath - Path to the downloaded MSI. + */ +async function runMsiExec(msiPath) { + // 3010 = ERROR_SUCCESS_REBOOT_REQUIRED; treat as success + const logPath = path.join(mkdirTemp(), "msi-install.log"); + const exitCode = await exec.exec( + "msiexec", + ["/i", msiPath, "/qn", "/norestart", "/l*v", logPath], + { ignoreReturnCode: true }, + ); + if (exitCode !== 0 && exitCode !== 3010) { + if (fs.existsSync(logPath)) { + const tail = fs.readFileSync(logPath, "utf8").split("\n").slice(-30); + core.warning( + `msiexec failed (${exitCode}); last log lines:\n${tail.join("\n")}`, + ); + } + throw new Error(`msiexec failed with exit code ${exitCode}`); + } +} + +/** + * Locates the SAM CLI bin directory created by the MSI install. + * + * Stable installs to `C:\Program Files\Amazon\AWSSAMCLI\bin`; nightly installs + * to `C:\Program Files\Amazon\AWSSAMCLI_NIGHTLY\bin`. + * + * @param {boolean} nightly - Whether the nightly MSI was installed. + */ +function findWindowsSamBinDir(nightly) { + const programFiles = process.env["ProgramFiles"] || "C:\\Program Files"; + const dir = path.join( + programFiles, + "Amazon", + nightly ? "AWSSAMCLI_NIGHTLY" : "AWSSAMCLI", + "bin", + ); + if (!fs.existsSync(dir)) { + throw new Error(`Expected SAM CLI install directory not found: ${dir}`); + } + return dir; +} + +/** + * Installs SAM CLI on Windows by downloading and running the MSI. + * + * @param {string} inputVersion - The SAM CLI version to install or "nightly". + * @returns {Promise} The directory containing sam.cmd / sam.exe. */ -// TODO: Support more platforms -async function installUsingNativeInstaller(inputVersion, token) { - // Validate platform - if (os.platform() !== "linux") { - core.setFailed("Only Linux is supported with use-installer: true"); +async function installWindowsNativeInstaller(inputVersion) { + // Validate version format (only x.y.z, "nightly", or empty are accepted) + if (inputVersion && !isNightly(inputVersion) && !isSemver(inputVersion)) { + core.setFailed('Version must be in the format x.y.z or "nightly"'); + return ""; + } + + const nightly = isNightly(inputVersion); + const url = nightly + ? windowsMsiUrl(NIGHTLY_TAG, true) + : windowsMsiUrl(inputVersion, false); + + if (nightly) { + core.info("Installing SAM CLI nightly release on Windows."); + } else { + core.info( + `Installing SAM CLI ${inputVersion || "latest"} on Windows via MSI.`, + ); + } + + try { + const msiPath = await tc.downloadTool(url); + await runMsiExec(msiPath); + } catch (error) { + core.setFailed(`Failed to install SAM CLI MSI: ${error.message}`); + return ""; + } + + let binDir; + try { + binDir = findWindowsSamBinDir(nightly); + } catch (error) { + core.setFailed(error.message); return ""; } + // Nightly MSI ships sam-nightly.{cmd,exe}; copy to sam.{cmd,exe} so users + // can invoke `sam` regardless of which release they install. + if (nightly) { + for (const ext of ["cmd", "exe"]) { + const src = path.join(binDir, `sam-nightly.${ext}`); + const dst = path.join(binDir, `sam.${ext}`); + if (fs.existsSync(src) && !fs.existsSync(dst)) { + fs.copyFileSync(src, dst); + } + } + } + + return binDir; +} + +/** + * Installs SAM CLI on Linux using the official native installer archive. + * + * @param {string} inputVersion - The SAM CLI version to install or "nightly". + * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The directory SAM CLI is installed in. + */ +async function installLinuxNativeInstaller(inputVersion, token) { if (os.arch() !== "x64" && os.arch() !== "arm64") { core.setFailed( - "Only x86-64 and aarch64 architectures are supported with use-installer: true", + "Only x86-64 and aarch64 architectures are supported with use-installer: true on Linux", ); return ""; } + const arch = os.arch() === "arm64" ? "arm64" : "x86_64"; + + // Nightly: download without caching since the "sam-cli-nightly" tag's + // contents change daily, so a cache hit would serve stale binaries. + if (isNightly(inputVersion)) { + core.info("Installing SAM CLI nightly release without caching."); + const binDir = await downloadWithoutCache(arch, NIGHTLY_TAG); + if (binDir) { + // The nightly archive ships `sam-nightly` instead of `sam`. Symlink so + // `sam` works on PATH without users having to change their commands. + const target = path.join(binDir, "sam-nightly"); + const link = path.join(binDir, "sam"); + if (fs.existsSync(target) && !fs.existsSync(link)) { + fs.symlinkSync(target, link); + } + } + return binDir; + } + // Validate version format if (inputVersion && !isSemver(inputVersion)) { - core.setFailed("Version must be in the format x.y.z"); + core.setFailed('Version must be in the format x.y.z or "nightly"'); return ""; } @@ -258,7 +399,6 @@ async function installUsingNativeInstaller(inputVersion, token) { } // Validate ARM64 version requirement - const arch = os.arch() === "arm64" ? "arm64" : "x86_64"; if (version && os.arch() === "arm64" && semverLt(version, "1.104.0")) { core.setFailed( "ARM64 installer is only available for versions 1.104.0 and above", @@ -293,14 +433,44 @@ async function installUsingNativeInstaller(inputVersion, token) { return await downloadWithoutCache(arch); } +/** + * Installs SAM CLI using the native installers. + * + * See https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html + * + * @param {string} inputVersion - The SAM CLI version to install. + * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The directory SAM CLI is installed in. + */ +// TODO: Support more platforms (macOS .pkg) +async function installUsingNativeInstaller(inputVersion, token) { + if (os.platform() === "linux") { + return await installLinuxNativeInstaller(inputVersion, token); + } + if (os.platform() === "win32") { + return await installWindowsNativeInstaller(inputVersion); + } + core.setFailed( + "use-installer: true is only supported on Linux and Windows runners", + ); + return ""; +} + async function setup() { - const version = getInput("version", /^[\d.*]*$/, ""); + const version = getInput("version", /^([\d.*]*|nightly)$/, ""); // python3 isn't standard on Windows const defaultPython = isWindows() ? "python" : "python3"; const python = getInput("python", /^.+$/, defaultPython); const useInstaller = core.getBooleanInput("use-installer"); const token = getInput("token", /^.*$/, ""); + if (isNightly(version) && !useInstaller) { + core.setFailed( + 'Installing the nightly release requires "use-installer: true". The aws-sam-cli nightly release is not published to PyPI.', + ); + return; + } + const binPath = useInstaller ? await installUsingNativeInstaller(version, token) : await installSamCli(python, version); diff --git a/lib/setup.js b/lib/setup.js index 5012442..9a199be 100644 --- a/lib/setup.js +++ b/lib/setup.js @@ -124,6 +124,16 @@ function isSemver(s) { return /^\d+\.\d+\.\d+$/.test(s); } +const NIGHTLY = "nightly"; +const NIGHTLY_TAG = "sam-cli-nightly"; + +/** + * Returns whether a version string requests the nightly release. + */ +function isNightly(version) { + return version === NIGHTLY; +} + /** * Get latest SAM CLI version from https://api.github.com/repos/aws/aws-sam-cli/releases/latest * @@ -192,10 +202,12 @@ async function downloadAndCache(version, arch, installDir, cacheKey) { * Downloads SAM CLI without caching. * * @param {string} arch - The architecture (x86_64 or arm64). + * @param {string} releaseTag - Optional release tag (e.g. "sam-cli-nightly"). Defaults to latest. * @returns {Promise} The directory SAM CLI is installed in. */ -async function downloadWithoutCache(arch) { - const url = `https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-${arch}.zip`; +async function downloadWithoutCache(arch, releaseTag) { + const tagSegment = releaseTag ? `download/${releaseTag}` : "latest/download"; + const url = `https://github.com/aws/aws-sam-cli/releases/${tagSegment}/aws-sam-cli-linux-${arch}.zip`; const tempDir = mkdirTemp(); try { @@ -209,32 +221,161 @@ async function downloadWithoutCache(arch) { } /** - * Installs SAM CLI using the native installers. + * Builds the URL to the Windows MSI asset for a given release tag or version. * - * See https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html + * @param {string} tagOrVersion - Either a release tag (e.g. "sam-cli-nightly") + * or an empty string to use the "latest" alias. + * @param {boolean} isTag - True if the first arg is a release tag, false if version (x.y.z). + */ +function windowsMsiUrl(tagOrVersion, isTag) { + const asset = "AWS_SAM_CLI_64_PY3.msi"; + if (!tagOrVersion) { + return `https://github.com/aws/aws-sam-cli/releases/latest/download/${asset}`; + } + const tag = isTag ? tagOrVersion : `v${tagOrVersion}`; + return `https://github.com/aws/aws-sam-cli/releases/download/${tag}/${asset}`; +} + +/** + * Runs the MSI silently via msiexec. Throws on failure. * - * @param {string} inputVersion - The SAM CLI version to install. - * @param {string} token - Authentication Token to use for GITHUB Apis. - * @returns {Promise} The directory SAM CLI is installed in. + * @param {string} msiPath - Path to the downloaded MSI. */ -// TODO: Support more platforms -async function installUsingNativeInstaller(inputVersion, token) { - // Validate platform - if (os.platform() !== "linux") { - core.setFailed("Only Linux is supported with use-installer: true"); +async function runMsiExec(msiPath) { + // 3010 = ERROR_SUCCESS_REBOOT_REQUIRED; treat as success + const logPath = path.join(mkdirTemp(), "msi-install.log"); + const exitCode = await exec.exec( + "msiexec", + ["/i", msiPath, "/qn", "/norestart", "/l*v", logPath], + { ignoreReturnCode: true }, + ); + if (exitCode !== 0 && exitCode !== 3010) { + if (fs.existsSync(logPath)) { + const tail = fs.readFileSync(logPath, "utf8").split("\n").slice(-30); + core.warning( + `msiexec failed (${exitCode}); last log lines:\n${tail.join("\n")}`, + ); + } + throw new Error(`msiexec failed with exit code ${exitCode}`); + } +} + +/** + * Locates the SAM CLI bin directory created by the MSI install. + * + * Stable installs to `C:\Program Files\Amazon\AWSSAMCLI\bin`; nightly installs + * to `C:\Program Files\Amazon\AWSSAMCLI_NIGHTLY\bin`. + * + * @param {boolean} nightly - Whether the nightly MSI was installed. + */ +function findWindowsSamBinDir(nightly) { + const programFiles = process.env["ProgramFiles"] || "C:\\Program Files"; + const dir = path.join( + programFiles, + "Amazon", + nightly ? "AWSSAMCLI_NIGHTLY" : "AWSSAMCLI", + "bin", + ); + if (!fs.existsSync(dir)) { + throw new Error(`Expected SAM CLI install directory not found: ${dir}`); + } + return dir; +} + +/** + * Installs SAM CLI on Windows by downloading and running the MSI. + * + * @param {string} inputVersion - The SAM CLI version to install or "nightly". + * @returns {Promise} The directory containing sam.cmd / sam.exe. + */ +async function installWindowsNativeInstaller(inputVersion) { + // Validate version format (only x.y.z, "nightly", or empty are accepted) + if (inputVersion && !isNightly(inputVersion) && !isSemver(inputVersion)) { + core.setFailed('Version must be in the format x.y.z or "nightly"'); + return ""; + } + + const nightly = isNightly(inputVersion); + const url = nightly + ? windowsMsiUrl(NIGHTLY_TAG, true) + : windowsMsiUrl(inputVersion, false); + + if (nightly) { + core.info("Installing SAM CLI nightly release on Windows."); + } else { + core.info( + `Installing SAM CLI ${inputVersion || "latest"} on Windows via MSI.`, + ); + } + + try { + const msiPath = await tc.downloadTool(url); + await runMsiExec(msiPath); + } catch (error) { + core.setFailed(`Failed to install SAM CLI MSI: ${error.message}`); + return ""; + } + + let binDir; + try { + binDir = findWindowsSamBinDir(nightly); + } catch (error) { + core.setFailed(error.message); return ""; } + // Nightly MSI ships sam-nightly.{cmd,exe}; copy to sam.{cmd,exe} so users + // can invoke `sam` regardless of which release they install. + if (nightly) { + for (const ext of ["cmd", "exe"]) { + const src = path.join(binDir, `sam-nightly.${ext}`); + const dst = path.join(binDir, `sam.${ext}`); + if (fs.existsSync(src) && !fs.existsSync(dst)) { + fs.copyFileSync(src, dst); + } + } + } + + return binDir; +} + +/** + * Installs SAM CLI on Linux using the official native installer archive. + * + * @param {string} inputVersion - The SAM CLI version to install or "nightly". + * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The directory SAM CLI is installed in. + */ +async function installLinuxNativeInstaller(inputVersion, token) { if (os.arch() !== "x64" && os.arch() !== "arm64") { core.setFailed( - "Only x86-64 and aarch64 architectures are supported with use-installer: true", + "Only x86-64 and aarch64 architectures are supported with use-installer: true on Linux", ); return ""; } + const arch = os.arch() === "arm64" ? "arm64" : "x86_64"; + + // Nightly: download without caching since the "sam-cli-nightly" tag's + // contents change daily, so a cache hit would serve stale binaries. + if (isNightly(inputVersion)) { + core.info("Installing SAM CLI nightly release without caching."); + const binDir = await downloadWithoutCache(arch, NIGHTLY_TAG); + if (binDir) { + // The nightly archive ships `sam-nightly` instead of `sam`. Symlink so + // `sam` works on PATH without users having to change their commands. + const target = path.join(binDir, "sam-nightly"); + const link = path.join(binDir, "sam"); + if (fs.existsSync(target) && !fs.existsSync(link)) { + fs.symlinkSync(target, link); + } + } + return binDir; + } + // Validate version format if (inputVersion && !isSemver(inputVersion)) { - core.setFailed("Version must be in the format x.y.z"); + core.setFailed('Version must be in the format x.y.z or "nightly"'); return ""; } @@ -252,7 +393,6 @@ async function installUsingNativeInstaller(inputVersion, token) { } // Validate ARM64 version requirement - const arch = os.arch() === "arm64" ? "arm64" : "x86_64"; if (version && os.arch() === "arm64" && semverLt(version, "1.104.0")) { core.setFailed( "ARM64 installer is only available for versions 1.104.0 and above", @@ -287,14 +427,44 @@ async function installUsingNativeInstaller(inputVersion, token) { return await downloadWithoutCache(arch); } +/** + * Installs SAM CLI using the native installers. + * + * See https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html + * + * @param {string} inputVersion - The SAM CLI version to install. + * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The directory SAM CLI is installed in. + */ +// TODO: Support more platforms (macOS .pkg) +async function installUsingNativeInstaller(inputVersion, token) { + if (os.platform() === "linux") { + return await installLinuxNativeInstaller(inputVersion, token); + } + if (os.platform() === "win32") { + return await installWindowsNativeInstaller(inputVersion); + } + core.setFailed( + "use-installer: true is only supported on Linux and Windows runners", + ); + return ""; +} + async function setup() { - const version = getInput("version", /^[\d.*]*$/, ""); + const version = getInput("version", /^([\d.*]*|nightly)$/, ""); // python3 isn't standard on Windows const defaultPython = isWindows() ? "python" : "python3"; const python = getInput("python", /^.+$/, defaultPython); const useInstaller = core.getBooleanInput("use-installer"); const token = getInput("token", /^.*$/, ""); + if (isNightly(version) && !useInstaller) { + core.setFailed( + 'Installing the nightly release requires "use-installer: true". The aws-sam-cli nightly release is not published to PyPI.', + ); + return; + } + const binPath = useInstaller ? await installUsingNativeInstaller(version, token) : await installSamCli(python, version); diff --git a/test/setup.test.js b/test/setup.test.js index fe7c0ec..4e778c5 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -377,3 +377,237 @@ test.each([["x64"], ["arm64"]])( } }, ); + +test.each([ + ["x64", "x86_64"], + ["arm64", "arm64"], +])( + "when use-installer enabled and version is nightly, downloads from sam-cli-nightly tag without caching (Linux %s)", + async (inputArch, expectedArch) => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue(inputArch); + + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce("nightly"); + + tc.extractZip = jest.fn().mockResolvedValueOnce(undefined); + tc.downloadTool = jest + .fn() + .mockResolvedValueOnce("/path/to/downloaded/sam"); + + await setup(); + + expect(tc.downloadTool).toHaveBeenCalledWith( + `https://github.com/aws/aws-sam-cli/releases/download/sam-cli-nightly/aws-sam-cli-linux-${expectedArch}.zip`, + ); + + expect(cache.restoreCache).toHaveBeenCalledTimes(0); + expect(cache.saveCache).toHaveBeenCalledTimes(0); + expect(core.addPath).toHaveBeenCalled(); + expect(core.setFailed).not.toHaveBeenCalled(); + }, +); + +test("when version is nightly but use-installer is false, fails with descriptive error", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + + core.getBooleanInput = jest.fn().mockReturnValue(false); + core.getInput = jest.fn().mockReturnValueOnce("nightly"); + + await setup(); + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('"use-installer: true"'), + ); + expect(io.which).not.toHaveBeenCalled(); + expect(tc.downloadTool).not.toHaveBeenCalled(); + expect(core.addPath).not.toHaveBeenCalled(); +}); + +describe("Windows native installer", () => { + const fs = require("fs"); + const path = require("path"); + + // path.join uses the host OS separator, so compute expected dirs the same way + // the production code does to keep these tests cross-platform. + const stableBinDir = path.join( + "C:\\Program Files", + "Amazon", + "AWSSAMCLI", + "bin", + ); + const nightlyBinDir = path.join( + "C:\\Program Files", + "Amazon", + "AWSSAMCLI_NIGHTLY", + "bin", + ); + + let existsSyncSpy; + let copyFileSyncSpy; + let originalProgramFiles; + + beforeEach(() => { + originalProgramFiles = process.env["ProgramFiles"]; + process.env["ProgramFiles"] = "C:\\Program Files"; + + jest.spyOn(os, "platform").mockReturnValue("win32"); + + existsSyncSpy = jest.spyOn(fs, "existsSync"); + copyFileSyncSpy = jest + .spyOn(fs, "copyFileSync") + .mockImplementation(() => {}); + + tc.downloadTool = jest + .fn() + .mockResolvedValue("/tmp/AWS_SAM_CLI_64_PY3.msi"); + exec.exec = jest.fn().mockResolvedValue(0); + }); + + afterEach(() => { + existsSyncSpy.mockRestore(); + copyFileSyncSpy.mockRestore(); + if (originalProgramFiles === undefined) { + delete process.env["ProgramFiles"]; + } else { + process.env["ProgramFiles"] = originalProgramFiles; + } + }); + + test("downloads MSI for a pinned version and runs msiexec", async () => { + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce("1.139.0"); + + existsSyncSpy.mockImplementation((p) => p === stableBinDir); + + await setup(); + + expect(tc.downloadTool).toHaveBeenCalledWith( + "https://github.com/aws/aws-sam-cli/releases/download/v1.139.0/AWS_SAM_CLI_64_PY3.msi", + ); + expect(exec.exec).toHaveBeenCalledWith( + "msiexec", + expect.arrayContaining([ + "/i", + "/tmp/AWS_SAM_CLI_64_PY3.msi", + "/qn", + "/norestart", + ]), + expect.objectContaining({ ignoreReturnCode: true }), + ); + expect(core.addPath).toHaveBeenCalledWith(stableBinDir); + expect(copyFileSyncSpy).not.toHaveBeenCalled(); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + test("downloads MSI from latest URL when no version is provided", async () => { + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce(""); + + existsSyncSpy.mockImplementation((p) => p === stableBinDir); + + await setup(); + + expect(tc.downloadTool).toHaveBeenCalledWith( + "https://github.com/aws/aws-sam-cli/releases/latest/download/AWS_SAM_CLI_64_PY3.msi", + ); + expect(core.addPath).toHaveBeenCalledWith(stableBinDir); + }); + + test("nightly: downloads from sam-cli-nightly tag and aliases sam-nightly to sam", async () => { + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce("nightly"); + + // Pretend the nightly install dir and sam-nightly.* exist; sam.* does not yet. + const samNightlyCmd = path.join(nightlyBinDir, "sam-nightly.cmd"); + const samNightlyExe = path.join(nightlyBinDir, "sam-nightly.exe"); + existsSyncSpy.mockImplementation( + (p) => p === nightlyBinDir || p === samNightlyCmd || p === samNightlyExe, + ); + + await setup(); + + expect(tc.downloadTool).toHaveBeenCalledWith( + "https://github.com/aws/aws-sam-cli/releases/download/sam-cli-nightly/AWS_SAM_CLI_64_PY3.msi", + ); + expect(core.addPath).toHaveBeenCalledWith(nightlyBinDir); + // Both sam.cmd and sam.exe should have been copied from the nightly variants + expect(copyFileSyncSpy).toHaveBeenCalledWith( + samNightlyCmd, + path.join(nightlyBinDir, "sam.cmd"), + ); + expect(copyFileSyncSpy).toHaveBeenCalledWith( + samNightlyExe, + path.join(nightlyBinDir, "sam.exe"), + ); + }); + + test("treats msiexec exit code 3010 (reboot required) as success", async () => { + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce("1.139.0"); + + exec.exec = jest.fn().mockResolvedValue(3010); + existsSyncSpy.mockImplementation((p) => p === stableBinDir); + + await setup(); + + expect(core.addPath).toHaveBeenCalled(); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + test("fails when msiexec returns a non-zero, non-3010 exit code", async () => { + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce("1.139.0"); + + exec.exec = jest.fn().mockResolvedValue(1603); + existsSyncSpy.mockReturnValue(false); + + await setup(); + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining("msiexec failed with exit code 1603"), + ); + expect(core.addPath).not.toHaveBeenCalled(); + }); + + test("fails when expected install directory is missing after MSI completes", async () => { + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce("1.139.0"); + + existsSyncSpy.mockReturnValue(false); + + await setup(); + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining("Expected SAM CLI install directory not found"), + ); + expect(core.addPath).not.toHaveBeenCalled(); + }); + + test("rejects invalid version string", async () => { + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce("1.2"); + + await setup(); + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining( + 'Version must be in the format x.y.z or "nightly"', + ), + ); + expect(tc.downloadTool).not.toHaveBeenCalled(); + }); +}); + +test("use-installer rejected on macOS", async () => { + jest.spyOn(os, "platform").mockReturnValue("darwin"); + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce("1.139.0"); + + await setup(); + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining("only supported on Linux and Windows"), + ); + expect(tc.downloadTool).not.toHaveBeenCalled(); +}); From bca9823105058abdf9092add1e3b63155fb63166 Mon Sep 17 00:00:00 2001 From: Roger Zhang Date: Fri, 8 May 2026 13:19:09 -0700 Subject: [PATCH 2/5] fix(windows): preserve .msi extension when downloading installer tc.downloadTool writes to a UUID-named file by default, which Windows Installer rejected with exit code 1603 because msiexec dispatches by file extension. Pass an explicit destination ending in .msi so the installer recognizes the file format. Co-Authored-By: Claude Opus 4.7 (1M context) --- dist/index.js | 6 +++++- lib/setup.js | 6 +++++- test/setup.test.js | 5 ++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/dist/index.js b/dist/index.js index 4766ef2..aaa4745 100644 --- a/dist/index.js +++ b/dist/index.js @@ -315,7 +315,11 @@ async function installWindowsNativeInstaller(inputVersion) { } try { - const msiPath = await tc.downloadTool(url); + // Windows Installer dispatches by file extension, so the destination + // path must end in `.msi` — tc.downloadTool's default UUID filename + // makes msiexec fail with exit code 1603. + const msiDest = path.join(mkdirTemp(), "AWS_SAM_CLI_64_PY3.msi"); + const msiPath = await tc.downloadTool(url, msiDest); await runMsiExec(msiPath); } catch (error) { core.setFailed(`Failed to install SAM CLI MSI: ${error.message}`); diff --git a/lib/setup.js b/lib/setup.js index 9a199be..e559efc 100644 --- a/lib/setup.js +++ b/lib/setup.js @@ -309,7 +309,11 @@ async function installWindowsNativeInstaller(inputVersion) { } try { - const msiPath = await tc.downloadTool(url); + // Windows Installer dispatches by file extension, so the destination + // path must end in `.msi` — tc.downloadTool's default UUID filename + // makes msiexec fail with exit code 1603. + const msiDest = path.join(mkdirTemp(), "AWS_SAM_CLI_64_PY3.msi"); + const msiPath = await tc.downloadTool(url, msiDest); await runMsiExec(msiPath); } catch (error) { core.setFailed(`Failed to install SAM CLI MSI: ${error.message}`); diff --git a/test/setup.test.js b/test/setup.test.js index 4e778c5..7b1f21b 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -484,12 +484,13 @@ describe("Windows native installer", () => { expect(tc.downloadTool).toHaveBeenCalledWith( "https://github.com/aws/aws-sam-cli/releases/download/v1.139.0/AWS_SAM_CLI_64_PY3.msi", + expect.stringMatching(/AWS_SAM_CLI_64_PY3\.msi$/), ); expect(exec.exec).toHaveBeenCalledWith( "msiexec", expect.arrayContaining([ "/i", - "/tmp/AWS_SAM_CLI_64_PY3.msi", + expect.stringMatching(/AWS_SAM_CLI_64_PY3\.msi$/), "/qn", "/norestart", ]), @@ -510,6 +511,7 @@ describe("Windows native installer", () => { expect(tc.downloadTool).toHaveBeenCalledWith( "https://github.com/aws/aws-sam-cli/releases/latest/download/AWS_SAM_CLI_64_PY3.msi", + expect.stringMatching(/AWS_SAM_CLI_64_PY3\.msi$/), ); expect(core.addPath).toHaveBeenCalledWith(stableBinDir); }); @@ -529,6 +531,7 @@ describe("Windows native installer", () => { expect(tc.downloadTool).toHaveBeenCalledWith( "https://github.com/aws/aws-sam-cli/releases/download/sam-cli-nightly/AWS_SAM_CLI_64_PY3.msi", + expect.stringMatching(/AWS_SAM_CLI_64_PY3\.msi$/), ); expect(core.addPath).toHaveBeenCalledWith(nightlyBinDir); // Both sam.cmd and sam.exe should have been copied from the nightly variants From 523d83a6aad56a6b5f62a7d65878d2671c29c39a Mon Sep 17 00:00:00 2001 From: Roger Zhang Date: Fri, 8 May 2026 14:15:40 -0700 Subject: [PATCH 3/5] test: bump test SAM CLI versions to 1.159.1 The Windows MSI install was failing with exit code 1603 because the runner image preinstalls SAM CLI 1.158.0 and Windows Installer rejects silent downgrades. Bumping the pinned version to 1.159.1 (newer than the preinstalled version) resolves the install conflict and uses a more recent stable release across Linux and PyPI matrix tests too. The PyPI matrix still pins SAM_VERSION=1.18.2 for older Python versions (3.10, 3.11) since that combination intentionally tests legacy support. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9ff6806..c202c55 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,15 +37,15 @@ jobs: uses: ./ with: use-installer: true - version: "1.104.0" - - run: sam --version | grep -F 1.104.0 + version: "1.159.1" + - run: sam --version | grep -F 1.159.1 - name: Test official installer (pinned version; should use cache) uses: ./ with: use-installer: true - version: "1.104.0" - - run: sam --version | grep -F 1.104.0 + version: "1.159.1" + - run: sam --version | grep -F 1.159.1 - name: Test official installer (latest version) uses: ./ @@ -79,8 +79,8 @@ jobs: uses: ./ with: use-installer: true - version: "1.139.0" - - run: sam --version | findstr /C:"1.139.0" + version: "1.159.1" + - run: sam --version | findstr /C:"1.159.1" shell: cmd - name: Test MSI installer (latest version) @@ -117,8 +117,8 @@ jobs: runs-on: ${{ matrix.os }} env: # Set SAM versions based on Python version - SAM_VERSION: ${{ contains(fromJson('["3.12", "3.13"]'), matrix.python) && '1.128.0' || '1.18.2' }} - INSTALLER_VERSION: ${{ contains( matrix.os, '-arm') && '1.130.0' || '1.71.0' }} + SAM_VERSION: ${{ contains(fromJson('["3.12", "3.13"]'), matrix.python) && '1.159.1' || '1.18.2' }} + INSTALLER_VERSION: "1.159.1" steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 From c720103deea7f35010496afccd68c804cb6d85f7 Mon Sep 17 00:00:00 2001 From: Roger Zhang Date: Mon, 11 May 2026 13:13:08 -0700 Subject: [PATCH 4/5] fix(windows): uninstall preinstalled SAM CLI before MSI install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub-hosted Windows runners ship a recent SAM CLI preinstalled, and Windows Installer rejects silent downgrades — `msiexec /i` for an older or equal-version MSI fails with exit 1603. Bumping the test pin past the preinstalled version (the previous workaround) only papers over the issue: the same failure recurs every time the runner image is updated. Add an `uninstallExistingWindowsSamCli` step that runs before every Windows install (pinned, latest, nightly). It enumerates the Windows uninstall registry for products whose `InstallLocation` matches the target install root for the flavor (stable vs nightly) and runs `msiexec /x` for each match. Best-effort: warns and continues on failure, letting the subsequent install surface the real error. Restore the pinned-version Windows MSI test to 1.139.0 so it actually exercises the downgrade path against the runner's preinstalled version. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 9 +++- dist/index.js | 98 +++++++++++++++++++++++++++++++++++--- lib/setup.js | 98 +++++++++++++++++++++++++++++++++++--- test/setup.test.js | 79 ++++++++++++++++++++++++++++-- 4 files changed, 264 insertions(+), 20 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c202c55..785d66b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,12 +75,17 @@ jobs: steps: - uses: actions/checkout@v6 + # Pin to an older version to exercise the downgrade path: GitHub-hosted + # Windows runners ship a recent SAM CLI preinstalled, and Windows + # Installer rejects silent downgrades unless the existing product is + # uninstalled first. This step verifies that uninstall-before-install + # works regardless of which version the runner image happens to ship. - name: Test MSI installer (pinned version) uses: ./ with: use-installer: true - version: "1.159.1" - - run: sam --version | findstr /C:"1.159.1" + version: "1.139.0" + - run: sam --version | findstr /C:"1.139.0" shell: cmd - name: Test MSI installer (latest version) diff --git a/dist/index.js b/dist/index.js index aaa4745..9fa79c9 100644 --- a/dist/index.js +++ b/dist/index.js @@ -267,27 +267,107 @@ async function runMsiExec(msiPath) { } /** - * Locates the SAM CLI bin directory created by the MSI install. + * Returns the MSI install root for the given SAM CLI flavor. * - * Stable installs to `C:\Program Files\Amazon\AWSSAMCLI\bin`; nightly installs - * to `C:\Program Files\Amazon\AWSSAMCLI_NIGHTLY\bin`. + * Stable installs to `C:\Program Files\Amazon\AWSSAMCLI`; nightly installs + * to `C:\Program Files\Amazon\AWSSAMCLI_NIGHTLY`. * - * @param {boolean} nightly - Whether the nightly MSI was installed. + * @param {boolean} nightly - Whether to return the nightly path. */ -function findWindowsSamBinDir(nightly) { +function windowsSamInstallRoot(nightly) { const programFiles = process.env["ProgramFiles"] || "C:\\Program Files"; - const dir = path.join( + return path.join( programFiles, "Amazon", nightly ? "AWSSAMCLI_NIGHTLY" : "AWSSAMCLI", - "bin", ); +} + +/** + * Locates the SAM CLI bin directory created by the MSI install. + * + * @param {boolean} nightly - Whether the nightly MSI was installed. + */ +function findWindowsSamBinDir(nightly) { + const dir = path.join(windowsSamInstallRoot(nightly), "bin"); if (!fs.existsSync(dir)) { throw new Error(`Expected SAM CLI install directory not found: ${dir}`); } return dir; } +/** + * Uninstalls any existing AWS SAM CLI MSI whose install root matches the + * flavor we're about to install (stable or nightly). + * + * Windows Installer rejects silent downgrades, so if a newer SAM CLI is + * already installed (e.g. GitHub-hosted Windows runners ship a recent stable, + * or a previous step in the same workflow installed one), `msiexec /i` for + * an older or equal pinned version fails with exit 1603. Removing the + * existing product first makes the install order- and version-independent. + * + * Best-effort: logs and continues on any uninstall failure — the subsequent + * `msiexec /i` will surface the real error if it can't proceed. + * + * @param {boolean} nightly - Whether to remove the nightly product. + */ +async function uninstallExistingWindowsSamCli(nightly) { + const installRoot = windowsSamInstallRoot(nightly); + if (!fs.existsSync(installRoot)) { + return; + } + + core.info( + `Found existing SAM CLI at ${installRoot}; uninstalling before reinstall to avoid downgrade rejection.`, + ); + + // Query the uninstall registry for products whose InstallLocation matches + // our target root, then run `msiexec /x ` for each match. + // Done via a script file rather than `-Command` to sidestep the fragile + // multi-level quoting required when passing a PowerShell script through + // exec args. + const script = `$ErrorActionPreference = 'Stop' +$root = $args[0].TrimEnd('\\') +$paths = @( + 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*', + 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' +) +$entries = Get-ItemProperty $paths -ErrorAction SilentlyContinue | Where-Object { + $_.InstallLocation -and ($_.InstallLocation.TrimEnd('\\') -ieq $root) +} +foreach ($entry in $entries) { + $code = $entry.PSChildName + if ($code -notmatch '^\\{[0-9A-Fa-f-]+\\}$') { continue } + Write-Host "Uninstalling $($entry.DisplayName) ($code)" + $p = Start-Process -FilePath msiexec.exe -ArgumentList '/x', $code, '/qn', '/norestart' -Wait -PassThru + if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { + throw "msiexec /x $code failed with exit code $($p.ExitCode)" + } +} +`; + const scriptPath = path.join(mkdirTemp(), "uninstall-sam.ps1"); + fs.writeFileSync(scriptPath, script); + + const exitCode = await exec.exec( + "powershell", + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + installRoot, + ], + { ignoreReturnCode: true }, + ); + if (exitCode !== 0) { + core.warning( + `Pre-install uninstall step exited with code ${exitCode}; continuing.`, + ); + } +} + /** * Installs SAM CLI on Windows by downloading and running the MSI. * @@ -315,6 +395,10 @@ async function installWindowsNativeInstaller(inputVersion) { } try { + // Remove any preinstalled SAM CLI of the same flavor first so msiexec + // doesn't reject a downgrade or equal-version install. + await uninstallExistingWindowsSamCli(nightly); + // Windows Installer dispatches by file extension, so the destination // path must end in `.msi` — tc.downloadTool's default UUID filename // makes msiexec fail with exit code 1603. diff --git a/lib/setup.js b/lib/setup.js index e559efc..ae9595c 100644 --- a/lib/setup.js +++ b/lib/setup.js @@ -261,27 +261,107 @@ async function runMsiExec(msiPath) { } /** - * Locates the SAM CLI bin directory created by the MSI install. + * Returns the MSI install root for the given SAM CLI flavor. * - * Stable installs to `C:\Program Files\Amazon\AWSSAMCLI\bin`; nightly installs - * to `C:\Program Files\Amazon\AWSSAMCLI_NIGHTLY\bin`. + * Stable installs to `C:\Program Files\Amazon\AWSSAMCLI`; nightly installs + * to `C:\Program Files\Amazon\AWSSAMCLI_NIGHTLY`. * - * @param {boolean} nightly - Whether the nightly MSI was installed. + * @param {boolean} nightly - Whether to return the nightly path. */ -function findWindowsSamBinDir(nightly) { +function windowsSamInstallRoot(nightly) { const programFiles = process.env["ProgramFiles"] || "C:\\Program Files"; - const dir = path.join( + return path.join( programFiles, "Amazon", nightly ? "AWSSAMCLI_NIGHTLY" : "AWSSAMCLI", - "bin", ); +} + +/** + * Locates the SAM CLI bin directory created by the MSI install. + * + * @param {boolean} nightly - Whether the nightly MSI was installed. + */ +function findWindowsSamBinDir(nightly) { + const dir = path.join(windowsSamInstallRoot(nightly), "bin"); if (!fs.existsSync(dir)) { throw new Error(`Expected SAM CLI install directory not found: ${dir}`); } return dir; } +/** + * Uninstalls any existing AWS SAM CLI MSI whose install root matches the + * flavor we're about to install (stable or nightly). + * + * Windows Installer rejects silent downgrades, so if a newer SAM CLI is + * already installed (e.g. GitHub-hosted Windows runners ship a recent stable, + * or a previous step in the same workflow installed one), `msiexec /i` for + * an older or equal pinned version fails with exit 1603. Removing the + * existing product first makes the install order- and version-independent. + * + * Best-effort: logs and continues on any uninstall failure — the subsequent + * `msiexec /i` will surface the real error if it can't proceed. + * + * @param {boolean} nightly - Whether to remove the nightly product. + */ +async function uninstallExistingWindowsSamCli(nightly) { + const installRoot = windowsSamInstallRoot(nightly); + if (!fs.existsSync(installRoot)) { + return; + } + + core.info( + `Found existing SAM CLI at ${installRoot}; uninstalling before reinstall to avoid downgrade rejection.`, + ); + + // Query the uninstall registry for products whose InstallLocation matches + // our target root, then run `msiexec /x ` for each match. + // Done via a script file rather than `-Command` to sidestep the fragile + // multi-level quoting required when passing a PowerShell script through + // exec args. + const script = `$ErrorActionPreference = 'Stop' +$root = $args[0].TrimEnd('\\') +$paths = @( + 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*', + 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' +) +$entries = Get-ItemProperty $paths -ErrorAction SilentlyContinue | Where-Object { + $_.InstallLocation -and ($_.InstallLocation.TrimEnd('\\') -ieq $root) +} +foreach ($entry in $entries) { + $code = $entry.PSChildName + if ($code -notmatch '^\\{[0-9A-Fa-f-]+\\}$') { continue } + Write-Host "Uninstalling $($entry.DisplayName) ($code)" + $p = Start-Process -FilePath msiexec.exe -ArgumentList '/x', $code, '/qn', '/norestart' -Wait -PassThru + if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { + throw "msiexec /x $code failed with exit code $($p.ExitCode)" + } +} +`; + const scriptPath = path.join(mkdirTemp(), "uninstall-sam.ps1"); + fs.writeFileSync(scriptPath, script); + + const exitCode = await exec.exec( + "powershell", + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + installRoot, + ], + { ignoreReturnCode: true }, + ); + if (exitCode !== 0) { + core.warning( + `Pre-install uninstall step exited with code ${exitCode}; continuing.`, + ); + } +} + /** * Installs SAM CLI on Windows by downloading and running the MSI. * @@ -309,6 +389,10 @@ async function installWindowsNativeInstaller(inputVersion) { } try { + // Remove any preinstalled SAM CLI of the same flavor first so msiexec + // doesn't reject a downgrade or equal-version install. + await uninstallExistingWindowsSamCli(nightly); + // Windows Installer dispatches by file extension, so the destination // path must end in `.msi` — tc.downloadTool's default UUID filename // makes msiexec fail with exit code 1603. diff --git a/test/setup.test.js b/test/setup.test.js index 7b1f21b..7743881 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -430,21 +430,22 @@ describe("Windows native installer", () => { // path.join uses the host OS separator, so compute expected dirs the same way // the production code does to keep these tests cross-platform. - const stableBinDir = path.join( + const stableInstallRoot = path.join( "C:\\Program Files", "Amazon", "AWSSAMCLI", - "bin", ); - const nightlyBinDir = path.join( + const nightlyInstallRoot = path.join( "C:\\Program Files", "Amazon", "AWSSAMCLI_NIGHTLY", - "bin", ); + const stableBinDir = path.join(stableInstallRoot, "bin"); + const nightlyBinDir = path.join(nightlyInstallRoot, "bin"); let existsSyncSpy; let copyFileSyncSpy; + let writeFileSyncSpy; let originalProgramFiles; beforeEach(() => { @@ -457,6 +458,9 @@ describe("Windows native installer", () => { copyFileSyncSpy = jest .spyOn(fs, "copyFileSync") .mockImplementation(() => {}); + writeFileSyncSpy = jest + .spyOn(fs, "writeFileSync") + .mockImplementation(() => {}); tc.downloadTool = jest .fn() @@ -467,6 +471,7 @@ describe("Windows native installer", () => { afterEach(() => { existsSyncSpy.mockRestore(); copyFileSyncSpy.mockRestore(); + writeFileSyncSpy.mockRestore(); if (originalProgramFiles === undefined) { delete process.env["ProgramFiles"]; } else { @@ -600,6 +605,72 @@ describe("Windows native installer", () => { ); expect(tc.downloadTool).not.toHaveBeenCalled(); }); + + test("uninstalls preinstalled stable SAM CLI before installing pinned version", async () => { + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce("1.139.0"); + + // Pretend a stable SAM CLI is already installed (install root exists), + // and the bin dir exists after the new install completes. + existsSyncSpy.mockImplementation( + (p) => p === stableInstallRoot || p === stableBinDir, + ); + + await setup(); + + // PowerShell uninstall is invoked with the stable install root passed + // as the script argument, then msiexec /i runs for the new MSI. + const calls = exec.exec.mock.calls; + const uninstallCall = calls.find((c) => c[0] === "powershell"); + expect(uninstallCall).toBeDefined(); + expect(uninstallCall[1]).toEqual(expect.arrayContaining(["-File"])); + expect(uninstallCall[1][uninstallCall[1].length - 1]).toBe( + stableInstallRoot, + ); + + const installCall = calls.find((c) => c[0] === "msiexec"); + expect(installCall).toBeDefined(); + expect(calls.indexOf(uninstallCall)).toBeLessThan( + calls.indexOf(installCall), + ); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + test("skips uninstall when no existing install is present", async () => { + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce("1.139.0"); + + // Install root does not exist before install; bin dir appears after. + existsSyncSpy.mockImplementation((p) => p === stableBinDir); + + await setup(); + + expect( + exec.exec.mock.calls.find((c) => c[0] === "powershell"), + ).toBeUndefined(); + expect(exec.exec.mock.calls.find((c) => c[0] === "msiexec")).toBeDefined(); + }); + + test("uninstall targets nightly install root for nightly install", async () => { + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce("nightly"); + + const samNightlyCmd = path.join(nightlyBinDir, "sam-nightly.cmd"); + existsSyncSpy.mockImplementation( + (p) => + p === nightlyInstallRoot || p === nightlyBinDir || p === samNightlyCmd, + ); + + await setup(); + + const uninstallCall = exec.exec.mock.calls.find( + (c) => c[0] === "powershell", + ); + expect(uninstallCall).toBeDefined(); + expect(uninstallCall[1][uninstallCall[1].length - 1]).toBe( + nightlyInstallRoot, + ); + }); }); test("use-installer rejected on macOS", async () => { From fbe86b674984021af5fc56aa7a3e9d2ef4a2038a Mon Sep 17 00:00:00 2001 From: Roger Zhang Date: Mon, 11 May 2026 13:19:50 -0700 Subject: [PATCH 5/5] fix(windows): match SAM CLI uninstall by DisplayName, not InstallLocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous uninstall-before-install step filtered registry entries by `InstallLocation -ieq `, but the AWS SAM CLI MSI does not populate `ARPINSTALLLOCATION`, so that field is empty in the uninstall registry — the filter matched zero entries and the subsequent `msiexec /i` still hit exit 1603 (downgrade rejected). Match by `DisplayName -like 'AWS SAM Command Line Interface*'` instead, which the install log confirms is reliably set. Stable and nightly are distinguished by whether the DisplayName contains "Nightly" so the two products remain isolated. Also log a "no matching entry found" line so future failures of this kind are easier to diagnose from the action output. Co-Authored-By: Claude Opus 4.7 (1M context) --- dist/index.js | 26 +++++++++++++++++++++----- lib/setup.js | 26 +++++++++++++++++++++----- test/setup.test.js | 19 ++++++++++++------- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/dist/index.js b/dist/index.js index 9fa79c9..d9c3de8 100644 --- a/dist/index.js +++ b/dist/index.js @@ -312,6 +312,12 @@ function findWindowsSamBinDir(nightly) { * @param {boolean} nightly - Whether to remove the nightly product. */ async function uninstallExistingWindowsSamCli(nightly) { + // Match by DisplayName rather than InstallLocation: the SAM CLI MSI does + // not populate ARPINSTALLLOCATION, so the registry's InstallLocation field + // is empty for stable AND nightly. The DisplayName is reliable — stable is + // "AWS SAM Command Line Interface", nightly is the same with " Nightly". + // We still gate on the install root existing to skip the powershell + // invocation entirely on a clean machine. const installRoot = windowsSamInstallRoot(nightly); if (!fs.existsSync(installRoot)) { return; @@ -321,21 +327,28 @@ async function uninstallExistingWindowsSamCli(nightly) { `Found existing SAM CLI at ${installRoot}; uninstalling before reinstall to avoid downgrade rejection.`, ); - // Query the uninstall registry for products whose InstallLocation matches - // our target root, then run `msiexec /x ` for each match. // Done via a script file rather than `-Command` to sidestep the fragile // multi-level quoting required when passing a PowerShell script through // exec args. + const flavor = nightly ? "nightly" : "stable"; const script = `$ErrorActionPreference = 'Stop' -$root = $args[0].TrimEnd('\\') +$flavor = $args[0] $paths = @( 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*', 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' ) $entries = Get-ItemProperty $paths -ErrorAction SilentlyContinue | Where-Object { - $_.InstallLocation -and ($_.InstallLocation.TrimEnd('\\') -ieq $root) + $_.DisplayName -and $_.DisplayName -like 'AWS SAM Command Line Interface*' } +# Stable and nightly co-exist as separate products; keep them isolated. +if ($flavor -eq 'nightly') { + $entries = $entries | Where-Object { $_.DisplayName -match '(?i)nightly' } +} else { + $entries = $entries | Where-Object { $_.DisplayName -notmatch '(?i)nightly' } +} +$found = $false foreach ($entry in $entries) { + $found = $true $code = $entry.PSChildName if ($code -notmatch '^\\{[0-9A-Fa-f-]+\\}$') { continue } Write-Host "Uninstalling $($entry.DisplayName) ($code)" @@ -344,6 +357,9 @@ foreach ($entry in $entries) { throw "msiexec /x $code failed with exit code $($p.ExitCode)" } } +if (-not $found) { + Write-Host "No matching $flavor SAM CLI uninstall registry entry found." +} `; const scriptPath = path.join(mkdirTemp(), "uninstall-sam.ps1"); fs.writeFileSync(scriptPath, script); @@ -357,7 +373,7 @@ foreach ($entry in $entries) { "Bypass", "-File", scriptPath, - installRoot, + flavor, ], { ignoreReturnCode: true }, ); diff --git a/lib/setup.js b/lib/setup.js index ae9595c..f9cf7bc 100644 --- a/lib/setup.js +++ b/lib/setup.js @@ -306,6 +306,12 @@ function findWindowsSamBinDir(nightly) { * @param {boolean} nightly - Whether to remove the nightly product. */ async function uninstallExistingWindowsSamCli(nightly) { + // Match by DisplayName rather than InstallLocation: the SAM CLI MSI does + // not populate ARPINSTALLLOCATION, so the registry's InstallLocation field + // is empty for stable AND nightly. The DisplayName is reliable — stable is + // "AWS SAM Command Line Interface", nightly is the same with " Nightly". + // We still gate on the install root existing to skip the powershell + // invocation entirely on a clean machine. const installRoot = windowsSamInstallRoot(nightly); if (!fs.existsSync(installRoot)) { return; @@ -315,21 +321,28 @@ async function uninstallExistingWindowsSamCli(nightly) { `Found existing SAM CLI at ${installRoot}; uninstalling before reinstall to avoid downgrade rejection.`, ); - // Query the uninstall registry for products whose InstallLocation matches - // our target root, then run `msiexec /x ` for each match. // Done via a script file rather than `-Command` to sidestep the fragile // multi-level quoting required when passing a PowerShell script through // exec args. + const flavor = nightly ? "nightly" : "stable"; const script = `$ErrorActionPreference = 'Stop' -$root = $args[0].TrimEnd('\\') +$flavor = $args[0] $paths = @( 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*', 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' ) $entries = Get-ItemProperty $paths -ErrorAction SilentlyContinue | Where-Object { - $_.InstallLocation -and ($_.InstallLocation.TrimEnd('\\') -ieq $root) + $_.DisplayName -and $_.DisplayName -like 'AWS SAM Command Line Interface*' } +# Stable and nightly co-exist as separate products; keep them isolated. +if ($flavor -eq 'nightly') { + $entries = $entries | Where-Object { $_.DisplayName -match '(?i)nightly' } +} else { + $entries = $entries | Where-Object { $_.DisplayName -notmatch '(?i)nightly' } +} +$found = $false foreach ($entry in $entries) { + $found = $true $code = $entry.PSChildName if ($code -notmatch '^\\{[0-9A-Fa-f-]+\\}$') { continue } Write-Host "Uninstalling $($entry.DisplayName) ($code)" @@ -338,6 +351,9 @@ foreach ($entry in $entries) { throw "msiexec /x $code failed with exit code $($p.ExitCode)" } } +if (-not $found) { + Write-Host "No matching $flavor SAM CLI uninstall registry entry found." +} `; const scriptPath = path.join(mkdirTemp(), "uninstall-sam.ps1"); fs.writeFileSync(scriptPath, script); @@ -351,7 +367,7 @@ foreach ($entry in $entries) { "Bypass", "-File", scriptPath, - installRoot, + flavor, ], { ignoreReturnCode: true }, ); diff --git a/test/setup.test.js b/test/setup.test.js index 7743881..b2b363d 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -618,14 +618,21 @@ describe("Windows native installer", () => { await setup(); - // PowerShell uninstall is invoked with the stable install root passed - // as the script argument, then msiexec /i runs for the new MSI. + // PowerShell uninstall is invoked with the "stable" flavor argument, + // then msiexec /i runs for the new MSI. const calls = exec.exec.mock.calls; const uninstallCall = calls.find((c) => c[0] === "powershell"); expect(uninstallCall).toBeDefined(); expect(uninstallCall[1]).toEqual(expect.arrayContaining(["-File"])); - expect(uninstallCall[1][uninstallCall[1].length - 1]).toBe( - stableInstallRoot, + expect(uninstallCall[1][uninstallCall[1].length - 1]).toBe("stable"); + + // The script body passed via -File should match by DisplayName, not + // InstallLocation (the SAM MSI doesn't populate ARPINSTALLLOCATION). + expect(writeFileSyncSpy).toHaveBeenCalledWith( + expect.stringMatching(/uninstall-sam\.ps1$/), + expect.stringContaining( + "DisplayName -like 'AWS SAM Command Line Interface*'", + ), ); const installCall = calls.find((c) => c[0] === "msiexec"); @@ -667,9 +674,7 @@ describe("Windows native installer", () => { (c) => c[0] === "powershell", ); expect(uninstallCall).toBeDefined(); - expect(uninstallCall[1][uninstallCall[1].length - 1]).toBe( - nightlyInstallRoot, - ); + expect(uninstallCall[1][uninstallCall[1].length - 1]).toBe("nightly"); }); });