diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 3de2510..22f738d 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -30,8 +30,8 @@ jobs: - name: Install Dependencies run: npm ci - - name: Build Action - run: npm run build + - name: Build Action and Run Local Smoke Tests + run: npm test - name: Test Action Loading (No Auth) uses: ./ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8356546..a1c95b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: # TODO(mpminardi): revisit / remove this if / when we give dependabot a tailnet for # testing with a smaller blast radius. if: ${{ github.event_name == 'push' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') }} - name: ${{ matrix.os }} (${{ matrix.arch }}) (${{ matrix.credential-type }}) tailscale-${{ matrix.version }} + name: ${{ matrix.os }} (${{ matrix.arch }}) (${{ matrix.credential-type }}) tailscale-${{ matrix.version || 'default' }} ${{ matrix.install-type && format('({0})', matrix.install-type) || '' }} strategy: fail-fast: false matrix: @@ -79,22 +79,23 @@ jobs: version: latest ping: 100.99.0.2,lax-pve.pineapplefish.ts.net,lax-pve credential-type: oauth + install-type: source - # macOS latest (ARM) + # macOS latest (ARM), explicitly exercising the Homebrew install path. - os: macos-latest runner-os: macOS arch: arm64 - version: latest ping: 100.99.0.2,lax-pve.pineapplefish.ts.net,lax-pve credential-type: oauth + install-type: brew # Try workload identity for each platform - os: macos-latest runner-os: macOS arch: amd64 - version: latest ping: 100.99.0.2,lax-pve.pineapplefish.ts.net,lax-pve credential-type: workload-identity + install-type: brew - os: windows-latest runner-os: Windows @@ -110,7 +111,6 @@ jobs: version: unstable credential-type: workload-identity - runs-on: ${{ matrix.os }} steps: @@ -129,6 +129,23 @@ jobs: - name: Build Action run: npm run build + - name: Resolve Homebrew Tailscale Version + id: brew-version + if: matrix.install-type == 'brew' + shell: bash + run: | + version="$(brew info --json=v2 --formula tailscale | node -e 'let input = ""; process.stdin.on("data", d => input += d); process.stdin.on("end", () => console.log(JSON.parse(input).formulae[0].versions.stable));')" + echo "version=${version}" >> "${GITHUB_OUTPUT}" + + - name: Force Source Install Path + if: matrix.install-type == 'source' + shell: bash + run: | + mkdir -p "${RUNNER_TEMP}/no-brew" + printf '#!/usr/bin/env bash\nexit 127\n' > "${RUNNER_TEMP}/no-brew/brew" + chmod +x "${RUNNER_TEMP}/no-brew/brew" + echo "${RUNNER_TEMP}/no-brew" >> "${GITHUB_PATH}" + # Test with OAuth authentication - name: Test Action id: tailscale-oauth @@ -138,12 +155,28 @@ jobs: oauth-secret: ${{ matrix.credential-type == 'oauth' && secrets.TS_AUTH_KEYS_OAUTH_CLIENT_SECRET || '' }} audience: ${{ matrix.credential-type == 'workload-identity' && secrets.TS_AUDIENCE || ''}} tags: "tag:ci" - version: "${{ matrix.version }}" + version: "${{ matrix.install-type == 'brew' && steps.brew-version.outputs.version || matrix.version }}" use-cache: false timeout: "5m" retry: 3 ping: "${{ matrix.ping }}" + - name: Verify Explicit Install Type + if: matrix.install-type + shell: bash + run: | + if [ "${{ matrix.install-type }}" = "brew" ]; then + brew list --formula tailscale + sudo -E tailscale status + elif [ "${{ matrix.install-type }}" = "source" ]; then + if brew --version >/dev/null 2>&1; then + echo "brew should have been unavailable for source install test" + exit 1 + fi + test -x /usr/local/bin/tailscale + test -x /usr/local/bin/tailscaled + fi + # Look up names to make sure MagicDNS is working - name: Look up qualified name run: nslookup lax-pve.pineapplefish.ts.net diff --git a/dist/index.js b/dist/index.js index feea710..6f8204a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -52131,10 +52131,10 @@ async function run() { // Set architecture config.arch = getTailscaleArch(runnerOS); // Install Tailscale - await installTailscale(config, runnerOS); + const installedWith = await installTailscale(config, runnerOS); // Start daemon (non-Windows only) if (runnerOS !== runnerWindows) { - await startTailscaleDaemon(config); + await startTailscaleDaemon(config, installedWith); } // Connect to Tailscale await connectToTailscale(config, runnerOS); @@ -52345,9 +52345,10 @@ async function installTailscale(config, runnerOS) { // For Linux/macOS, copy binaries to /usr/local/bin await installCachedBinaries(toolPath, runnerOS); } - return; + return "cache"; } } + let installedWith = "source"; // Install fresh if not cached if (runnerOS === runnerLinux) { await installTailscaleLinux(config, toolPath); @@ -52356,10 +52357,10 @@ async function installTailscale(config, runnerOS) { await installTailscaleWindows(config, toolPath); } else if (runnerOS === runnerMacOS) { - await installTailscaleMacOS(config, toolPath); + installedWith = await installTailscaleMacOS(config, toolPath); } // Save to cache after installation - if (config.useCache && cacheKey) { + if (config.useCache && cacheKey && installedWith !== "brew") { try { await cache.saveCache([toolPath], cacheKey); core.info(`Cached Tailscale ${config.resolvedVersion} at: ${toolPath}`); @@ -52377,6 +52378,7 @@ async function installTailscale(config, runnerOS) { } } } + return installedWith; } async function calculateFileSha256(filePath) { return new Promise((resolve, reject) => { @@ -52522,6 +52524,108 @@ async function installTailscaleWindows(config, toolPath, fromCache = false) { core.addPath("C:\\Program Files\\Tailscale\\"); } async function installTailscaleMacOS(config, toolPath) { + if (!(await isHomebrewAvailable())) { + core.notice("Homebrew not found on macOS runner; installing Tailscale from source."); + await installTailscaleFromSourceOnMacOS(config, toolPath); + return "source"; + } + const formulaVersion = await getHomebrewTailscaleFormulaVersion(); + if (formulaVersion === undefined) { + await installTailscaleFromSourceOnMacOS(config, toolPath); + return "source"; + } + if (formulaVersion === config.resolvedVersion) { + core.info(`Installing Tailscale ${config.resolvedVersion} via Homebrew`); + await installTailscaleWithHomebrew(config.resolvedVersion); + return "brew"; + } + core.notice(`Homebrew tailscale formula version ${formulaVersion} does not match requested version ${config.resolvedVersion}; installing Tailscale from source.`); + await installTailscaleFromSourceOnMacOS(config, toolPath); + return "source"; +} +async function isHomebrewAvailable() { + try { + const out = await exec.getExecOutput("brew", ["--version"], { + silent: true, + ignoreReturnCode: true, + }); + return out.exitCode === 0; + } + catch (error) { + core.debug(`Homebrew availability check failed: ${error}`); + return false; + } +} +async function getHomebrewTailscaleFormulaVersion() { + let out; + try { + out = await exec.getExecOutput("brew", ["info", "--json=v2", "--formula", cmdTailscale], { + silent: true, + ignoreReturnCode: true, + }); + } + catch (error) { + core.notice(`Unable to inspect Homebrew tailscale formula metadata: ${error}; installing Tailscale from source.`); + return undefined; + } + if (out.exitCode !== 0) { + core.notice("Unable to inspect Homebrew tailscale formula; installing Tailscale from source."); + return undefined; + } + try { + const info = JSON.parse(out.stdout); + return info?.formulae?.[0]?.versions?.stable; + } + catch (error) { + core.notice(`Unable to parse Homebrew tailscale formula metadata: ${error}; installing Tailscale from source.`); + return undefined; + } +} +async function installTailscaleWithHomebrew(resolvedVersion) { + await execSilent("install tailscale via homebrew", "brew", [ + "install", + "--formula", + cmdTailscale, + ]); + let installedVersion = await getHomebrewInstalledTailscaleVersion(); + if (installedVersion !== resolvedVersion) { + core.info(`Installed Homebrew tailscale version ${installedVersion || "unknown"} does not match requested version ${resolvedVersion}; attempting upgrade.`); + await execSilent("upgrade tailscale via homebrew", "brew", [ + "upgrade", + "--formula", + cmdTailscale, + ]); + installedVersion = await getHomebrewInstalledTailscaleVersion(); + } + if (installedVersion !== resolvedVersion) { + throw new Error(`Homebrew installed tailscale version ${installedVersion || "unknown"}, expected ${resolvedVersion}`); + } +} +async function getHomebrewInstalledTailscaleVersion() { + let out; + try { + out = await exec.getExecOutput("brew", ["info", "--json=v2", "--formula", cmdTailscale], { + silent: true, + ignoreReturnCode: true, + }); + } + catch (error) { + core.debug(`Unable to inspect installed Homebrew tailscale version: ${error}`); + return undefined; + } + if (out.exitCode !== 0) { + return undefined; + } + try { + const info = JSON.parse(out.stdout); + return info?.formulae?.[0]?.installed?.[0]?.version; + } + catch (error) { + core.debug(`Unable to parse installed Homebrew tailscale version: ${error}`); + return undefined; + } +} +async function installTailscaleFromSourceOnMacOS(config, toolPath) { core.info("Building tailscale from src on macOS..."); // Clone the repo await execSilent("clone tailscale repo", "git clone https://github.com/tailscale/tailscale.git tailscale"); @@ -52561,8 +52665,10 @@ async function installTailscaleMacOS(config, toolPath) { ]); core.info("✅ Tailscale installed successfully on macOS from source"); } -async function startTailscaleDaemon(config) { - const runnerOS = process.env.RUNNER_OS || ""; +async function startTailscaleDaemon(config, installedWith) { + if (installedWith === "brew") { + core.info("Starting Homebrew-installed tailscaled daemon manually..."); + } // Manual daemon start const stateArgs = config.stateDir ? [`--statedir=${config.stateDir}`] diff --git a/package.json b/package.json index 2af541a..cd7de31 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "node": ">=24.0.0" }, "scripts": { - "build": "ncc build src/main.ts -o dist && ncc build src/logout/logout.ts -o dist/logout" + "build": "ncc build src/main.ts -o dist && ncc build src/logout/logout.ts -o dist/logout", + "test": "npm run build && npm run test:smoke", + "test:smoke": "node --test test/*.test.js" }, "repository": { "type": "git", diff --git a/src/main.ts b/src/main.ts index dbc3d1c..e29d699 100644 --- a/src/main.ts +++ b/src/main.ts @@ -64,6 +64,8 @@ type tailscaleStatus = { CurrentTailnet: tailnetInfo; }; +type installMethod = "brew" | "cache" | "source"; + // Cross-platform Tailscale local API status check async function getTailscaleStatus(): Promise { const { stdout } = await execSilent("get tailscale status", cmdTailscale, [ @@ -105,11 +107,11 @@ async function run(): Promise { config.arch = getTailscaleArch(runnerOS); // Install Tailscale - await installTailscale(config, runnerOS); + const installedWith = await installTailscale(config, runnerOS); // Start daemon (non-Windows only) if (runnerOS !== runnerWindows) { - await startTailscaleDaemon(config); + await startTailscaleDaemon(config, installedWith); } // Connect to Tailscale @@ -341,7 +343,7 @@ function getTailscaleArch(runnerOS: string): string { async function installTailscale( config: TailscaleConfig, runnerOS: string, -): Promise { +): Promise { const cacheKey = generateCacheKey(config, runnerOS); const toolPath = getToolPath(config, runnerOS); @@ -360,21 +362,23 @@ async function installTailscale( // For Linux/macOS, copy binaries to /usr/local/bin await installCachedBinaries(toolPath, runnerOS); } - return; + return "cache"; } } + let installedWith: installMethod = "source"; + // Install fresh if not cached if (runnerOS === runnerLinux) { await installTailscaleLinux(config, toolPath); } else if (runnerOS === runnerWindows) { await installTailscaleWindows(config, toolPath); } else if (runnerOS === runnerMacOS) { - await installTailscaleMacOS(config, toolPath); + installedWith = await installTailscaleMacOS(config, toolPath); } // Save to cache after installation - if (config.useCache && cacheKey) { + if (config.useCache && cacheKey && installedWith !== "brew") { try { await cache.saveCache([toolPath], cacheKey); core.info(`Cached Tailscale ${config.resolvedVersion} at: ${toolPath}`); @@ -389,6 +393,8 @@ async function installTailscale( } } } + + return installedWith; } async function calculateFileSha256(filePath: string): Promise { @@ -571,6 +577,156 @@ async function installTailscaleWindows( async function installTailscaleMacOS( config: TailscaleConfig, toolPath: string, +): Promise { + if (!(await isHomebrewAvailable())) { + core.notice( + "Homebrew not found on macOS runner; installing Tailscale from source.", + ); + await installTailscaleFromSourceOnMacOS(config, toolPath); + return "source"; + } + + const formulaVersion = await getHomebrewTailscaleFormulaVersion(); + if (formulaVersion === undefined) { + await installTailscaleFromSourceOnMacOS(config, toolPath); + return "source"; + } + + if (formulaVersion === config.resolvedVersion) { + core.info(`Installing Tailscale ${config.resolvedVersion} via Homebrew`); + await installTailscaleWithHomebrew(config.resolvedVersion); + return "brew"; + } + + core.notice( + `Homebrew tailscale formula version ${formulaVersion} does not match requested version ${config.resolvedVersion}; installing Tailscale from source.`, + ); + await installTailscaleFromSourceOnMacOS(config, toolPath); + return "source"; +} + +async function isHomebrewAvailable(): Promise { + try { + const out = await exec.getExecOutput("brew", ["--version"], { + silent: true, + ignoreReturnCode: true, + }); + return out.exitCode === 0; + } catch (error) { + core.debug(`Homebrew availability check failed: ${error}`); + return false; + } +} + +async function getHomebrewTailscaleFormulaVersion(): Promise< + string | undefined +> { + let out: exec.ExecOutput; + try { + out = await exec.getExecOutput( + "brew", + ["info", "--json=v2", "--formula", cmdTailscale], + { + silent: true, + ignoreReturnCode: true, + }, + ); + } catch (error) { + core.notice( + `Unable to inspect Homebrew tailscale formula metadata: ${error}; installing Tailscale from source.`, + ); + return undefined; + } + + if (out.exitCode !== 0) { + core.notice( + "Unable to inspect Homebrew tailscale formula; installing Tailscale from source.", + ); + return undefined; + } + + try { + const info = JSON.parse(out.stdout); + return info?.formulae?.[0]?.versions?.stable; + } catch (error) { + core.notice( + `Unable to parse Homebrew tailscale formula metadata: ${error}; installing Tailscale from source.`, + ); + return undefined; + } +} + +async function installTailscaleWithHomebrew( + resolvedVersion: string, +): Promise { + await execSilent("install tailscale via homebrew", "brew", [ + "install", + "--formula", + cmdTailscale, + ]); + + let installedVersion = await getHomebrewInstalledTailscaleVersion(); + if (installedVersion !== resolvedVersion) { + core.info( + `Installed Homebrew tailscale version ${ + installedVersion || "unknown" + } does not match requested version ${resolvedVersion}; attempting upgrade.`, + ); + await execSilent("upgrade tailscale via homebrew", "brew", [ + "upgrade", + "--formula", + cmdTailscale, + ]); + installedVersion = await getHomebrewInstalledTailscaleVersion(); + } + + if (installedVersion !== resolvedVersion) { + throw new Error( + `Homebrew installed tailscale version ${ + installedVersion || "unknown" + }, expected ${resolvedVersion}`, + ); + } +} + +async function getHomebrewInstalledTailscaleVersion(): Promise< + string | undefined +> { + let out: exec.ExecOutput; + try { + out = await exec.getExecOutput( + "brew", + ["info", "--json=v2", "--formula", cmdTailscale], + { + silent: true, + ignoreReturnCode: true, + }, + ); + } catch (error) { + core.debug( + `Unable to inspect installed Homebrew tailscale version: ${error}`, + ); + return undefined; + } + + if (out.exitCode !== 0) { + return undefined; + } + + try { + const info = JSON.parse(out.stdout); + return info?.formulae?.[0]?.installed?.[0]?.version; + } catch (error) { + core.debug( + `Unable to parse installed Homebrew tailscale version: ${error}`, + ); + return undefined; + } +} + +async function installTailscaleFromSourceOnMacOS( + config: TailscaleConfig, + toolPath: string, ): Promise { core.info("Building tailscale from src on macOS..."); @@ -632,8 +788,13 @@ async function installTailscaleMacOS( core.info("✅ Tailscale installed successfully on macOS from source"); } -async function startTailscaleDaemon(config: TailscaleConfig): Promise { - const runnerOS = process.env.RUNNER_OS || ""; +async function startTailscaleDaemon( + config: TailscaleConfig, + installedWith: installMethod, +): Promise { + if (installedWith === "brew") { + core.info("Starting Homebrew-installed tailscaled daemon manually..."); + } // Manual daemon start const stateArgs = config.stateDir diff --git a/test/macos-homebrew.test.js b/test/macos-homebrew.test.js new file mode 100644 index 0000000..0b41f52 --- /dev/null +++ b/test/macos-homebrew.test.js @@ -0,0 +1,68 @@ +// Copyright (c) Tailscale Inc, & Contributors +// SPDX-License-Identifier: BSD-3-Clause + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const repoRoot = path.resolve(__dirname, ".."); +const source = fs.readFileSync(path.join(repoRoot, "src/main.ts"), "utf8"); +const bundled = fs.readFileSync(path.join(repoRoot, "dist/index.js"), "utf8"); + +test("macOS install flow checks Homebrew without failing the action", () => { + assert.match(source, /async function isHomebrewAvailable\(\)/); + assert.match(source, /exec\.getExecOutput\("brew", \["--version"\]/); + assert.match(source, /ignoreReturnCode: true/); + assert.match(source, /Homebrew availability check failed/); + assert.match(source, /return false/); + assert.match( + source, + /Homebrew not found on macOS runner; installing Tailscale from source\./ + ); +}); + +test("macOS Homebrew path preserves exact requested version semantics", () => { + assert.match(source, /brew",\s*\["info", "--json=v2", "--formula"/); + assert.match(source, /formulaVersion === config\.resolvedVersion/); + assert.match(source, /brew",\s*\[\s*"install",\s*"--formula"/); + assert.match(source, /async function installTailscaleWithHomebrew/); + assert.match(source, /async function getHomebrewInstalledTailscaleVersion/); + assert.match(source, /brew",\s*\[\s*"upgrade",\s*"--formula"/); + assert.match(source, /Homebrew installed tailscale version/); + assert.match( + source, + /Homebrew tailscale formula version \$\{formulaVersion\} does not match requested version/ + ); +}); + +test("Homebrew-owned installs are not saved to the action cache", () => { + assert.match( + source, + /config\.useCache && cacheKey && installedWith !== "brew"/ + ); +}); + +test("Homebrew installs start tailscaled with the manual daemon path", () => { + assert.match( + source, + /Starting Homebrew-installed tailscaled daemon manually/ + ); + assert.match(source, /spawn\("sudo", \["-E", cmdTailscaled, \.\.\.args\]/); + assert.doesNotMatch(source, /"brew",\s*\[\s*"services",\s*"start"/); +}); + +test("bundled action includes the macOS Homebrew smoke path", () => { + assert.match( + bundled, + /Homebrew not found on macOS runner; installing Tailscale from source\./ + ); + assert.match( + bundled, + /Installing Tailscale \$\{config\.resolvedVersion\} via Homebrew/ + ); + assert.match( + bundled, + /Starting Homebrew-installed tailscaled daemon manually/ + ); +}); diff --git a/tsconfig.json b/tsconfig.json index ca4ac69..8f52031 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -43,7 +43,7 @@ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ - // "types": [], /* Type declaration files to be included in compilation. */ + "types": ["node"], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */