Skip to content

Commit

Permalink
Merge pull request #162 from dscho/detached-mode
Browse files Browse the repository at this point in the history
Add support for a "detached" mode
  • Loading branch information
dscho committed May 18, 2023
2 parents 73f5c99 + a38d254 commit 53a5c23
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 4 deletions.
20 changes: 20 additions & 0 deletions .github/workflows/manual-detached-test.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
82 changes: 80 additions & 2 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12481,9 +12481,10 @@ const useSudoPrefix = () => {

/**
* @param {string} cmd
* @param {{quiet: boolean} | undefined} [options]
* @returns {Promise<string>}
*/
const execShellCommand = (cmd) => {
const execShellCommand = (cmd, options) => {
core.debug(`Executing shell command: [${cmd}]`)
return new Promise((resolve, reject) => {
const proc = process.platform !== "win32" ?
Expand All @@ -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();
});

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ export const useSudoPrefix = () => {

/**
* @param {string} cmd
* @param {{quiet: boolean} | undefined} [options]
* @returns {Promise<string>}
*/
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" ?
Expand All @@ -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();
});

Expand Down
77 changes: 77 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit 53a5c23

Please sign in to comment.