diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29ff668..785d66b 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: ./ @@ -56,6 +56,53 @@ 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 + + # 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.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 @@ -75,8 +122,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 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..d9c3de8 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,265 @@ 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}`); + } +} + +/** + * Returns the MSI install root for the given SAM CLI flavor. + * + * Stable installs to `C:\Program Files\Amazon\AWSSAMCLI`; nightly installs + * to `C:\Program Files\Amazon\AWSSAMCLI_NIGHTLY`. + * + * @param {boolean} nightly - Whether to return the nightly path. + */ +function windowsSamInstallRoot(nightly) { + const programFiles = process.env["ProgramFiles"] || "C:\\Program Files"; + return path.join( + programFiles, + "Amazon", + nightly ? "AWSSAMCLI_NIGHTLY" : "AWSSAMCLI", + ); +} + +/** + * Locates the SAM CLI bin directory created by the MSI install. + * + * @param {boolean} nightly - Whether the nightly MSI was installed. */ -// 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"); +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) { + // 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; + } + + core.info( + `Found existing SAM CLI at ${installRoot}; uninstalling before reinstall to avoid downgrade rejection.`, + ); + + // 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' +$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 { + $_.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)" + $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)" + } +} +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); + + const exitCode = await exec.exec( + "powershell", + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + flavor, + ], + { 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. + * + * @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 { + // 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. + 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}`); + 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 +503,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 +537,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..f9cf7bc 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,265 @@ 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}`); + } +} + +/** + * Returns the MSI install root for the given SAM CLI flavor. + * + * Stable installs to `C:\Program Files\Amazon\AWSSAMCLI`; nightly installs + * to `C:\Program Files\Amazon\AWSSAMCLI_NIGHTLY`. + * + * @param {boolean} nightly - Whether to return the nightly path. + */ +function windowsSamInstallRoot(nightly) { + const programFiles = process.env["ProgramFiles"] || "C:\\Program Files"; + return path.join( + programFiles, + "Amazon", + nightly ? "AWSSAMCLI_NIGHTLY" : "AWSSAMCLI", + ); +} + +/** + * 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) { + // 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; + } + + core.info( + `Found existing SAM CLI at ${installRoot}; uninstalling before reinstall to avoid downgrade rejection.`, + ); + + // 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' +$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 { + $_.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)" + $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)" + } +} +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); + + const exitCode = await exec.exec( + "powershell", + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + flavor, + ], + { 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. + * + * @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 { + // 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. + 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}`); + 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 +497,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 +531,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..b2b363d 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -377,3 +377,316 @@ 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 stableInstallRoot = path.join( + "C:\\Program Files", + "Amazon", + "AWSSAMCLI", + ); + const nightlyInstallRoot = path.join( + "C:\\Program Files", + "Amazon", + "AWSSAMCLI_NIGHTLY", + ); + const stableBinDir = path.join(stableInstallRoot, "bin"); + const nightlyBinDir = path.join(nightlyInstallRoot, "bin"); + + let existsSyncSpy; + let copyFileSyncSpy; + let writeFileSyncSpy; + 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(() => {}); + writeFileSyncSpy = jest + .spyOn(fs, "writeFileSync") + .mockImplementation(() => {}); + + tc.downloadTool = jest + .fn() + .mockResolvedValue("/tmp/AWS_SAM_CLI_64_PY3.msi"); + exec.exec = jest.fn().mockResolvedValue(0); + }); + + afterEach(() => { + existsSyncSpy.mockRestore(); + copyFileSyncSpy.mockRestore(); + writeFileSyncSpy.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.stringMatching(/AWS_SAM_CLI_64_PY3\.msi$/), + ); + expect(exec.exec).toHaveBeenCalledWith( + "msiexec", + expect.arrayContaining([ + "/i", + expect.stringMatching(/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.stringMatching(/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.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 + 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("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" 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("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"); + 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("nightly"); + }); +}); + +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(); +});