feat: implement log grouping for better output

Signed-off-by: Lee Briggs <lee@leebriggs.co.uk>
This commit is contained in:
Lee Briggs 2026-07-02 16:40:48 -07:00
parent 780049a30b
commit 73c8a00bae
No known key found for this signature in database
GPG key ID: A4D09B96FDFEB505
6 changed files with 733 additions and 474 deletions

View file

@ -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:

View file

@ -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'

318
dist/index.js generated vendored
View file

@ -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);
}

159
dist/logout/index.js generated vendored
View file

@ -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

View file

@ -10,84 +10,152 @@ import * as path from "path";
const runnerWindows = "Windows";
const runnerMacOS = "macOS";
type LogMode = "grouped" | "normal" | "quiet";
async function logout(): Promise<void> {
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<T>(
logMode: LogMode,
name: string,
fn: () => Promise<T>,
): Promise<T> {
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<number> {
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

View file

@ -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<tailscaleStatus> {
const { stdout } = await execSilent("get tailscale status", cmdTailscale, [
"status",
"--json",
]);
async function getTailscaleStatus(
logMode: LogMode = "normal",
): Promise<tailscaleStatus> {
const { stdout } = await execSilent(
"get tailscale status",
cmdTailscale,
["status", "--json"],
{ logMode },
);
return JSON.parse(stdout);
}
@ -97,52 +104,80 @@ async function run(): Promise<void> {
// 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<void> {
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<void> {
core.info(`Pinging host ${host}`);
async function pingHost(host: string, logMode: LogMode): Promise<void> {
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<void> {
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<void> {
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<T>(
logMode: LogMode,
name: string,
fn: () => Promise<T>,
): Promise<T> {
if (logMode !== "grouped") {
return fn();
}
core.startGroup(name);
try {
return await fn();
} finally {
core.endGroup();
}
}
async function getInputs(): Promise<TailscaleConfig> {
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<TailscaleConfig> {
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<string> {
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<void> {
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<void> {
@ -651,7 +770,7 @@ async function startTailscaleDaemon(config: TailscaleConfig): Promise<void> {
...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<void> {
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<void> {
async function waitForDaemonReady(logMode: LogMode): Promise<void> {
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<void> {
} 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<never>((_, 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<void> {
@ -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<void> {
async function configureDNSOnMacOS(
status: tailscaleStatus,
logMode: LogMode,
): Promise<void> {
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<exec.ExecOutput> {
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);
}