Merge pull request #304 from tailscale/log_groups

feat: implement log grouping for better output
This commit is contained in:
Lee Briggs 2026-07-02 23:37:17 -04:00 committed by GitHub
commit 508737e196
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 928 additions and 536 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'

443
dist/index.js generated vendored
View file

@ -52031,6 +52031,119 @@ module.exports = {
}
/***/ }),
/***/ 91338:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
// Copyright (c) Lee Briggs, Tailscale Inc, & Contributors
// SPDX-License-Identifier: BSD-3-Clause
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.ExecError = void 0;
exports.getLogMode = getLogMode;
exports.logInfo = logInfo;
exports.logDebug = logDebug;
exports.withLogGroup = withLogGroup;
exports.execCommand = execCommand;
const core = __importStar(__nccwpck_require__(37484));
const exec = __importStar(__nccwpck_require__(95236));
const process = __importStar(__nccwpck_require__(932));
class ExecError {
constructor(msg, exitCode, stderr) {
this.msg = msg;
this.exitCode = exitCode;
this.stderr = stderr;
}
toString() {
return this.msg;
}
}
exports.ExecError = ExecError;
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 execCommand(commandLine, args, opts) {
const { label, logMode = "normal", ...execOpts } = opts || {};
if (label) {
logInfo(logMode, `▶️ ${label}`);
}
const silent = execOpts.silent || logMode === "quiet" || !core.isDebug();
const out = await exec.getExecOutput(commandLine, args, {
...execOpts,
silent,
ignoreReturnCode: true,
});
if (out.exitCode !== 0) {
if (silent) {
process.stderr.write(out.stderr);
}
throw new ExecError(`${commandLine} failed with exit code ${out.exitCode}`, out.exitCode, out.stderr);
}
return out;
}
/***/ }),
/***/ 41730:
@ -52076,7 +52189,6 @@ var __importStar = (this && this.__importStar) || (function () {
Object.defineProperty(exports, "__esModule", ({ value: true }));
const cache = __importStar(__nccwpck_require__(5116));
const core = __importStar(__nccwpck_require__(37484));
const exec = __importStar(__nccwpck_require__(95236));
const tc = __importStar(__nccwpck_require__(33472));
const child_process_1 = __nccwpck_require__(35317);
const crypto = __importStar(__nccwpck_require__(76982));
@ -52085,6 +52197,7 @@ const os = __importStar(__nccwpck_require__(70857));
const path = __importStar(__nccwpck_require__(16928));
const semver = __importStar(__nccwpck_require__(62088));
const promises_1 = __nccwpck_require__(16460);
const logging_1 = __nccwpck_require__(91338);
const cmdTailscale = "tailscale";
const cmdTailscaleFullPath = "/usr/local/bin/tailscale";
const cmdTailscaled = "tailscaled";
@ -52102,11 +52215,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 +52235,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 (0, logging_1.withLogGroup)(config.logMode, "Resolving Tailscale version", async () => {
config.resolvedVersion = await resolveVersion(config.version, runnerOS, config.logMode);
(0, logging_1.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 (0, logging_1.withLogGroup)(config.logMode, "Installing Tailscale", async () => {
await installTailscale(config, runnerOS);
});
if (runnerOS !== runnerWindows) {
await startTailscaleDaemon(config);
await (0, logging_1.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 (0, logging_1.withLogGroup)(config.logMode, "Connecting to Tailscale", async () => {
await connectToTailscale(config, runnerOS);
});
let shouldPingHosts = false;
await (0, logging_1.withLogGroup)(config.logMode, "Checking Tailscale status", async () => {
try {
const status = await getTailscaleStatus(config.logMode);
if (status.BackendState === "Running") {
(0, logging_1.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
(0, logging_1.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 +52292,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 (0, logging_1.withLogGroup)(config.logMode, "Pinging Tailscale hosts", async () => {
(0, logging_1.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) {
(0, logging_1.logInfo)(logMode, `Pinging host ${host}`);
let start = new Date().getTime();
var i = 0;
// Try for up to 180 seconds (3 minutes).
@ -52191,35 +52309,32 @@ 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`);
(0, logging_1.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,
});
(0, logging_1.logInfo)(logMode, `✅ Ping host ${host} reachable via direct connection!`);
return;
}
catch (err) {
if (err instanceof execError &&
if (err instanceof logging_1.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!`);
(0, logging_1.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`);
}
async function getInputs() {
let ping = core.getInput("ping");
let pingHosts = ping?.length > 0 ? ping.split(",") : [];
const logMode = (0, logging_1.getLogMode)();
const authKey = core.getInput("authkey") || "";
const oauthSecret = core.getInput("oauth-secret") || "";
// Mask sensitive values in logs unless debug mode is enabled
@ -52249,6 +52364,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 +52383,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 +52449,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}`);
(0, logging_1.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 +52475,7 @@ async function installTailscale(config, runnerOS) {
if (config.useCache && cacheKey) {
try {
await cache.saveCache([toolPath], cacheKey);
core.info(`Cached Tailscale ${config.resolvedVersion} at: ${toolPath}`);
(0, logging_1.logInfo)(config.logMode, `Cached Tailscale ${config.resolvedVersion} at: ${toolPath}`);
}
catch (error) {
const typedError = error;
@ -52372,7 +52483,7 @@ async function installTailscale(config, runnerOS) {
throw error;
}
else if (typedError.name === cache.ReserveCacheError.name) {
core.info(typedError.message);
(0, logging_1.logInfo)(config.logMode, typedError.message);
}
else {
core.warning(`Cache save failed: ${typedError.message}`);
@ -52399,26 +52510,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}`);
(0, logging_1.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}`);
(0, logging_1.logInfo)(config.logMode, `Expected sha256: ${expectedSha}`);
(0, logging_1.logInfo)(config.logMode, `Actual sha256: ${actualSha}`);
if (actualSha !== expectedSha) {
throw new Error("SHA256 checksum mismatch");
}
@ -52435,18 +52540,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 +52554,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}`);
(0, logging_1.logInfo)(config.logMode, `Installing cached MSI from ${msiPath}`);
}
else {
// Fresh download
@ -52470,13 +52567,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 +52578,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)`);
(0, logging_1.logInfo)(config.logMode, `Using existing MSI at ${msiPath} (checksum verified)`);
needsDownload = false;
}
else {
core.info(`Existing MSI checksum mismatch, re-downloading`);
(0, logging_1.logInfo)(config.logMode, `Existing MSI checksum mismatch, re-downloading`);
fs.unlinkSync(msiPath);
}
}
if (needsDownload) {
core.info(`Downloading ${downloadUrl}`);
(0, logging_1.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}`);
(0, logging_1.logInfo)(config.logMode, `Expected sha256: ${expectedSha}`);
(0, logging_1.logInfo)(config.logMode, `Actual sha256: ${actualSha}`);
if (actualSha !== expectedSha) {
throw new Error("SHA256 checksum mismatch");
}
@ -52519,17 +52610,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...");
(0, logging_1.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 +52633,7 @@ async function installTailscaleMacOS(config, toolPath) {
...process.env,
TS_USE_TOOLCHAIN: "1",
},
logMode: config.logMode,
});
}
// Install binaries to /usr/local/bin
@ -52549,19 +52642,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 });
(0, logging_1.logInfo)(config.logMode, "✅ Tailscale installed successfully on macOS from source");
}
async function startTailscaleDaemon(config) {
const runnerOS = process.env.RUNNER_OS || "";
@ -52576,7 +52661,7 @@ async function startTailscaleDaemon(config) {
...stateArgs,
...config.tailscaledArgs.split(" ").filter(Boolean),
];
core.info("Starting tailscaled daemon...");
(0, logging_1.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 +52684,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);
(0, logging_1.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...");
(0, logging_1.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"}`);
(0, logging_1.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)`);
(0, logging_1.logDebug)(logMode, `Waiting for daemon... (${waited}ms elapsed)`);
}
await sleep(pollIntervalMs);
waited += pollIntervalMs;
@ -52635,7 +52720,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 +52770,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...`);
(0, logging_1.logInfo)(config.logMode, `Attempt ${attempt} to bring up Tailscale...`);
let execArgs;
if (runnerOS === runnerWindows) {
execArgs = [cmdTailscale, ...upArgs];
@ -52693,12 +52780,24 @@ async function connectToTailscale(config, runnerOS) {
execArgs = ["sudo", "-E", cmdTailscale, ...upArgs];
}
const timeoutMs = parseTimeout(config.timeout);
await Promise.race([
execSilent("tailscale up", execArgs[0], execArgs.slice(1)),
new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs)),
]);
let timeoutHandle;
try {
await Promise.race([
execSilent("tailscale up", execArgs[0], execArgs.slice(1), {
logMode: config.logMode,
}),
new Promise((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error("Timeout")), timeoutMs);
}),
]);
}
finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
// Success
core.info(`✅ Tailscale up command completed successfully on attempt ${attempt}`);
(0, logging_1.logInfo)(config.logMode, `✅ Tailscale up command completed successfully on attempt ${attempt}`);
return;
}
catch (error) {
@ -52707,7 +52806,7 @@ async function connectToTailscale(config, runnerOS) {
throw error;
}
const sleepTime = attempt * 2; // Reduced from 5 to 2 seconds
core.info(`Retrying in ${sleepTime} seconds...`);
(0, logging_1.logInfo)(config.logMode, `Retrying in ${sleepTime} seconds...`);
await sleep(sleepTime * 1000);
}
}
@ -52745,55 +52844,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");
(0, logging_1.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}`);
(0, logging_1.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,30 +52889,10 @@ run();
* @throws execError if exec returned a non-zero status code
*/
async function execSilent(label, cmd, args, opts) {
core.info(`▶️ ${label}`);
const out = await exec.getExecOutput(cmd, args, {
return (0, logging_1.execCommand)(cmd, args, {
...opts,
silent: !core.isDebug(),
ignoreReturnCode: true,
label,
});
if (out.exitCode !== 0) {
if (!core.isDebug()) {
// When debug logging is off, stderr won't have been written to console, write it now.
process.stderr.write(out.stderr);
}
throw new execError(`${cmd} failed with exit code ${out.exitCode}`, out.exitCode, out.stderr);
}
return out;
}
class execError {
constructor(msg, exitCode, stderr) {
this.msg = msg;
this.exitCode = exitCode;
this.stderr = stderr;
}
toString() {
return this.msg;
}
}
@ -53067,6 +53122,14 @@ module.exports = require("perf_hooks");
/***/ }),
/***/ 932:
/***/ ((module) => {
"use strict";
module.exports = require("process");
/***/ }),
/***/ 83480:
/***/ ((module) => {

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

@ -25893,6 +25893,119 @@ module.exports = {
}
/***/ }),
/***/ 1338:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
// Copyright (c) Lee Briggs, Tailscale Inc, & Contributors
// SPDX-License-Identifier: BSD-3-Clause
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.ExecError = void 0;
exports.getLogMode = getLogMode;
exports.logInfo = logInfo;
exports.logDebug = logDebug;
exports.withLogGroup = withLogGroup;
exports.execCommand = execCommand;
const core = __importStar(__nccwpck_require__(7484));
const exec = __importStar(__nccwpck_require__(5236));
const process = __importStar(__nccwpck_require__(932));
class ExecError {
constructor(msg, exitCode, stderr) {
this.msg = msg;
this.exitCode = exitCode;
this.stderr = stderr;
}
toString() {
return this.msg;
}
}
exports.ExecError = ExecError;
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 execCommand(commandLine, args, opts) {
const { label, logMode = "normal", ...execOpts } = opts || {};
if (label) {
logInfo(logMode, `▶️ ${label}`);
}
const silent = execOpts.silent || logMode === "quiet" || !core.isDebug();
const out = await exec.getExecOutput(commandLine, args, {
...execOpts,
silent,
ignoreReturnCode: true,
});
if (out.exitCode !== 0) {
if (silent) {
process.stderr.write(out.stderr);
}
throw new ExecError(`${commandLine} failed with exit code ${out.exitCode}`, out.exitCode, out.stderr);
}
return out;
}
/***/ }),
/***/ 7254:
@ -25937,79 +26050,83 @@ var __importStar = (this && this.__importStar) || (function () {
})();
Object.defineProperty(exports, "__esModule", ({ value: true }));
const core = __importStar(__nccwpck_require__(7484));
const exec = __importStar(__nccwpck_require__(5236));
const fs = __importStar(__nccwpck_require__(9896));
const os = __importStar(__nccwpck_require__(857));
const path = __importStar(__nccwpck_require__(6928));
const logging_1 = __nccwpck_require__(1338);
const runnerWindows = "Windows";
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 = (0, logging_1.getLogMode)();
await (0, logging_1.withLogGroup)(logMode, "Cleaning up Tailscale", async () => {
if (runnerOS === runnerMacOS) {
// The below is required to allow GitHub's post job cleanup to complete.
(0, logging_1.logInfo)(logMode, "Resetting DNS settings on macOS");
await (0, logging_1.execCommand)("networksetup", ["-setdnsservers", "Ethernet", "Empty"], { logMode });
await (0, logging_1.execCommand)("networksetup", ["-setsearchdomains", "Ethernet", "Empty"], { logMode });
}
else {
// Linux and macOS - use system-installed binary with sudo
execArgs = ["sudo", "-E", "tailscale", "logout"];
}
core.info(`Running: ${execArgs.join(" ")}`);
(0, logging_1.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 (0, logging_1.execCommand)("tailscale", ["--version"], {
logMode,
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"];
}
(0, logging_1.logInfo)(logMode, `Running: ${execArgs.join(" ")}`);
try {
await (0, logging_1.execCommand)(execArgs[0], execArgs.slice(1), { logMode });
(0, logging_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}`);
(0, logging_1.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");
(0, logging_1.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");
(0, logging_1.logInfo)(logMode, "Stopping tailscale");
try {
if (runnerOS === runnerWindows) {
await (0, logging_1.execCommand)("net", ["stop", "Tailscale"], { logMode });
await (0, logging_1.execCommand)("taskkill", ["/F", "/IM", "tailscale-ipn.exe"], {
logMode,
});
}
// 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 (0, logging_1.execCommand)("sudo", ["pkill", "-P", pid], { logMode });
// Clean up DNS and routes.
await (0, logging_1.execCommand)("sudo", ["tailscaled", "--cleanup"], { logMode });
}
(0, logging_1.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
@ -26185,6 +26302,14 @@ module.exports = require("perf_hooks");
/***/ }),
/***/ 932:
/***/ ((module) => {
"use strict";
module.exports = require("process");
/***/ }),
/***/ 3480:
/***/ ((module) => {

92
src/logging.ts Normal file
View file

@ -0,0 +1,92 @@
// Copyright (c) Lee Briggs, Tailscale Inc, & Contributors
// SPDX-License-Identifier: BSD-3-Clause
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import * as process from "process";
export type LogMode = "grouped" | "normal" | "quiet";
export class ExecError {
msg: string;
exitCode: number;
stderr: string;
public constructor(msg: string, exitCode: number, stderr: string) {
this.msg = msg;
this.exitCode = exitCode;
this.stderr = stderr;
}
public toString(): string {
return this.msg;
}
}
export 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;
}
export function logInfo(logMode: LogMode, message: string): void {
if (logMode !== "quiet") {
core.info(message);
}
}
export function logDebug(logMode: LogMode, message: string): void {
if (logMode !== "quiet") {
core.debug(message);
}
}
export 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();
}
}
export async function execCommand(
commandLine: string,
args?: string[],
opts?: exec.ExecOptions & { label?: string; logMode?: LogMode },
): Promise<exec.ExecOutput> {
const { label, logMode = "normal", ...execOpts } = opts || {};
if (label) {
logInfo(logMode, `▶️ ${label}`);
}
const silent = execOpts.silent || logMode === "quiet" || !core.isDebug();
const out = await exec.getExecOutput(commandLine, args, {
...execOpts,
silent,
ignoreReturnCode: true,
});
if (out.exitCode !== 0) {
if (silent) {
process.stderr.write(out.stderr);
}
throw new ExecError(
`${commandLine} failed with exit code ${out.exitCode}`,
out.exitCode,
out.stderr,
);
}
return out;
}

View file

@ -2,10 +2,10 @@
// SPDX-License-Identifier: BSD-3-Clause
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { execCommand, getLogMode, logInfo, withLogGroup } from "../logging";
const runnerWindows = "Windows";
const runnerMacOS = "macOS";
@ -13,75 +13,91 @@ const runnerMacOS = "macOS";
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"];
}
core.info(`Running: ${execArgs.join(" ")}`);
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",
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(
"networksetup",
["-setdnsservers", "Ethernet", "Empty"],
{ logMode },
);
await execCommand(
"networksetup",
["-setsearchdomains", "Ethernet", "Empty"],
{ logMode },
);
}
} 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, "🔄 Logging out of Tailscale...");
// Check if tailscale is available first
try {
await execCommand("tailscale", ["--version"], {
logMode,
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"];
}
// 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(execArgs[0], execArgs.slice(1), { logMode });
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("net", ["stop", "Tailscale"], { logMode });
await execCommand("taskkill", ["/F", "/IM", "tailscale-ipn.exe"], {
logMode,
});
} 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("sudo", ["pkill", "-P", pid], { logMode });
// Clean up DNS and routes.
await execCommand("sudo", ["tailscaled", "--cleanup"], { logMode });
}
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}`);

View file

@ -12,6 +12,15 @@ import * as os from "os";
import * as path from "path";
import * as semver from "semver";
import { setTimeout as wait } from "timers/promises";
import {
ExecError,
execCommand,
getLogMode,
logDebug,
logInfo,
withLogGroup,
} from "./logging";
import type { LogMode } from "./logging";
const cmdTailscale = "tailscale";
const cmdTailscaleFullPath = "/usr/local/bin/tailscale";
@ -52,6 +61,7 @@ interface TailscaleConfig {
useCache: boolean;
sha256Sum: string;
pingHosts: string[];
logMode: LogMode;
}
type tailnetInfo = {
@ -65,11 +75,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 +111,76 @@ 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 +192,22 @@ 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,37 +215,34 @@ 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 (
err instanceof execError &&
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`);
}
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 +275,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 +312,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 +321,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 +391,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 +401,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 +420,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 +461,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 +481,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 +506,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 +547,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 +560,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 +578,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 +613,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 +634,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 +651,7 @@ async function installTailscaleMacOS(
[],
{
cwd: cmdTailscale,
logMode: config.logMode,
},
);
@ -607,31 +670,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 +725,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 +750,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 +777,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 +798,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 +854,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) {
@ -788,15 +865,28 @@ async function connectToTailscale(
}
const timeoutMs = parseTimeout(config.timeout);
await Promise.race([
execSilent("tailscale up", execArgs[0], execArgs.slice(1)),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), timeoutMs),
),
]);
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
execSilent("tailscale up", execArgs[0], execArgs.slice(1), {
logMode: config.logMode,
}),
new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(
() => reject(new Error("Timeout")),
timeoutMs,
);
}),
]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
// Success
core.info(
logInfo(
config.logMode,
`✅ Tailscale up command completed successfully on attempt ${attempt}`,
);
return;
@ -807,7 +897,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 +952,7 @@ function getToolPath(config: TailscaleConfig, runnerOS: string): string {
}
async function installCachedBinaries(
config: TailscaleConfig,
toolPath: string,
runnerOS: string,
): Promise<void> {
@ -871,52 +962,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,40 +1042,10 @@ async function execSilent(
label: string,
cmd: string,
args?: string[],
opts?: {},
opts?: exec.ExecOptions & { logMode?: LogMode },
): Promise<exec.ExecOutput> {
core.info(`▶️ ${label}`);
const out = await exec.getExecOutput(cmd, args, {
return execCommand(cmd, args, {
...opts,
silent: !core.isDebug(),
ignoreReturnCode: true,
label,
});
if (out.exitCode !== 0) {
if (!core.isDebug()) {
// When debug logging is off, stderr won't have been written to console, write it now.
process.stderr.write(out.stderr);
}
throw new execError(
`${cmd} failed with exit code ${out.exitCode}`,
out.exitCode,
out.stderr,
);
}
return out;
}
class execError {
msg: string;
exitCode: number;
stderr: string;
public constructor(msg: string, exitCode: number, stderr: string) {
this.msg = msg;
this.exitCode = exitCode;
this.stderr = stderr;
}
public toString(): string {
return this.msg;
}
}

View file

@ -43,7 +43,7 @@
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
"types": ["node"], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */