Skip to content

Commit 53a5c23

Browse files
authored
Merge pull request #162 from dscho/detached-mode
Add support for a "detached" mode
2 parents 73f5c99 + a38d254 commit 53a5c23

File tree

6 files changed

+206
-4
lines changed

6 files changed

+206
-4
lines changed
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: Test detached mode
2+
on: workflow_dispatch
3+
4+
jobs:
5+
test:
6+
runs-on: ubuntu-latest
7+
steps:
8+
- uses: actions/checkout@v3
9+
- uses: ./
10+
with:
11+
limit-access-to-actor: true
12+
detached: true
13+
- run: |
14+
echo "A busy loop"
15+
for value in $(seq 10)
16+
do
17+
echo "Value: $value"
18+
echo "value $value" >>counter.txt
19+
sleep 1
20+
done

README.md

+20
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,26 @@ jobs:
7474

7575
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.
7676

77+
## Detached mode
78+
79+
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.
80+
81+
```yaml
82+
name: CI
83+
on: [push]
84+
jobs:
85+
build:
86+
runs-on: ubuntu-latest
87+
steps:
88+
- uses: actions/checkout@v3
89+
- name: Setup tmate session
90+
uses: mxschmitt/action-tmate@v3
91+
with:
92+
detached: true
93+
```
94+
95+
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.
96+
7797
## Without sudo
7898

7999
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.

action.yml

+6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ author: 'Max Schmitt'
66
runs:
77
using: 'node16'
88
main: 'lib/index.js'
9+
post: 'lib/index.js'
10+
post-if: '!cancelled()'
911
inputs:
1012
sudo:
1113
description: 'If apt should be executed with sudo or without'
@@ -19,6 +21,10 @@ inputs:
1921
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)'
2022
required: false
2123
default: 'auto'
24+
detached:
25+
description: 'In detached mode, the workflow job will continue while the tmate session is active'
26+
required: false
27+
default: 'false'
2228
tmate-server-host:
2329
description: 'The hostname for your tmate server (e.g. ssh.example.org)'
2430
required: false

lib/index.js

