From dcd27aa028e970e5f8fd4c8d4796bc2c3b87a1de Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Wed, 17 May 2023 22:06:00 +0200 Subject: [PATCH 1/4] execShellCommand: optionally skip echoing the stdout Sometimes it is nicer when things are quiet. In the upcoming commit, I will introduce a caller that does want to consume the output of a shell command without having its output clutter the job's logs. Signed-off-by: Johannes Schindelin --- src/helpers.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/helpers.js b/src/helpers.js index bba2c618..6660c822 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -14,9 +14,10 @@ export const useSudoPrefix = () => { /** * @param {string} cmd + * @param {{quiet: boolean} | undefined} [options] * @returns {Promise} */ -export const execShellCommand = (cmd) => { +export const execShellCommand = (cmd, options) => { core.debug(`Executing shell command: [${cmd}]`) return new Promise((resolve, reject) => { const proc = process.platform !== "win32" ? @@ -37,7 +38,7 @@ export const execShellCommand = (cmd) => { }) let stdout = "" proc.stdout.on('data', (data) => { - process.stdout.write(data); + if (!options || !options.quiet) process.stdout.write(data); stdout += data.toString(); }); From 04c417b8770d0f1576abd9da468ca05804e16ed2 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Wed, 17 May 2023 17:50:58 +0200 Subject: [PATCH 2/4] Implement a "detached" mode In "detached" mode, the `tmate` session will be created, the Action will then print the information how to connect, and then exit with success. The workflow job's steps will run concurrently with the `tmate` session, allowing the user to monitor the steps as they are progressing. At the end of the job, the Action's newly-introduced post-job Action will wait until the session is ended, or up to 10 minutes for any client to connect at all. This detached mode is inspired by CircleCI/CirrusCI's debugging support that _only_ allows a concurrent shell terminal to be opened. Signed-off-by: Johannes Schindelin --- README.md | 20 ++++++++++++++ action.yml | 6 ++++ src/index.js | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/README.md b/README.md index 26c219ac..3704f75e 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,26 @@ jobs: You can then [manually run a workflow](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) on the desired branch and set `debug_enabled` to true to get a debug session. +## Detached mode + +By default, this Action starts a `tmate` session and waits for the session to be done (typically by way of a user connecting and exiting the shell after debugging). In detached mode, this Action will start the `tmate` session, print the connection details, and continue with the next step(s) of the workflow's job. At the end of the job, the Action will wait for the session to exit. + +```yaml +name: CI +on: [push] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup tmate session + uses: mxschmitt/action-tmate@v3 + with: + detached: true +``` + +By default, this mode will wait at the end of the job for a user to connect and then to terminate the tmate session. If no user has connected within 10 minutes after the post-job step started, it will terminate the `tmate` session and quit gracefully. + ## Without sudo By default we run installation commands using sudo on Linux. If you get `sudo: not found` you can use the parameter below to execute the commands directly. diff --git a/action.yml b/action.yml index 1e8e6822..cd4d200e 100644 --- a/action.yml +++ b/action.yml @@ -6,6 +6,8 @@ author: 'Max Schmitt' runs: using: 'node16' main: 'lib/index.js' + post: 'lib/index.js' + post-if: '!cancelled()' inputs: sudo: description: 'If apt should be executed with sudo or without' @@ -19,6 +21,10 @@ inputs: description: 'Whether to authorize only the public SSH keys of the user triggering the workflow (defaults to true if the GitHub profile of the user has a public SSH key)' required: false default: 'auto' + detached: + description: 'In detached mode, the workflow job will continue while the tmate session is active' + required: false + default: 'false' tmate-server-host: description: 'The hostname for your tmate server (e.g. ssh.example.org)' required: false diff --git a/src/index.js b/src/index.js index ee07bcf1..f150fc7f 100644 --- a/src/index.js +++ b/src/index.js @@ -26,6 +26,53 @@ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); export async function run() { try { + /* Indicates whether the POST action is running */ + if (!!core.getState('isPost')) { + const message = core.getState('message') + const tmate = core.getState('tmate') + if (tmate && message) { + const shutdown = async () => { + core.error('Got signal') + await execShellCommand(`${tmate} kill-session`) + process.exit(1) + } + // This is needed to fully support canceling the post-job Action, for details see + // https://docs.github.com/en/actions/managing-workflow-runs/canceling-a-workflow#steps-github-takes-to-cancel-a-workflow-run + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + core.debug("Waiting") + const hasAnyoneConnectedYet = (() => { + let result = false + return async () => { + return result ||= + !didTmateQuit() + && '0' !== await execShellCommand(`${tmate} display -p '#{tmate_num_clients}'`, { quiet: true }) + } + })() + for (let seconds = 10 * 60; seconds > 0; ) { + console.log(`${ + await hasAnyoneConnectedYet() + ? 'Waiting for session to end' + : `Waiting for client to connect (at most ${seconds} more second(s))` + }\n${message}`) + + if (continueFileExists()) { + core.info("Exiting debugging session because the continue file was created") + break + } + + if (didTmateQuit()) { + core.info("Exiting debugging session 'tmate' quit") + break + } + + await sleep(5000) + if (!await hasAnyoneConnectedYet()) seconds -= 5 + } + } + return + } + let tmateExecutable = "tmate" if (core.getInput("install-dependencies") !== "false") { core.debug("Installing dependencies") @@ -137,6 +184,36 @@ export async function run() { const tmateSSH = await execShellCommand(`${tmate} display -p '#{tmate_ssh}'`); const tmateWeb = await execShellCommand(`${tmate} display -p '#{tmate_web}'`); + /* + * Publish a variable so that when the POST action runs, it can determine + * it should run the appropriate logic. This is necessary since we don't + * have a separate entry point. + * + * Inspired by https://github.com/actions/checkout/blob/v3.1.0/src/state-helper.ts#L56-L60 + */ + core.saveState('isPost', 'true') + + const detached = core.getInput("detached") + if (detached === "true") { + core.debug("Entering detached mode") + + let message = '' + if (publicSSHKeysWarning) { + message += `::warning::${publicSSHKeysWarning}\n` + } + if (tmateWeb) { + message += `::notice::Web shell: ${tmateWeb}\n` + } + message += `::notice::SSH: ${tmateSSH}\n` + if (tmateSSHDashI) { + message += `::notice::or: ${tmateSSH.replace(/^ssh/, tmateSSHDashI)}\n` + } + core.saveState('message', message) + core.saveState('tmate', tmate) + console.log(message) + return + } + core.debug("Entering main loop") while (true) { if (publicSSHKeysWarning) { From 192e28923a93b013116c680146f3c9b732b23a94 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Wed, 17 May 2023 18:07:01 +0200 Subject: [PATCH 3/4] npm run build Signed-off-by: Johannes Schindelin --- lib/index.js | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/lib/index.js b/lib/index.js index 1b0716bb..a333ffc1 100644 --- a/lib/index.js +++ b/lib/index.js @@ -12481,9 +12481,10 @@ const useSudoPrefix = () => { /** * @param {string} cmd + * @param {{quiet: boolean} | undefined} [options] * @returns {Promise} */ -const execShellCommand = (cmd) => { +const execShellCommand = (cmd, options) => { core.debug(`Executing shell command: [${cmd}]`) return new Promise((resolve, reject) => { const proc = process.platform !== "win32" ? @@ -12504,7 +12505,7 @@ const execShellCommand = (cmd) => { }) let stdout = "" proc.stdout.on('data', (data) => { - process.stdout.write(data); + if (!options || !options.quiet) process.stdout.write(data); stdout += data.toString(); }); @@ -12577,6 +12578,53 @@ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); async function run() { try { + /* Indicates whether the POST action is running */ + if (!!core.getState('isPost')) { + const message = core.getState('message') + const tmate = core.getState('tmate') + if (tmate && message) { + const shutdown = async () => { + core.error('Got signal') + await execShellCommand(`${tmate} kill-session`) + process.exit(1) + } + // This is needed to fully support canceling the post-job Action, for details see + // https://docs.github.com/en/actions/managing-workflow-runs/canceling-a-workflow#steps-github-takes-to-cancel-a-workflow-run + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + core.debug("Waiting") + const hasAnyoneConnectedYet = (() => { + let result = false + return async () => { + return result ||= + !didTmateQuit() + && '0' !== await execShellCommand(`${tmate} display -p '#{tmate_num_clients}'`, { quiet: true }) + } + })() + for (let seconds = 10 * 60; seconds > 0; ) { + console.log(`${ + await hasAnyoneConnectedYet() + ? 'Waiting for session to end' + : `Waiting for client to connect (at most ${seconds} more second(s))` + }\n${message}`) + + if (continueFileExists()) { + core.info("Exiting debugging session because the continue file was created") + break + } + + if (didTmateQuit()) { + core.info("Exiting debugging session 'tmate' quit") + break + } + + await sleep(5000) + if (!await hasAnyoneConnectedYet()) seconds -= 5 + } + } + return + } + let tmateExecutable = "tmate" if (core.getInput("install-dependencies") !== "false") { core.debug("Installing dependencies") @@ -12688,6 +12736,36 @@ async function run() { const tmateSSH = await execShellCommand(`${tmate} display -p '#{tmate_ssh}'`); const tmateWeb = await execShellCommand(`${tmate} display -p '#{tmate_web}'`); + /* + * Publish a variable so that when the POST action runs, it can determine + * it should run the appropriate logic. This is necessary since we don't + * have a separate entry point. + * + * Inspired by https://github.com/actions/checkout/blob/v3.1.0/src/state-helper.ts#L56-L60 + */ + core.saveState('isPost', 'true') + + const detached = core.getInput("detached") + if (detached === "true") { + core.debug("Entering detached mode") + + let message = '' + if (publicSSHKeysWarning) { + message += `::warning::${publicSSHKeysWarning}\n` + } + if (tmateWeb) { + message += `::notice::Web shell: ${tmateWeb}\n` + } + message += `::notice::SSH: ${tmateSSH}\n` + if (tmateSSHDashI) { + message += `::notice::or: ${tmateSSH.replace(/^ssh/, tmateSSHDashI)}\n` + } + core.saveState('message', message) + core.saveState('tmate', tmate) + console.log(message) + return + } + core.debug("Entering main loop") while (true) { if (publicSSHKeysWarning) { From a38d254a207b8bec302ba095123d317f0e603be4 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Wed, 17 May 2023 18:02:29 +0200 Subject: [PATCH 4/4] Add a GitHub workflow to manually test the detached mode To better assess how useful the detached mode is, and whether it handles all of the corner cases correctly (client connects only during the post job phase, job is canceled while waiting for the session to end, etc), let's have a GitHub workflow that can be triggered manually. Signed-off-by: Johannes Schindelin --- .github/workflows/manual-detached-test.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/manual-detached-test.yml diff --git a/.github/workflows/manual-detached-test.yml b/.github/workflows/manual-detached-test.yml new file mode 100644 index 00000000..a623483f --- /dev/null +++ b/.github/workflows/manual-detached-test.yml @@ -0,0 +1,20 @@ +name: Test detached mode +on: workflow_dispatch + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ./ + with: + limit-access-to-actor: true + detached: true + - run: | + echo "A busy loop" + for value in $(seq 10) + do + echo "Value: $value" + echo "value $value" >>counter.txt + sleep 1 + done