diff --git a/README.md b/README.md index 08344dd..385dd93 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ and log out immediately after finishing their CI run, at which point they are au by the coordination server. The nodes are also [marked Preapproved][kb-auth-keys] on tailnets which use [Device Approval][kb-device-approval] - ### Workload identity federation + [Workload identity federation][kb-workload-identity-federation] can also be used for authenticating nodes with your tailnet: ```yaml @@ -92,6 +92,27 @@ tailscale ping my-target.my-tailnet.ts.net The `ping` option will wait up to 3 minutes for a connection (direct or relayed). +## Log mode + +By default, this action folds its major setup and cleanup phases into GitHub Actions log groups. +You can change this with the `log-mode` input: + +```yaml +- name: Tailscale + uses: tailscale/github-action@v4 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} + tags: tag:ci + log-mode: normal +``` + +Supported values are: + +- `grouped`: fold major action phases in the log. This is the default. +- `normal`: print routine action logs without grouping. +- `quiet`: suppress routine informational output while preserving warnings and errors. + ## Tailnet Lock If you are using this Action in a [Tailnet Lock][kb-tailnet-lock] enabled network, you need to: diff --git a/action.yml b/action.yml index 34f6512..19af21a 100644 --- a/action.yml +++ b/action.yml @@ -64,7 +64,11 @@ inputs: description: 'Comma separated list of hosts (Tailscale IP addresses or machine names if MagicDNS is enabled on the tailnet) to `tailscale ping` for connectivity verification after `tailscale up` completes.' required: false default: '' - + log-mode: + description: 'Controls action log output mode. Use `grouped` to fold major setup and cleanup phases, `normal` for ungrouped logs, or `quiet` to suppress routine informational output.' + required: false + default: 'grouped' + runs: using: 'node24' main: 'dist/index.js' diff --git a/dist/index.js b/dist/index.js index 2e549e5..f3583af 100644 --- a/dist/index.js +++ b/dist/index.js @@ -52102,11 +52102,8 @@ function xdgRuntimeDir() { const versionLatest = "latest"; const versionUnstable = "unstable"; // Cross-platform Tailscale local API status check -async function getTailscaleStatus() { - const { stdout } = await execSilent("get tailscale status", cmdTailscale, [ - "status", - "--json", - ]); +async function getTailscaleStatus(logMode = "normal") { + const { stdout } = await execSilent("get tailscale status", cmdTailscale, ["status", "--json"], { logMode }); return JSON.parse(stdout); } async function run() { @@ -52125,47 +52122,53 @@ async function run() { } // Validate authentication validateAuth(config); - // Resolve version - config.resolvedVersion = await resolveVersion(config.version, runnerOS); - core.info(`Resolved Tailscale version: ${config.resolvedVersion}`); + await withLogGroup(config.logMode, "Resolving Tailscale version", async () => { + config.resolvedVersion = await resolveVersion(config.version, runnerOS, config.logMode); + logInfo(config.logMode, `Resolved Tailscale version: ${config.resolvedVersion}`); + }); // Set architecture config.arch = getTailscaleArch(runnerOS); - // Install Tailscale - await installTailscale(config, runnerOS); - // Start daemon (non-Windows only) + await withLogGroup(config.logMode, "Installing Tailscale", async () => { + await installTailscale(config, runnerOS); + }); if (runnerOS !== runnerWindows) { - await startTailscaleDaemon(config); + await withLogGroup(config.logMode, "Starting tailscaled", async () => { + await startTailscaleDaemon(config); + }); } - // Connect to Tailscale - await connectToTailscale(config, runnerOS); - // Check Tailscale status (cross-platform) - try { - const status = await getTailscaleStatus(); - if (status.BackendState === "Running") { - core.info("✅ Tailscale is running and connected!"); - if (runnerOS === runnerMacOS) { - await configureDNSOnMacOS(status); + await withLogGroup(config.logMode, "Connecting to Tailscale", async () => { + await connectToTailscale(config, runnerOS); + }); + let shouldPingHosts = false; + await withLogGroup(config.logMode, "Checking Tailscale status", async () => { + try { + const status = await getTailscaleStatus(config.logMode); + if (status.BackendState === "Running") { + logInfo(config.logMode, "✅ Tailscale is running and connected!"); + if (runnerOS === runnerMacOS) { + await configureDNSOnMacOS(status, config.logMode); + } + shouldPingHosts = true; + } + else { + core.setFailed(`❌ Tailscale backend state: ${status.BackendState}`); + process.exitCode = 1; } - await pingHostsIfNecessary(config); - // Explicitly exit to prevent hanging - process.exit(0); } - else { - core.setFailed(`❌ Tailscale backend state: ${status.BackendState}`); - process.exit(1); + catch (err) { + core.warning(`Failed to get Tailscale status: ${err}`); + if (runnerOS === runnerMacOS) { + core.setFailed(`❌ Tailscale status is required in order to configure macOS`); + process.exitCode = 2; + return; + } + // Still exit successfully since the main connection worked + logInfo(config.logMode, "✅ Tailscale daemon is connected!"); + shouldPingHosts = true; } - } - catch (err) { - core.warning(`Failed to get Tailscale status: ${err}`); - if (runnerOS === runnerMacOS) { - core.setFailed(`❌ Tailscale status is required in order to configure macOS`); - process.exit(2); - } - // Still exit successfully since the main connection worked - core.info("✅ Tailscale daemon is connected!"); + }); + if (shouldPingHosts) { await pingHostsIfNecessary(config); - // Explicitly exit to prevent hanging - process.exit(0); } } catch (error) { @@ -52176,14 +52179,16 @@ async function pingHostsIfNecessary(config) { if (config.pingHosts.length == 0) { return; } - core.info(`Will ping hosts ${config.pingHosts.join(",")} up to 3 minutes each (in parallel) in order to check connectivity`); - let pings = config.pingHosts.map((host) => pingHost(host)); - for (const ping of pings) { - await ping; - } + await withLogGroup(config.logMode, "Pinging Tailscale hosts", async () => { + logInfo(config.logMode, `Will ping hosts ${config.pingHosts.join(",")} up to 3 minutes each (in parallel) in order to check connectivity`); + let pings = config.pingHosts.map((host) => pingHost(host, config.logMode)); + for (const ping of pings) { + await ping; + } + }); } -async function pingHost(host) { - core.info(`Pinging host ${host}`); +async function pingHost(host, logMode) { + logInfo(logMode, `Pinging host ${host}`); let start = new Date().getTime(); var i = 0; // Try for up to 180 seconds (3 minutes). @@ -52191,35 +52196,63 @@ async function pingHost(host) { if (i > 0) { // Exponential backoff on wait time, with maximum 5 second wait. let waitTime = Math.min(Math.pow(1.3, i), 5000); - core.debug(`Waiting ${waitTime} milliseconds before pinging`); + logDebug(logMode, `Waiting ${waitTime} milliseconds before pinging`); await (0, promises_1.setTimeout)(waitTime); } try { - let result = await execSilent("ping host", cmdTailscale, [ - "ping", - "-c", - "1", - host, - ]); - core.info(`✅ Ping host ${host} reachable via direct connection!`); + await execSilent("ping host", cmdTailscale, ["ping", "-c", "1", host], { + logMode, + }); + logInfo(logMode, `✅ Ping host ${host} reachable via direct connection!`); return; } catch (err) { if (err instanceof execError && err.stderr.includes("direct connection not established")) { // Relayed connectivity is good enough, we don't want to tie up a CI job waiting for a direct connection. - core.info(`✅ Ping host ${host} reachable via DERP!`); + logInfo(logMode, `✅ Ping host ${host} reachable via DERP!`); return; } } i++; } - core.setFailed(`❌ Ping host ${host} did not respond`); - process.exit(1); + throw new Error(`❌ Ping host ${host} did not respond`); +} +function getLogMode() { + const logMode = core.getInput("log-mode") || "grouped"; + if (logMode !== "grouped" && + logMode !== "normal" && + logMode !== "quiet") { + throw new Error(`Invalid log-mode "${logMode}". Expected "grouped", "normal", or "quiet".`); + } + return logMode; +} +function logInfo(logMode, message) { + if (logMode !== "quiet") { + core.info(message); + } +} +function logDebug(logMode, message) { + if (logMode !== "quiet") { + core.debug(message); + } +} +async function withLogGroup(logMode, name, fn) { + if (logMode !== "grouped") { + return fn(); + } + core.startGroup(name); + try { + return await fn(); + } + finally { + core.endGroup(); + } } async function getInputs() { let ping = core.getInput("ping"); let pingHosts = ping?.length > 0 ? ping.split(",") : []; + const logMode = getLogMode(); const authKey = core.getInput("authkey") || ""; const oauthSecret = core.getInput("oauth-secret") || ""; // Mask sensitive values in logs unless debug mode is enabled @@ -52249,6 +52282,7 @@ async function getInputs() { useCache: core.getBooleanInput("use-cache"), sha256Sum: core.getInput("sha256sum") || "", pingHosts: pingHosts, + logMode: logMode, }; if (config.oauthSecret && !config.tags) { throw new Error("the tags parameter is required when using an OAuth client"); @@ -52267,19 +52301,14 @@ function validateAuth(config) { throw new Error("Workload identity federation requires using tailscale version 1.90.0 or later."); } } -async function resolveVersion(version, runnerOS) { +async function resolveVersion(version, runnerOS, logMode) { if (runnerOS === runnerMacOS && version === versionUnstable) { return "main"; } if (version === versionLatest || version === versionUnstable) { let path = version === versionUnstable ? versionUnstable : "stable"; let pkg = `https://pkgs.tailscale.com/${path}/?mode=json`; - const { stdout } = await execSilent(`curl ${pkg}`, "curl", [ - "-H", - "user-agent:action-setup-tailscale", - "-s", - pkg, - ]); + const { stdout } = await execSilent(`curl ${pkg}`, "curl", ["-H", "user-agent:action-setup-tailscale", "-s", pkg], { logMode }); const response = JSON.parse(stdout); switch (runnerOS) { case runnerLinux: @@ -52338,14 +52367,14 @@ async function installTailscale(config, runnerOS) { if (config.useCache && cacheKey) { const cacheHit = await cache.restoreCache([toolPath], cacheKey); if (cacheHit) { - core.info(`Found Tailscale ${config.resolvedVersion} in cache: ${toolPath}`); + logInfo(config.logMode, `Found Tailscale ${config.resolvedVersion} in cache: ${toolPath}`); // For Windows, install the cached MSI if (runnerOS === runnerWindows) { await installTailscaleWindows(config, toolPath, true); } else { // For Linux/macOS, copy binaries to /usr/local/bin - await installCachedBinaries(toolPath, runnerOS); + await installCachedBinaries(config, toolPath, runnerOS); } return; } @@ -52364,7 +52393,7 @@ async function installTailscale(config, runnerOS) { if (config.useCache && cacheKey) { try { await cache.saveCache([toolPath], cacheKey); - core.info(`Cached Tailscale ${config.resolvedVersion} at: ${toolPath}`); + logInfo(config.logMode, `Cached Tailscale ${config.resolvedVersion} at: ${toolPath}`); } catch (error) { const typedError = error; @@ -52372,7 +52401,7 @@ async function installTailscale(config, runnerOS) { throw error; } else if (typedError.name === cache.ReserveCacheError.name) { - core.info(typedError.message); + logInfo(config.logMode, typedError.message); } else { core.warning(`Cache save failed: ${typedError.message}`); @@ -52399,26 +52428,20 @@ async function installTailscaleLinux(config, toolPath) { // Get SHA256 if not provided if (!config.sha256Sum) { const shaUrl = `${baseUrl}/tailscale_${config.resolvedVersion}_${config.arch}.tgz.sha256`; - const { stdout } = await execSilent(`curl ${shaUrl}`, "curl", [ - "-H", - "user-agent:action-setup-tailscale", - "-L", - shaUrl, - "--fail", - ]); + const { stdout } = await execSilent(`curl ${shaUrl}`, "curl", ["-H", "user-agent:action-setup-tailscale", "-L", shaUrl, "--fail"], { logMode: config.logMode }); config.sha256Sum = stdout.trim(); } // Download and extract const downloadUrl = `${baseUrl}/tailscale_${config.resolvedVersion}_${config.arch}.tgz`; - core.info(`Downloading ${downloadUrl}`); + logInfo(config.logMode, `Downloading ${downloadUrl}`); const tarDest = path.join(xdgCacheDir(), "tailscale.tgz"); fs.mkdirSync(path.dirname(tarDest), { recursive: true }); const tarPath = await tc.downloadTool(downloadUrl, tarDest); // Verify checksum const actualSha = await calculateFileSha256(tarPath); const expectedSha = config.sha256Sum.trim().toLowerCase(); - core.info(`Expected sha256: ${expectedSha}`); - core.info(`Actual sha256: ${actualSha}`); + logInfo(config.logMode, `Expected sha256: ${expectedSha}`); + logInfo(config.logMode, `Actual sha256: ${actualSha}`); if (actualSha !== expectedSha) { throw new Error("SHA256 checksum mismatch"); } @@ -52435,18 +52458,10 @@ async function installTailscaleLinux(config, toolPath) { path.join(toolPath, cmdTailscale), path.join(toolPath, cmdTailscaled), "/usr/local/bin", - ]); + ], { logMode: config.logMode }); // Make sure they're executable - await execSilent("chmod tailscale binary", "sudo", [ - "chmod", - "+x", - cmdTailscaleFullPath, - ]); - await execSilent("chmod tailscaled binary", "sudo", [ - "chmod", - "+x", - cmdTailscaledFullPath, - ]); + await execSilent("chmod tailscale binary", "sudo", ["chmod", "+x", cmdTailscaleFullPath], { logMode: config.logMode }); + await execSilent("chmod tailscaled binary", "sudo", ["chmod", "+x", cmdTailscaledFullPath], { logMode: config.logMode }); } async function installTailscaleWindows(config, toolPath, fromCache = false) { // Create tool directory @@ -52457,7 +52472,7 @@ async function installTailscaleWindows(config, toolPath, fromCache = false) { if (!fs.existsSync(msiPath)) { throw new Error(`Cached MSI not found at ${msiPath}`); } - core.info(`Installing cached MSI from ${msiPath}`); + logInfo(config.logMode, `Installing cached MSI from ${msiPath}`); } else { // Fresh download @@ -52470,13 +52485,7 @@ async function installTailscaleWindows(config, toolPath, fromCache = false) { // Get SHA256 if not provided if (!config.sha256Sum) { const shaUrl = `${baseUrl}/tailscale-setup-${config.resolvedVersion}-${config.arch}.msi.sha256`; - const { stdout } = await execSilent(`curl ${shaUrl}`, "curl", [ - "-H", - "user-agent:action-setup-tailscale", - "-L", - shaUrl, - "--fail", - ]); + const { stdout } = await execSilent(`curl ${shaUrl}`, "curl", ["-H", "user-agent:action-setup-tailscale", "-L", shaUrl, "--fail"], { logMode: config.logMode }); config.sha256Sum = stdout.trim(); } // Download MSI @@ -52487,21 +52496,21 @@ async function installTailscaleWindows(config, toolPath, fromCache = false) { if (fs.existsSync(msiPath)) { const existingSha = await calculateFileSha256(msiPath); if (existingSha === expectedSha) { - core.info(`Using existing MSI at ${msiPath} (checksum verified)`); + logInfo(config.logMode, `Using existing MSI at ${msiPath} (checksum verified)`); needsDownload = false; } else { - core.info(`Existing MSI checksum mismatch, re-downloading`); + logInfo(config.logMode, `Existing MSI checksum mismatch, re-downloading`); fs.unlinkSync(msiPath); } } if (needsDownload) { - core.info(`Downloading ${downloadUrl}`); + logInfo(config.logMode, `Downloading ${downloadUrl}`); const downloadedMsiPath = await tc.downloadTool(downloadUrl, msiPath); // Verify checksum const actualSha = await calculateFileSha256(downloadedMsiPath); - core.info(`Expected sha256: ${expectedSha}`); - core.info(`Actual sha256: ${actualSha}`); + logInfo(config.logMode, `Expected sha256: ${expectedSha}`); + logInfo(config.logMode, `Actual sha256: ${actualSha}`); if (actualSha !== expectedSha) { throw new Error("SHA256 checksum mismatch"); } @@ -52519,17 +52528,18 @@ async function installTailscaleWindows(config, toolPath, fromCache = false) { path.join(process.env.RUNNER_TEMP || "", "tailscale.log"), "/i", msiPath, - ]); + ], { logMode: config.logMode }); // Add to PATH core.addPath("C:\\Program Files\\Tailscale\\"); } async function installTailscaleMacOS(config, toolPath) { - core.info("Building tailscale from src on macOS..."); + logInfo(config.logMode, "Building tailscale from src on macOS..."); // Clone the repo - await execSilent("clone tailscale repo", "git clone https://github.com/tailscale/tailscale.git tailscale"); + await execSilent("clone tailscale repo", "git clone https://github.com/tailscale/tailscale.git tailscale", [], { logMode: config.logMode }); // Checkout the resolved version await execSilent("checkout resolved version", `git checkout v${config.resolvedVersion}`, [], { cwd: cmdTailscale, + logMode: config.logMode, }); // Create tool directory and copy binaries there for caching fs.mkdirSync(toolPath, { recursive: true }); @@ -52541,6 +52551,7 @@ async function installTailscaleMacOS(config, toolPath) { ...process.env, TS_USE_TOOLCHAIN: "1", }, + logMode: config.logMode, }); } // Install binaries to /usr/local/bin @@ -52549,19 +52560,11 @@ async function installTailscaleMacOS(config, toolPath) { path.join(toolPath, cmdTailscale), path.join(toolPath, cmdTailscaled), "/usr/local/bin", - ]); + ], { logMode: config.logMode }); // Make sure they're executable - await execSilent("chmod tailscale", "sudo", [ - "chmod", - "+x", - cmdTailscaleFullPath, - ]); - await execSilent("chmod tailscaled", "sudo", [ - "chmod", - "+x", - cmdTailscaledFullPath, - ]); - core.info("✅ Tailscale installed successfully on macOS from source"); + await execSilent("chmod tailscale", "sudo", ["chmod", "+x", cmdTailscaleFullPath], { logMode: config.logMode }); + await execSilent("chmod tailscaled", "sudo", ["chmod", "+x", cmdTailscaledFullPath], { logMode: config.logMode }); + logInfo(config.logMode, "✅ Tailscale installed successfully on macOS from source"); } async function startTailscaleDaemon(config) { const runnerOS = process.env.RUNNER_OS || ""; @@ -52576,7 +52579,7 @@ async function startTailscaleDaemon(config) { ...stateArgs, ...config.tailscaledArgs.split(" ").filter(Boolean), ]; - core.info("Starting tailscaled daemon..."); + logInfo(config.logMode, "Starting tailscaled daemon..."); // Start daemon in background const daemon = (0, child_process_1.spawn)("sudo", ["-E", cmdTailscaled, ...args], { detached: true, @@ -52599,28 +52602,28 @@ async function startTailscaleDaemon(config) { if (daemon.stderr) daemon.stderr.destroy(); // Poll the local API until daemon is responsive - await waitForDaemonReady(); - core.info("✅ tailscaled daemon is up and running!"); + await waitForDaemonReady(config.logMode); + logInfo(config.logMode, "✅ tailscaled daemon is up and running!"); } -async function waitForDaemonReady() { +async function waitForDaemonReady(logMode) { const maxWaitMs = 15000; // 15 seconds const pollIntervalMs = 500; let waited = 0; - core.info("Waiting for tailscaled daemon to become ready..."); + logInfo(logMode, "Waiting for tailscaled daemon to become ready..."); var lastErr; while (waited < maxWaitMs) { try { - const status = await getTailscaleStatus(); + const status = await getTailscaleStatus(logMode); // If we get any valid response from the API, the daemon is ready if (status) { - core.info(`Daemon ready! Initial state: ${status.BackendState || "Unknown"}`); + logInfo(logMode, `Daemon ready! Initial state: ${status.BackendState || "Unknown"}`); return; } } catch (err) { // Daemon not ready yet, keep polling lastErr = err; - core.debug(`Waiting for daemon... (${waited}ms elapsed)`); + logDebug(logMode, `Waiting for daemon... (${waited}ms elapsed)`); } await sleep(pollIntervalMs); waited += pollIntervalMs; @@ -52635,7 +52638,9 @@ async function connectToTailscale(config, runnerOS) { hostname = `github-${process.env.COMPUTERNAME}`; } else { - const { stdout } = await execSilent("hostname", "hostname"); + const { stdout } = await execSilent("hostname", "hostname", [], { + logMode: config.logMode, + }); hostname = `github-${stdout.trim()}`; } } @@ -52683,7 +52688,7 @@ async function connectToTailscale(config, runnerOS) { // Retry logic for (let attempt = 1; attempt <= config.retry; attempt++) { try { - core.info(`Attempt ${attempt} to bring up Tailscale...`); + logInfo(config.logMode, `Attempt ${attempt} to bring up Tailscale...`); let execArgs; if (runnerOS === runnerWindows) { execArgs = [cmdTailscale, ...upArgs]; @@ -52694,11 +52699,13 @@ async function connectToTailscale(config, runnerOS) { } const timeoutMs = parseTimeout(config.timeout); await Promise.race([ - execSilent("tailscale up", execArgs[0], execArgs.slice(1)), + execSilent("tailscale up", execArgs[0], execArgs.slice(1), { + logMode: config.logMode, + }), new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs)), ]); // Success - core.info(`✅ Tailscale up command completed successfully on attempt ${attempt}`); + logInfo(config.logMode, `✅ Tailscale up command completed successfully on attempt ${attempt}`); return; } catch (error) { @@ -52707,7 +52714,7 @@ async function connectToTailscale(config, runnerOS) { throw error; } const sleepTime = attempt * 2; // Reduced from 5 to 2 seconds - core.info(`Retrying in ${sleepTime} seconds...`); + logInfo(config.logMode, `Retrying in ${sleepTime} seconds...`); await sleep(sleepTime * 1000); } } @@ -52745,55 +52752,31 @@ function getToolPath(config, runnerOS) { } return path.join(cacheDirectory, cmdTailscale, config.resolvedVersion, `${runnerOS}-${config.arch}`); } -async function installCachedBinaries(toolPath, runnerOS) { +async function installCachedBinaries(config, toolPath, runnerOS) { if (runnerOS === runnerLinux || runnerOS === runnerMacOS) { // Copy cached binaries to /usr/local/bin const tailscaleBin = path.join(toolPath, cmdTailscale); const tailscaledBin = path.join(toolPath, cmdTailscaled); if (fs.existsSync(tailscaleBin) && fs.existsSync(tailscaledBin)) { - await execSilent("copy tailscale from cache", "sudo", [ - "cp", - tailscaleBin, - cmdTailscaleFullPath, - ]); - await execSilent("copy tailscaled from cache", "sudo", [ - "cp", - tailscaledBin, - cmdTailscaledFullPath, - ]); - await execSilent("chmod tailscale", "sudo", [ - "chmod", - "+x", - cmdTailscaleFullPath, - ]); - await execSilent("chmod tailscaled", "sudo", [ - "chmod", - "+x", - cmdTailscaledFullPath, - ]); + await execSilent("copy tailscale from cache", "sudo", ["cp", tailscaleBin, cmdTailscaleFullPath], { logMode: config.logMode }); + await execSilent("copy tailscaled from cache", "sudo", ["cp", tailscaledBin, cmdTailscaledFullPath], { logMode: config.logMode }); + await execSilent("chmod tailscale", "sudo", ["chmod", "+x", cmdTailscaleFullPath], { logMode: config.logMode }); + await execSilent("chmod tailscaled", "sudo", ["chmod", "+x", cmdTailscaledFullPath], { logMode: config.logMode }); } else { throw new Error(`Cached binaries not found in ${toolPath}`); } } } -async function configureDNSOnMacOS(status) { +async function configureDNSOnMacOS(status, logMode) { if (!status.CurrentTailnet.MagicDNSEnabled) { - core.info("MagicDNS is disabled, not configuring DNS"); + logInfo(logMode, "MagicDNS is disabled, not configuring DNS"); return; } - core.info(`Setting system DNS server to 100.100.100.100 and searchdomains to ${status.CurrentTailnet.MagicDNSSuffix}`); + logInfo(logMode, `Setting system DNS server to 100.100.100.100 and searchdomains to ${status.CurrentTailnet.MagicDNSSuffix}`); try { - await execSilent("set dns servers", "networksetup", [ - "-setdnsservers", - "Ethernet", - "100.100.100.100", - ]); - await execSilent("set search domains", "networksetup", [ - "-setsearchdomains", - "Ethernet", - status.CurrentTailnet.MagicDNSSuffix, - ]); + await execSilent("set dns servers", "networksetup", ["-setdnsservers", "Ethernet", "100.100.100.100"], { logMode }); + await execSilent("set search domains", "networksetup", ["-setsearchdomains", "Ethernet", status.CurrentTailnet.MagicDNSSuffix], { logMode }); } catch (e) { throw Error(`Failed to configure DNS on macOS: ${e}`); @@ -52814,14 +52797,15 @@ run(); * @throws execError if exec returned a non-zero status code */ async function execSilent(label, cmd, args, opts) { - core.info(`▶️ ${label}`); + const { logMode = "normal", ...execOpts } = opts || {}; + logInfo(logMode, `▶️ ${label}`); const out = await exec.getExecOutput(cmd, args, { - ...opts, - silent: !core.isDebug(), + ...execOpts, + silent: logMode === "quiet" || !core.isDebug(), ignoreReturnCode: true, }); if (out.exitCode !== 0) { - if (!core.isDebug()) { + if (logMode === "quiet" || !core.isDebug()) { // When debug logging is off, stderr won't have been written to console, write it now. process.stderr.write(out.stderr); } diff --git a/dist/logout/index.js b/dist/logout/index.js index f99401c..d70e9fc 100644 --- a/dist/logout/index.js +++ b/dist/logout/index.js @@ -25946,76 +25946,121 @@ const runnerMacOS = "macOS"; async function logout() { try { const runnerOS = process.env.RUNNER_OS || ""; - if (runnerOS === runnerMacOS) { - // The below is required to allow GitHub's post job cleanup to complete. - core.info("Resetting DNS settings on macOS"); - await exec.exec("networksetup", ["-setdnsservers", "Ethernet", "Empty"]); - await exec.exec("networksetup", [ - "-setsearchdomains", - "Ethernet", - "Empty", - ]); - } - core.info("🔄 Logging out of Tailscale..."); - // Check if tailscale is available first - try { - await exec.exec("tailscale", ["--version"], { silent: true }); - // Determine the correct command based on OS - let execArgs; - if (runnerOS === runnerWindows) { - execArgs = ["tailscale", "logout"]; + const logMode = getLogMode(); + await withLogGroup(logMode, "Cleaning up Tailscale", async () => { + if (runnerOS === runnerMacOS) { + // The below is required to allow GitHub's post job cleanup to complete. + logInfo(logMode, "Resetting DNS settings on macOS"); + await execCommand(logMode, "networksetup", [ + "-setdnsservers", + "Ethernet", + "Empty", + ]); + await execCommand(logMode, "networksetup", [ + "-setsearchdomains", + "Ethernet", + "Empty", + ]); } - else { - // Linux and macOS - use system-installed binary with sudo - execArgs = ["sudo", "-E", "tailscale", "logout"]; - } - core.info(`Running: ${execArgs.join(" ")}`); + logInfo(logMode, "🔄 Logging out of Tailscale..."); + // Check if tailscale is available first try { - await exec.exec(execArgs[0], execArgs.slice(1)); - core.info("✅ Successfully logged out of Tailscale"); + await execCommand(logMode, "tailscale", ["--version"], { + silent: true, + }); + // Determine the correct command based on OS + let execArgs; + if (runnerOS === runnerWindows) { + execArgs = ["tailscale", "logout"]; + } + else { + // Linux and macOS - use system-installed binary with sudo + execArgs = ["sudo", "-E", "tailscale", "logout"]; + } + logInfo(logMode, `Running: ${execArgs.join(" ")}`); + try { + await execCommand(logMode, execArgs[0], execArgs.slice(1)); + logInfo(logMode, "✅ Successfully logged out of Tailscale"); + } + catch (error) { + // Don't fail the action if logout fails - it's just cleanup + core.warning(`Failed to logout from Tailscale: ${error}`); + logInfo(logMode, "Your ephemeral node will eventually be cleaned up by Tailscale"); + } } catch (error) { - // Don't fail the action if logout fails - it's just cleanup - core.warning(`Failed to logout from Tailscale: ${error}`); - core.info("Your ephemeral node will eventually be cleaned up by Tailscale"); + logInfo(logMode, "Tailscale not found or not accessible, skipping logout"); + return; } - } - catch (error) { - core.info("Tailscale not found or not accessible, skipping logout"); - return; - } - core.info("Stopping tailscale"); - try { - if (runnerOS === runnerWindows) { - await exec.exec("net", ["stop", "Tailscale"]); - await exec.exec("taskkill", ["/F", "/IM", "tailscale-ipn.exe"]); - } - else { - const xdgRuntimeDir = process.env.XDG_RUNTIME_DIR || - process.env.XDG_CACHE_HOME || - path.join(os.homedir(), ".cache"); - const pid = fs - .readFileSync(path.join(xdgRuntimeDir, "tailscaled.pid")) - .toString(); - if (pid === "") { - throw new Error("pid file empty"); + logInfo(logMode, "Stopping tailscale"); + try { + if (runnerOS === runnerWindows) { + await execCommand(logMode, "net", ["stop", "Tailscale"]); + await execCommand(logMode, "taskkill", [ + "/F", + "/IM", + "tailscale-ipn.exe", + ]); } - // The pid is actually the pid of the `sudo` parent of tailscaled, so use pkill -P to kill children of that parent - await exec.exec("sudo", ["pkill", "-P", pid]); - // Clean up DNS and routes. - await exec.exec("sudo", ["tailscaled", "--cleanup"]); + else { + const xdgRuntimeDir = process.env.XDG_RUNTIME_DIR || + process.env.XDG_CACHE_HOME || + path.join(os.homedir(), ".cache"); + const pid = fs + .readFileSync(path.join(xdgRuntimeDir, "tailscaled.pid")) + .toString(); + if (pid === "") { + throw new Error("pid file empty"); + } + // The pid is actually the pid of the `sudo` parent of tailscaled, so use pkill -P to kill children of that parent + await execCommand(logMode, "sudo", ["pkill", "-P", pid]); + // Clean up DNS and routes. + await execCommand(logMode, "sudo", ["tailscaled", "--cleanup"]); + } + logInfo(logMode, "✅ Stopped tailscale"); } - core.info("✅ Stopped tailscale"); - } - catch (error) { - core.warning(`Failed to stop tailscale: ${error}`); - } + catch (error) { + core.warning(`Failed to stop tailscale: ${error}`); + } + }); } catch (error) { // Don't fail the action for post-cleanup issues core.warning(`Post-action cleanup error: ${error}`); } } +function getLogMode() { + const logMode = core.getInput("log-mode") || "grouped"; + if (logMode !== "grouped" && + logMode !== "normal" && + logMode !== "quiet") { + throw new Error(`Invalid log-mode "${logMode}". Expected "grouped", "normal", or "quiet".`); + } + return logMode; +} +function logInfo(logMode, message) { + if (logMode !== "quiet") { + core.info(message); + } +} +async function withLogGroup(logMode, name, fn) { + if (logMode !== "grouped") { + return fn(); + } + core.startGroup(name); + try { + return await fn(); + } + finally { + core.endGroup(); + } +} +async function execCommand(logMode, commandLine, args, options) { + return exec.exec(commandLine, args, { + ...options, + silent: options?.silent || logMode === "quiet", + }); +} // Run the logout function logout().catch((error) => { // Even if logout fails, don't fail the action diff --git a/src/logout/logout.ts b/src/logout/logout.ts index ca07e62..d16b7ff 100644 --- a/src/logout/logout.ts +++ b/src/logout/logout.ts @@ -10,84 +10,152 @@ import * as path from "path"; const runnerWindows = "Windows"; const runnerMacOS = "macOS"; +type LogMode = "grouped" | "normal" | "quiet"; + async function logout(): Promise { try { const runnerOS = process.env.RUNNER_OS || ""; + const logMode = getLogMode(); - if (runnerOS === runnerMacOS) { - // The below is required to allow GitHub's post job cleanup to complete. - core.info("Resetting DNS settings on macOS"); - await exec.exec("networksetup", ["-setdnsservers", "Ethernet", "Empty"]); - await exec.exec("networksetup", [ - "-setsearchdomains", - "Ethernet", - "Empty", - ]); - } - - core.info("🔄 Logging out of Tailscale..."); - - // Check if tailscale is available first - try { - await exec.exec("tailscale", ["--version"], { silent: true }); - - // Determine the correct command based on OS - let execArgs: string[]; - if (runnerOS === runnerWindows) { - execArgs = ["tailscale", "logout"]; - } else { - // Linux and macOS - use system-installed binary with sudo - execArgs = ["sudo", "-E", "tailscale", "logout"]; + await withLogGroup(logMode, "Cleaning up Tailscale", async () => { + if (runnerOS === runnerMacOS) { + // The below is required to allow GitHub's post job cleanup to complete. + logInfo(logMode, "Resetting DNS settings on macOS"); + await execCommand(logMode, "networksetup", [ + "-setdnsservers", + "Ethernet", + "Empty", + ]); + await execCommand(logMode, "networksetup", [ + "-setsearchdomains", + "Ethernet", + "Empty", + ]); } - core.info(`Running: ${execArgs.join(" ")}`); + logInfo(logMode, "🔄 Logging out of Tailscale..."); + // Check if tailscale is available first try { - await exec.exec(execArgs[0], execArgs.slice(1)); - core.info("✅ Successfully logged out of Tailscale"); - } catch (error) { - // Don't fail the action if logout fails - it's just cleanup - core.warning(`Failed to logout from Tailscale: ${error}`); - core.info( - "Your ephemeral node will eventually be cleaned up by Tailscale", - ); - } - } catch (error) { - core.info("Tailscale not found or not accessible, skipping logout"); - return; - } + await execCommand(logMode, "tailscale", ["--version"], { + silent: true, + }); - core.info("Stopping tailscale"); - try { - if (runnerOS === runnerWindows) { - await exec.exec("net", ["stop", "Tailscale"]); - await exec.exec("taskkill", ["/F", "/IM", "tailscale-ipn.exe"]); - } else { - const xdgRuntimeDir = - process.env.XDG_RUNTIME_DIR || - process.env.XDG_CACHE_HOME || - path.join(os.homedir(), ".cache"); - const pid = fs - .readFileSync(path.join(xdgRuntimeDir, "tailscaled.pid")) - .toString(); - if (pid === "") { - throw new Error("pid file empty"); + // Determine the correct command based on OS + let execArgs: string[]; + if (runnerOS === runnerWindows) { + execArgs = ["tailscale", "logout"]; + } else { + // Linux and macOS - use system-installed binary with sudo + execArgs = ["sudo", "-E", "tailscale", "logout"]; } - // The pid is actually the pid of the `sudo` parent of tailscaled, so use pkill -P to kill children of that parent - await exec.exec("sudo", ["pkill", "-P", pid]); - // Clean up DNS and routes. - await exec.exec("sudo", ["tailscaled", "--cleanup"]); + + logInfo(logMode, `Running: ${execArgs.join(" ")}`); + + try { + await execCommand(logMode, execArgs[0], execArgs.slice(1)); + logInfo(logMode, "✅ Successfully logged out of Tailscale"); + } catch (error) { + // Don't fail the action if logout fails - it's just cleanup + core.warning(`Failed to logout from Tailscale: ${error}`); + logInfo( + logMode, + "Your ephemeral node will eventually be cleaned up by Tailscale", + ); + } + } catch (error) { + logInfo( + logMode, + "Tailscale not found or not accessible, skipping logout", + ); + return; } - core.info("✅ Stopped tailscale"); - } catch (error) { - core.warning(`Failed to stop tailscale: ${error}`); - } + + logInfo(logMode, "Stopping tailscale"); + try { + if (runnerOS === runnerWindows) { + await execCommand(logMode, "net", ["stop", "Tailscale"]); + await execCommand(logMode, "taskkill", [ + "/F", + "/IM", + "tailscale-ipn.exe", + ]); + } else { + const xdgRuntimeDir = + process.env.XDG_RUNTIME_DIR || + process.env.XDG_CACHE_HOME || + path.join(os.homedir(), ".cache"); + const pid = fs + .readFileSync(path.join(xdgRuntimeDir, "tailscaled.pid")) + .toString(); + if (pid === "") { + throw new Error("pid file empty"); + } + // The pid is actually the pid of the `sudo` parent of tailscaled, so use pkill -P to kill children of that parent + await execCommand(logMode, "sudo", ["pkill", "-P", pid]); + // Clean up DNS and routes. + await execCommand(logMode, "sudo", ["tailscaled", "--cleanup"]); + } + logInfo(logMode, "✅ Stopped tailscale"); + } catch (error) { + core.warning(`Failed to stop tailscale: ${error}`); + } + }); } catch (error) { // Don't fail the action for post-cleanup issues core.warning(`Post-action cleanup error: ${error}`); } } +function getLogMode(): LogMode { + const logMode = core.getInput("log-mode") || "grouped"; + if ( + logMode !== "grouped" && + logMode !== "normal" && + logMode !== "quiet" + ) { + throw new Error( + `Invalid log-mode "${logMode}". Expected "grouped", "normal", or "quiet".`, + ); + } + return logMode; +} + +function logInfo(logMode: LogMode, message: string): void { + if (logMode !== "quiet") { + core.info(message); + } +} + +async function withLogGroup( + logMode: LogMode, + name: string, + fn: () => Promise, +): Promise { + if (logMode !== "grouped") { + return fn(); + } + + core.startGroup(name); + try { + return await fn(); + } finally { + core.endGroup(); + } +} + +async function execCommand( + logMode: LogMode, + commandLine: string, + args?: string[], + options?: exec.ExecOptions, +): Promise { + return exec.exec(commandLine, args, { + ...options, + silent: options?.silent || logMode === "quiet", + }); +} + // Run the logout function logout().catch((error) => { // Even if logout fails, don't fail the action diff --git a/src/main.ts b/src/main.ts index a77592c..6996125 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,6 +34,8 @@ function xdgRuntimeDir(): string { const versionLatest = "latest"; const versionUnstable = "unstable"; +type LogMode = "grouped" | "normal" | "quiet"; + interface TailscaleConfig { version: string; resolvedVersion: string; @@ -52,6 +54,7 @@ interface TailscaleConfig { useCache: boolean; sha256Sum: string; pingHosts: string[]; + logMode: LogMode; } type tailnetInfo = { @@ -65,11 +68,15 @@ type tailscaleStatus = { }; // Cross-platform Tailscale local API status check -async function getTailscaleStatus(): Promise { - const { stdout } = await execSilent("get tailscale status", cmdTailscale, [ - "status", - "--json", - ]); +async function getTailscaleStatus( + logMode: LogMode = "normal", +): Promise { + const { stdout } = await execSilent( + "get tailscale status", + cmdTailscale, + ["status", "--json"], + { logMode }, + ); return JSON.parse(stdout); } @@ -97,52 +104,80 @@ async function run(): Promise { // Validate authentication validateAuth(config); - // Resolve version - config.resolvedVersion = await resolveVersion(config.version, runnerOS); - core.info(`Resolved Tailscale version: ${config.resolvedVersion}`); + await withLogGroup( + config.logMode, + "Resolving Tailscale version", + async () => { + config.resolvedVersion = await resolveVersion( + config.version, + runnerOS, + config.logMode, + ); + logInfo( + config.logMode, + `Resolved Tailscale version: ${config.resolvedVersion}`, + ); + }, + ); // Set architecture config.arch = getTailscaleArch(runnerOS); - // Install Tailscale - await installTailscale(config, runnerOS); + await withLogGroup(config.logMode, "Installing Tailscale", async () => { + await installTailscale(config, runnerOS); + }); - // Start daemon (non-Windows only) if (runnerOS !== runnerWindows) { - await startTailscaleDaemon(config); + await withLogGroup(config.logMode, "Starting tailscaled", async () => { + await startTailscaleDaemon(config); + }); } - // Connect to Tailscale - await connectToTailscale(config, runnerOS); + await withLogGroup( + config.logMode, + "Connecting to Tailscale", + async () => { + await connectToTailscale(config, runnerOS); + }, + ); - // Check Tailscale status (cross-platform) - try { - const status = await getTailscaleStatus(); - if (status.BackendState === "Running") { - core.info("✅ Tailscale is running and connected!"); - if (runnerOS === runnerMacOS) { - await configureDNSOnMacOS(status); + let shouldPingHosts = false; + await withLogGroup( + config.logMode, + "Checking Tailscale status", + async () => { + try { + const status = await getTailscaleStatus(config.logMode); + if (status.BackendState === "Running") { + logInfo(config.logMode, "✅ Tailscale is running and connected!"); + if (runnerOS === runnerMacOS) { + await configureDNSOnMacOS(status, config.logMode); + } + shouldPingHosts = true; + } else { + core.setFailed( + `❌ Tailscale backend state: ${status.BackendState}`, + ); + process.exitCode = 1; + } + } catch (err) { + core.warning(`Failed to get Tailscale status: ${err}`); + if (runnerOS === runnerMacOS) { + core.setFailed( + `❌ Tailscale status is required in order to configure macOS`, + ); + process.exitCode = 2; + return; + } + // Still exit successfully since the main connection worked + logInfo(config.logMode, "✅ Tailscale daemon is connected!"); + shouldPingHosts = true; } - await pingHostsIfNecessary(config); - // Explicitly exit to prevent hanging - process.exit(0); - } else { - core.setFailed(`❌ Tailscale backend state: ${status.BackendState}`); - process.exit(1); - } - } catch (err) { - core.warning(`Failed to get Tailscale status: ${err}`); - if (runnerOS === runnerMacOS) { - core.setFailed( - `❌ Tailscale status is required in order to configure macOS`, - ); - process.exit(2); - } - // Still exit successfully since the main connection worked - core.info("✅ Tailscale daemon is connected!"); + }, + ); + + if (shouldPingHosts) { await pingHostsIfNecessary(config); - // Explicitly exit to prevent hanging - process.exit(0); } } catch (error) { core.setFailed(error instanceof Error ? error.message : String(error)); @@ -154,19 +189,24 @@ async function pingHostsIfNecessary(config: TailscaleConfig): Promise { return; } - core.info( - `Will ping hosts ${config.pingHosts.join( - ",", - )} up to 3 minutes each (in parallel) in order to check connectivity`, - ); - let pings = config.pingHosts.map((host) => pingHost(host)); - for (const ping of pings) { - await ping; - } + await withLogGroup(config.logMode, "Pinging Tailscale hosts", async () => { + logInfo( + config.logMode, + `Will ping hosts ${config.pingHosts.join( + ",", + )} up to 3 minutes each (in parallel) in order to check connectivity`, + ); + let pings = config.pingHosts.map((host) => + pingHost(host, config.logMode), + ); + for (const ping of pings) { + await ping; + } + }); } -async function pingHost(host: string): Promise { - core.info(`Pinging host ${host}`); +async function pingHost(host: string, logMode: LogMode): Promise { + logInfo(logMode, `Pinging host ${host}`); let start = new Date().getTime(); var i = 0; // Try for up to 180 seconds (3 minutes). @@ -174,17 +214,17 @@ async function pingHost(host: string): Promise { if (i > 0) { // Exponential backoff on wait time, with maximum 5 second wait. let waitTime = Math.min(Math.pow(1.3, i), 5000); - core.debug(`Waiting ${waitTime} milliseconds before pinging`); + logDebug(logMode, `Waiting ${waitTime} milliseconds before pinging`); await wait(waitTime); } try { - let result = await execSilent("ping host", cmdTailscale, [ - "ping", - "-c", - "1", - host, - ]); - core.info(`✅ Ping host ${host} reachable via direct connection!`); + await execSilent("ping host", cmdTailscale, ["ping", "-c", "1", host], { + logMode, + }); + logInfo( + logMode, + `✅ Ping host ${host} reachable via direct connection!`, + ); return; } catch (err) { if ( @@ -192,19 +232,62 @@ async function pingHost(host: string): Promise { err.stderr.includes("direct connection not established") ) { // Relayed connectivity is good enough, we don't want to tie up a CI job waiting for a direct connection. - core.info(`✅ Ping host ${host} reachable via DERP!`); + logInfo(logMode, `✅ Ping host ${host} reachable via DERP!`); return; } } i++; } - core.setFailed(`❌ Ping host ${host} did not respond`); - process.exit(1); + throw new Error(`❌ Ping host ${host} did not respond`); +} + +function getLogMode(): LogMode { + const logMode = core.getInput("log-mode") || "grouped"; + if ( + logMode !== "grouped" && + logMode !== "normal" && + logMode !== "quiet" + ) { + throw new Error( + `Invalid log-mode "${logMode}". Expected "grouped", "normal", or "quiet".`, + ); + } + return logMode; +} + +function logInfo(logMode: LogMode, message: string): void { + if (logMode !== "quiet") { + core.info(message); + } +} + +function logDebug(logMode: LogMode, message: string): void { + if (logMode !== "quiet") { + core.debug(message); + } +} + +async function withLogGroup( + logMode: LogMode, + name: string, + fn: () => Promise, +): Promise { + if (logMode !== "grouped") { + return fn(); + } + + core.startGroup(name); + try { + return await fn(); + } finally { + core.endGroup(); + } } async function getInputs(): Promise { let ping = core.getInput("ping"); let pingHosts = ping?.length > 0 ? ping.split(",") : []; + const logMode = getLogMode(); const authKey = core.getInput("authkey") || ""; const oauthSecret = core.getInput("oauth-secret") || ""; @@ -237,6 +320,7 @@ async function getInputs(): Promise { useCache: core.getBooleanInput("use-cache"), sha256Sum: core.getInput("sha256sum") || "", pingHosts: pingHosts, + logMode: logMode, }; if (config.oauthSecret && !config.tags) { @@ -273,6 +357,7 @@ function validateAuth(config: TailscaleConfig): void { async function resolveVersion( version: string, runnerOS: string, + logMode: LogMode, ): Promise { if (runnerOS === runnerMacOS && version === versionUnstable) { return "main"; @@ -281,12 +366,12 @@ async function resolveVersion( if (version === versionLatest || version === versionUnstable) { let path = version === versionUnstable ? versionUnstable : "stable"; let pkg = `https://pkgs.tailscale.com/${path}/?mode=json`; - const { stdout } = await execSilent(`curl ${pkg}`, "curl", [ - "-H", - "user-agent:action-setup-tailscale", - "-s", - pkg, - ]); + const { stdout } = await execSilent( + `curl ${pkg}`, + "curl", + ["-H", "user-agent:action-setup-tailscale", "-s", pkg], + { logMode }, + ); const response = JSON.parse(stdout); switch (runnerOS) { case runnerLinux: @@ -351,7 +436,8 @@ async function installTailscale( if (config.useCache && cacheKey) { const cacheHit = await cache.restoreCache([toolPath], cacheKey); if (cacheHit) { - core.info( + logInfo( + config.logMode, `Found Tailscale ${config.resolvedVersion} in cache: ${toolPath}`, ); @@ -360,7 +446,7 @@ async function installTailscale( await installTailscaleWindows(config, toolPath, true); } else { // For Linux/macOS, copy binaries to /usr/local/bin - await installCachedBinaries(toolPath, runnerOS); + await installCachedBinaries(config, toolPath, runnerOS); } return; } @@ -379,13 +465,16 @@ async function installTailscale( if (config.useCache && cacheKey) { try { await cache.saveCache([toolPath], cacheKey); - core.info(`Cached Tailscale ${config.resolvedVersion} at: ${toolPath}`); + logInfo( + config.logMode, + `Cached Tailscale ${config.resolvedVersion} at: ${toolPath}`, + ); } catch (error) { const typedError = error as Error; if (typedError.name === cache.ValidationError.name) { throw error; } else if (typedError.name === cache.ReserveCacheError.name) { - core.info(typedError.message); + logInfo(config.logMode, typedError.message); } else { core.warning(`Cache save failed: ${typedError.message}`); } @@ -417,19 +506,18 @@ async function installTailscaleLinux( // Get SHA256 if not provided if (!config.sha256Sum) { const shaUrl = `${baseUrl}/tailscale_${config.resolvedVersion}_${config.arch}.tgz.sha256`; - const { stdout } = await execSilent(`curl ${shaUrl}`, "curl", [ - "-H", - "user-agent:action-setup-tailscale", - "-L", - shaUrl, - "--fail", - ]); + const { stdout } = await execSilent( + `curl ${shaUrl}`, + "curl", + ["-H", "user-agent:action-setup-tailscale", "-L", shaUrl, "--fail"], + { logMode: config.logMode }, + ); config.sha256Sum = stdout.trim(); } // Download and extract const downloadUrl = `${baseUrl}/tailscale_${config.resolvedVersion}_${config.arch}.tgz`; - core.info(`Downloading ${downloadUrl}`); + logInfo(config.logMode, `Downloading ${downloadUrl}`); const tarDest = path.join(xdgCacheDir(), "tailscale.tgz"); fs.mkdirSync(path.dirname(tarDest), { recursive: true }); @@ -438,8 +526,8 @@ async function installTailscaleLinux( // Verify checksum const actualSha = await calculateFileSha256(tarPath); const expectedSha = config.sha256Sum.trim().toLowerCase(); - core.info(`Expected sha256: ${expectedSha}`); - core.info(`Actual sha256: ${actualSha}`); + logInfo(config.logMode, `Expected sha256: ${expectedSha}`); + logInfo(config.logMode, `Actual sha256: ${actualSha}`); if (actualSha !== expectedSha) { throw new Error("SHA256 checksum mismatch"); } @@ -463,24 +551,31 @@ async function installTailscaleLinux( ); // Install binaries to /usr/local/bin - await execSilent("copy tailscale binaries to /usr/local/bin", "sudo", [ - "cp", - path.join(toolPath, cmdTailscale), - path.join(toolPath, cmdTailscaled), - "/usr/local/bin", - ]); + await execSilent( + "copy tailscale binaries to /usr/local/bin", + "sudo", + [ + "cp", + path.join(toolPath, cmdTailscale), + path.join(toolPath, cmdTailscaled), + "/usr/local/bin", + ], + { logMode: config.logMode }, + ); // Make sure they're executable - await execSilent("chmod tailscale binary", "sudo", [ - "chmod", - "+x", - cmdTailscaleFullPath, - ]); - await execSilent("chmod tailscaled binary", "sudo", [ - "chmod", - "+x", - cmdTailscaledFullPath, - ]); + await execSilent( + "chmod tailscale binary", + "sudo", + ["chmod", "+x", cmdTailscaleFullPath], + { logMode: config.logMode }, + ); + await execSilent( + "chmod tailscaled binary", + "sudo", + ["chmod", "+x", cmdTailscaledFullPath], + { logMode: config.logMode }, + ); } async function installTailscaleWindows( @@ -497,7 +592,7 @@ async function installTailscaleWindows( if (!fs.existsSync(msiPath)) { throw new Error(`Cached MSI not found at ${msiPath}`); } - core.info(`Installing cached MSI from ${msiPath}`); + logInfo(config.logMode, `Installing cached MSI from ${msiPath}`); } else { // Fresh download // Determine if stable or unstable @@ -510,13 +605,12 @@ async function installTailscaleWindows( // Get SHA256 if not provided if (!config.sha256Sum) { const shaUrl = `${baseUrl}/tailscale-setup-${config.resolvedVersion}-${config.arch}.msi.sha256`; - const { stdout } = await execSilent(`curl ${shaUrl}`, "curl", [ - "-H", - "user-agent:action-setup-tailscale", - "-L", - shaUrl, - "--fail", - ]); + const { stdout } = await execSilent( + `curl ${shaUrl}`, + "curl", + ["-H", "user-agent:action-setup-tailscale", "-L", shaUrl, "--fail"], + { logMode: config.logMode }, + ); config.sha256Sum = stdout.trim(); } @@ -529,22 +623,28 @@ async function installTailscaleWindows( if (fs.existsSync(msiPath)) { const existingSha = await calculateFileSha256(msiPath); if (existingSha === expectedSha) { - core.info(`Using existing MSI at ${msiPath} (checksum verified)`); + logInfo( + config.logMode, + `Using existing MSI at ${msiPath} (checksum verified)`, + ); needsDownload = false; } else { - core.info(`Existing MSI checksum mismatch, re-downloading`); + logInfo( + config.logMode, + `Existing MSI checksum mismatch, re-downloading`, + ); fs.unlinkSync(msiPath); } } if (needsDownload) { - core.info(`Downloading ${downloadUrl}`); + logInfo(config.logMode, `Downloading ${downloadUrl}`); const downloadedMsiPath = await tc.downloadTool(downloadUrl, msiPath); // Verify checksum const actualSha = await calculateFileSha256(downloadedMsiPath); - core.info(`Expected sha256: ${expectedSha}`); - core.info(`Actual sha256: ${actualSha}`); + logInfo(config.logMode, `Expected sha256: ${expectedSha}`); + logInfo(config.logMode, `Actual sha256: ${actualSha}`); if (actualSha !== expectedSha) { throw new Error("SHA256 checksum mismatch"); } @@ -558,13 +658,18 @@ async function installTailscaleWindows( } // Install MSI (same for both fresh and cached) - await execSilent("install msi", "msiexec.exe", [ - "/quiet", - `/l*v`, - path.join(process.env.RUNNER_TEMP || "", "tailscale.log"), - "/i", - msiPath, - ]); + await execSilent( + "install msi", + "msiexec.exe", + [ + "/quiet", + `/l*v`, + path.join(process.env.RUNNER_TEMP || "", "tailscale.log"), + "/i", + msiPath, + ], + { logMode: config.logMode }, + ); // Add to PATH core.addPath("C:\\Program Files\\Tailscale\\"); @@ -574,12 +679,14 @@ async function installTailscaleMacOS( config: TailscaleConfig, toolPath: string, ): Promise { - core.info("Building tailscale from src on macOS..."); + logInfo(config.logMode, "Building tailscale from src on macOS..."); // Clone the repo await execSilent( "clone tailscale repo", "git clone https://github.com/tailscale/tailscale.git tailscale", + [], + { logMode: config.logMode }, ); // Checkout the resolved version @@ -589,6 +696,7 @@ async function installTailscaleMacOS( [], { cwd: cmdTailscale, + logMode: config.logMode, }, ); @@ -607,31 +715,42 @@ async function installTailscaleMacOS( ...process.env, TS_USE_TOOLCHAIN: "1", }, + logMode: config.logMode, }, ); } // Install binaries to /usr/local/bin - await execSilent("copy binaries to /usr/local/bin", "sudo", [ - "cp", - path.join(toolPath, cmdTailscale), - path.join(toolPath, cmdTailscaled), - "/usr/local/bin", - ]); + await execSilent( + "copy binaries to /usr/local/bin", + "sudo", + [ + "cp", + path.join(toolPath, cmdTailscale), + path.join(toolPath, cmdTailscaled), + "/usr/local/bin", + ], + { logMode: config.logMode }, + ); // Make sure they're executable - await execSilent("chmod tailscale", "sudo", [ - "chmod", - "+x", - cmdTailscaleFullPath, - ]); - await execSilent("chmod tailscaled", "sudo", [ - "chmod", - "+x", - cmdTailscaledFullPath, - ]); + await execSilent( + "chmod tailscale", + "sudo", + ["chmod", "+x", cmdTailscaleFullPath], + { logMode: config.logMode }, + ); + await execSilent( + "chmod tailscaled", + "sudo", + ["chmod", "+x", cmdTailscaledFullPath], + { logMode: config.logMode }, + ); - core.info("✅ Tailscale installed successfully on macOS from source"); + logInfo( + config.logMode, + "✅ Tailscale installed successfully on macOS from source", + ); } async function startTailscaleDaemon(config: TailscaleConfig): Promise { @@ -651,7 +770,7 @@ async function startTailscaleDaemon(config: TailscaleConfig): Promise { ...config.tailscaledArgs.split(" ").filter(Boolean), ]; - core.info("Starting tailscaled daemon..."); + logInfo(config.logMode, "Starting tailscaled daemon..."); // Start daemon in background const daemon = spawn("sudo", ["-E", cmdTailscaled, ...args], { @@ -676,25 +795,26 @@ async function startTailscaleDaemon(config: TailscaleConfig): Promise { if (daemon.stderr) daemon.stderr.destroy(); // Poll the local API until daemon is responsive - await waitForDaemonReady(); + await waitForDaemonReady(config.logMode); - core.info("✅ tailscaled daemon is up and running!"); + logInfo(config.logMode, "✅ tailscaled daemon is up and running!"); } -async function waitForDaemonReady(): Promise { +async function waitForDaemonReady(logMode: LogMode): Promise { const maxWaitMs = 15000; // 15 seconds const pollIntervalMs = 500; let waited = 0; - core.info("Waiting for tailscaled daemon to become ready..."); + logInfo(logMode, "Waiting for tailscaled daemon to become ready..."); var lastErr: any; while (waited < maxWaitMs) { try { - const status = await getTailscaleStatus(); + const status = await getTailscaleStatus(logMode); // If we get any valid response from the API, the daemon is ready if (status) { - core.info( + logInfo( + logMode, `Daemon ready! Initial state: ${status.BackendState || "Unknown"}`, ); return; @@ -702,7 +822,7 @@ async function waitForDaemonReady(): Promise { } catch (err) { // Daemon not ready yet, keep polling lastErr = err; - core.debug(`Waiting for daemon... (${waited}ms elapsed)`); + logDebug(logMode, `Waiting for daemon... (${waited}ms elapsed)`); } await sleep(pollIntervalMs); waited += pollIntervalMs; @@ -723,7 +843,9 @@ async function connectToTailscale( if (runnerOS === runnerWindows) { hostname = `github-${process.env.COMPUTERNAME}`; } else { - const { stdout } = await execSilent("hostname", "hostname"); + const { stdout } = await execSilent("hostname", "hostname", [], { + logMode: config.logMode, + }); hostname = `github-${stdout.trim()}`; } } @@ -777,7 +899,7 @@ async function connectToTailscale( // Retry logic for (let attempt = 1; attempt <= config.retry; attempt++) { try { - core.info(`Attempt ${attempt} to bring up Tailscale...`); + logInfo(config.logMode, `Attempt ${attempt} to bring up Tailscale...`); let execArgs: string[]; if (runnerOS === runnerWindows) { @@ -789,14 +911,17 @@ async function connectToTailscale( const timeoutMs = parseTimeout(config.timeout); await Promise.race([ - execSilent("tailscale up", execArgs[0], execArgs.slice(1)), + execSilent("tailscale up", execArgs[0], execArgs.slice(1), { + logMode: config.logMode, + }), new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs), ), ]); // Success - core.info( + logInfo( + config.logMode, `✅ Tailscale up command completed successfully on attempt ${attempt}`, ); return; @@ -807,7 +932,7 @@ async function connectToTailscale( } const sleepTime = attempt * 2; // Reduced from 5 to 2 seconds - core.info(`Retrying in ${sleepTime} seconds...`); + logInfo(config.logMode, `Retrying in ${sleepTime} seconds...`); await sleep(sleepTime * 1000); } } @@ -862,6 +987,7 @@ function getToolPath(config: TailscaleConfig, runnerOS: string): string { } async function installCachedBinaries( + config: TailscaleConfig, toolPath: string, runnerOS: string, ): Promise { @@ -871,52 +997,62 @@ async function installCachedBinaries( const tailscaledBin = path.join(toolPath, cmdTailscaled); if (fs.existsSync(tailscaleBin) && fs.existsSync(tailscaledBin)) { - await execSilent("copy tailscale from cache", "sudo", [ - "cp", - tailscaleBin, - cmdTailscaleFullPath, - ]); - await execSilent("copy tailscaled from cache", "sudo", [ - "cp", - tailscaledBin, - cmdTailscaledFullPath, - ]); - await execSilent("chmod tailscale", "sudo", [ - "chmod", - "+x", - cmdTailscaleFullPath, - ]); - await execSilent("chmod tailscaled", "sudo", [ - "chmod", - "+x", - cmdTailscaledFullPath, - ]); + await execSilent( + "copy tailscale from cache", + "sudo", + ["cp", tailscaleBin, cmdTailscaleFullPath], + { logMode: config.logMode }, + ); + await execSilent( + "copy tailscaled from cache", + "sudo", + ["cp", tailscaledBin, cmdTailscaledFullPath], + { logMode: config.logMode }, + ); + await execSilent( + "chmod tailscale", + "sudo", + ["chmod", "+x", cmdTailscaleFullPath], + { logMode: config.logMode }, + ); + await execSilent( + "chmod tailscaled", + "sudo", + ["chmod", "+x", cmdTailscaledFullPath], + { logMode: config.logMode }, + ); } else { throw new Error(`Cached binaries not found in ${toolPath}`); } } } -async function configureDNSOnMacOS(status: tailscaleStatus): Promise { +async function configureDNSOnMacOS( + status: tailscaleStatus, + logMode: LogMode, +): Promise { if (!status.CurrentTailnet.MagicDNSEnabled) { - core.info("MagicDNS is disabled, not configuring DNS"); + logInfo(logMode, "MagicDNS is disabled, not configuring DNS"); return; } - core.info( + logInfo( + logMode, `Setting system DNS server to 100.100.100.100 and searchdomains to ${status.CurrentTailnet.MagicDNSSuffix}`, ); try { - await execSilent("set dns servers", "networksetup", [ - "-setdnsservers", - "Ethernet", - "100.100.100.100", - ]); - await execSilent("set search domains", "networksetup", [ - "-setsearchdomains", - "Ethernet", - status.CurrentTailnet.MagicDNSSuffix, - ]); + await execSilent( + "set dns servers", + "networksetup", + ["-setdnsservers", "Ethernet", "100.100.100.100"], + { logMode }, + ); + await execSilent( + "set search domains", + "networksetup", + ["-setsearchdomains", "Ethernet", status.CurrentTailnet.MagicDNSSuffix], + { logMode }, + ); } catch (e) { throw Error(`Failed to configure DNS on macOS: ${e}`); } @@ -941,16 +1077,17 @@ async function execSilent( label: string, cmd: string, args?: string[], - opts?: {}, + opts?: exec.ExecOptions & { logMode?: LogMode }, ): Promise { - core.info(`▶️ ${label}`); + const { logMode = "normal", ...execOpts } = opts || {}; + logInfo(logMode, `▶️ ${label}`); const out = await exec.getExecOutput(cmd, args, { - ...opts, - silent: !core.isDebug(), + ...execOpts, + silent: logMode === "quiet" || !core.isDebug(), ignoreReturnCode: true, }); if (out.exitCode !== 0) { - if (!core.isDebug()) { + if (logMode === "quiet" || !core.isDebug()) { // When debug logging is off, stderr won't have been written to console, write it now. process.stderr.write(out.stderr); }