+80-2
Original file line numberDiff line numberDiff line change
@@ -12481,9 +12481,10 @@ const useSudoPrefix = () => {
1248112481

1248212482
/**
1248312483
* @param {string} cmd
12484+
* @param {{quiet: boolean} | undefined} [options]
1248412485
* @returns {Promise<string>}
1248512486
*/
12486-
const execShellCommand = (cmd) => {
12487+
const execShellCommand = (cmd, options) => {
1248712488
core.debug(`Executing shell command: [${cmd}]`)
1248812489
return new Promise((resolve, reject) => {
1248912490
const proc = process.platform !== "win32" ?
@@ -12504,7 +12505,7 @@ const execShellCommand = (cmd) => {
1250412505
})
1250512506
let stdout = ""
1250612507
proc.stdout.on('data', (data) => {
12507-
process.stdout.write(data);
12508+
if (!options || !options.quiet) process.stdout.write(data);
1250812509
stdout += data.toString();
1250912510
});
1251012511

@@ -12577,6 +12578,53 @@ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
1257712578

1257812579
async function run() {
1257912580
try {
12581+
/* Indicates whether the POST action is running */
12582+
if (!!core.getState('isPost')) {
12583+
const message = core.getState('message')
12584+
const tmate = core.getState('tmate')
12585+
if (tmate && message) {
12586+
const shutdown = async () => {
12587+
core.error('Got signal')
12588+
await execShellCommand(`${tmate} kill-session`)
12589+
process.exit(1)
12590+
}
12591+
// This is needed to fully support canceling the post-job Action, for details see
12592+
// https://docs.github.com/en/actions/managing-workflow-runs/canceling-a-workflow#steps-github-takes-to-cancel-a-workflow-run
12593+
process.on('SIGINT', shutdown)
12594+
process.on('SIGTERM', shutdown)
12595+
core.debug("Waiting")
12596+
const hasAnyoneConnectedYet = (() => {
12597+
let result = false
12598+
return async () => {
12599+
return result ||=
12600+
!didTmateQuit()
12601+
&& '0' !== await execShellCommand(`${tmate} display -p '#{tmate_num_clients}'`, { quiet: true })
12602+
}
12603+
})()
12604+
for (let seconds = 10 * 60; seconds > 0; ) {
12605+
console.log(`${
12606+
await hasAnyoneConnectedYet()
12607+
? 'Waiting for session to end'
12608+
: `Waiting for client to connect (at most ${seconds} more second(s))`
12609+
}\n${message}`)
12610+
12611+
if (continueFileExists()) {
12612+
core.info("Exiting debugging session because the continue file was created")
12613+
break
12614+
}
12615+
12616+
if (didTmateQuit()) {
12617+
core.info("Exiting debugging session 'tmate' quit")
12618+
break
12619+
}
12620+
12621+
await sleep(5000)
12622+
if (!await hasAnyoneConnectedYet()) seconds -= 5
12623+
}
12624+
}
12625+
return
12626+
}
12627+
1258012628
let tmateExecutable = "tmate"
1258112629
if (core.getInput("install-dependencies") !== "false") {
1258212630
core.debug("Installing dependencies")
@@ -12688,6 +12736,36 @@ async function run() {
1268812736
const tmateSSH = await execShellCommand(`${tmate} display -p '#{tmate_ssh}'`);
1268912737
const tmateWeb = await execShellCommand(`${tmate} display -p '#{tmate_web}'`);
1269012738

12739+
/*
12740+
* Publish a variable so that when the POST action runs, it can determine
12741+
* it should run the appropriate logic. This is necessary since we don't
12742+
* have a separate entry point.
12743+
*
12744+
* Inspired by https://github.com/actions/checkout/blob/v3.1.0/src/state-helper.ts#L56-L60
12745+
*/
12746+
core.saveState('isPost', 'true')
12747+
12748+
const detached = core.getInput("detached")
12749+
if (detached === "true") {
12750+
core.debug("Entering detached mode")
12751+
12752+
let message = ''
12753+
if (publicSSHKeysWarning) {
12754+
message += `::warning::${publicSSHKeysWarning}\n`
12755+
}
12756+
if (tmateWeb) {
12757+
message += `::notice::Web shell: ${tmateWeb}\n`
12758+
}
12759+
message += `::notice::SSH: ${tmateSSH}\n`
12760+
if (tmateSSHDashI) {
12761+
message += `::notice::or: ${tmateSSH.replace(/^ssh/, tmateSSHDashI)}\n`
12762+
}
12763+
core.saveState('message', message)
12764+
core.saveState('tmate', tmate)
12765+
console.log(message)
12766+
return
12767+
}
12768+
1269112769
core.debug("Entering main loop")
1269212770
while (true) {
1269312771
if (publicSSHKeysWarning) {

src/helpers.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ export const useSudoPrefix = () => {
1414

1515
/**
1616
* @param {string} cmd
17+
* @param {{quiet: boolean} | undefined} [options]
1718
* @returns {Promise<string>}
1819
*/
19-
export const execShellCommand = (cmd) => {
20+
export const execShellCommand = (cmd, options) => {
2021
core.debug(`Executing shell command: [${cmd}]`)
2122
return new Promise((resolve, reject) => {
2223
const proc = process.platform !== "win32" ?
@@ -37,7 +38,7 @@ export const execShellCommand = (cmd) => {
3738
})
3839
let stdout = ""
3940
proc.stdout.on('data', (data) => {
40-
process.stdout.write(data);
41+
if (!options || !options.quiet) process.stdout.write(data);
4142
stdout += data.toString();
4243
});
4344

src/index.js

+77
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,53 @@ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
2626

2727
export async function run() {
2828
try {
29+
/* Indicates whether the POST action is running */
30+
if (!!core.getState('isPost')) {
31+
const message = core.getState('message')
32+
const tmate = core.getState('tmate')
33+
if (tmate && message) {
34+
const shutdown = async () => {
35+
core.error('Got signal')
36+
await execShellCommand(`${tmate} kill-session`)
37+
process.exit(1)
38+
}
39+
// This is needed to fully support canceling the post-job Action, for details see
40+
// https://docs.github.com/en/actions/managing-workflow-runs/canceling-a-workflow#steps-github-takes-to-cancel-a-workflow-run
41+
process.on('SIGINT', shutdown)
42+
process.on('SIGTERM', shutdown)
43+
core.debug("Waiting")
44+
const hasAnyoneConnectedYet = (() => {
45+
let result = false
46+
return async () => {
47+
return result ||=
48+
!didTmateQuit()
49+
&& '0' !== await execShellCommand(`${tmate} display -p '#{tmate_num_clients}'`, { quiet: true })
50+
}
51+
})()
52+
for (let seconds = 10 * 60; seconds > 0; ) {
53+
console.log(`${
54+
await hasAnyoneConnectedYet()
55+
? 'Waiting for session to end'
56+
: `Waiting for client to connect (at most ${seconds} more second(s))`
57+
}\n${message}`)
58+
59+
if (continueFileExists()) {
60+
core.info("Exiting debugging session because the continue file was created")
61+
break
62+
}
63+
64+
if (didTmateQuit()) {
65+
core.info("Exiting debugging session 'tmate' quit")
66+
break
67+
}
68+
69+
await sleep(5000)
70+
if (!await hasAnyoneConnectedYet()) seconds -= 5
71+
}
72+
}
73+
return
74+
}
75+
2976
let tmateExecutable = "tmate"
3077
if (core.getInput("install-dependencies") !== "false") {
3178
core.debug("Installing dependencies")
@@ -137,6 +184,36 @@ export async function run() {
137184
const tmateSSH = await execShellCommand(`${tmate} display -p '#{tmate_ssh}'`);
138185
const tmateWeb = await execShellCommand(`${tmate} display -p '#{tmate_web}'`);
139186

187+
/*
188+
* Publish a variable so that when the POST action runs, it can determine
189+
* it should run the appropriate logic. This is necessary since we don't
190+
* have a separate entry point.
191+
*
192+
* Inspired by https://github.com/actions/checkout/blob/v3.1.0/src/state-helper.ts#L56-L60
193+
*/
194+
core.saveState('isPost', 'true')
195+
196+
const detached = core.getInput("detached")
197+
if (detached === "true") {
198+
core.debug("Entering detached mode")
199+
200+
let message = ''
201+
if (publicSSHKeysWarning) {
202+
message += `::warning::${publicSSHKeysWarning}\n`
203+
}
204+
if (tmateWeb) {
205+
message += `::notice::Web shell: ${tmateWeb}\n`
206+
}
207+
message += `::notice::SSH: ${tmateSSH}\n`
208+
if (tmateSSHDashI) {
209+
message += `::notice::or: ${tmateSSH.replace(/^ssh/, tmateSSHDashI)}\n`
210+
}
211+
core.saveState('message', message)
212+
core.saveState('tmate', tmate)
213+
console.log(message)
214+
return
215+
}
216+
140217
core.debug("Entering main loop")
141218
while (true) {
142219
if (publicSSHKeysWarning) {

0 commit comments

Comments
 (0)