├── .github ├── CODEOWNERS ├── FUNDING.yml └── workflows │ ├── checkin.yml │ ├── manual-detached-test.yml │ ├── manual-test.yml │ └── update-manual-test.js ├── .gitignore ├── LICENSE ├── README.md ├── RELEASE.md ├── _config.yml ├── action.yml ├── babel.config.js ├── detached └── action.yml ├── docs └── checks-tab.png ├── jest.config.js ├── lib └── index.js ├── package-lock.json ├── package.json └── src ├── helpers.js ├── index.js ├── index.test.js └── main.js /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @mxschmitt @dscho 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mxschmitt 2 | -------------------------------------------------------------------------------- /.github/workflows/checkin.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | branches: [master] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Use Node.js 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: 20 16 | - name: Verify that action.yml files are in sync 17 | run: | 18 | npm run update-detached-action.yml && 19 | if ! git diff --exit-code \*action.yml 20 | then 21 | echo '::error::action.yml files are not in sync, maybe run `npm run update-detached-action.yml`?' 22 | exit 1 23 | fi 24 | - name: Install dependencies 25 | run: npm ci 26 | - name: Run tests 27 | run: npm run test --coverage 28 | env: 29 | CI: "true" 30 | - name: Build project 31 | run: npm run build 32 | - name: Verify that the project is built 33 | run: | 34 | if [[ -n $(git status -s) ]]; then 35 | echo "ERROR: generated lib/ differs from the current sources" 36 | git status -s 37 | git diff 38 | exit 1 39 | fi 40 | -------------------------------------------------------------------------------- /.github/workflows/manual-detached-test.yml: -------------------------------------------------------------------------------- 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@v4 9 | - uses: ./detached 10 | with: 11 | connect-timeout-seconds: 60 12 | - run: | 13 | echo "A busy loop" 14 | for value in $(seq 10) 15 | do 16 | echo "Value: $value" 17 | echo "value $value" >>counter.txt 18 | sleep 1 19 | done 20 | -------------------------------------------------------------------------------- /.github/workflows/manual-test.yml: -------------------------------------------------------------------------------- 1 | name: Manual test 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | runs-on: 6 | type: choice 7 | description: 'The runner pool to run the job on' 8 | required: true 9 | default: ubuntu-24.04 10 | options: 11 | - ubuntu-24.04 12 | - ubuntu-22.04 13 | - macos-15-large 14 | - macos-15 15 | - macos-14-large 16 | - macos-14 17 | - macos-13 18 | - macos-13-xlarge 19 | - windows-2025 20 | - windows-2022 21 | - windows-2019 22 | - windows-11-arm 23 | container-runs-on: 24 | type: choice 25 | description: 'The Docker container to run the job on (this overrides the `runs-on` input)' 26 | required: false 27 | default: '(none)' 28 | options: 29 | - '(none)' 30 | - fedora:latest 31 | - archlinux:latest 32 | - ubuntu:latest 33 | limit-access-to-actor: 34 | type: choice 35 | description: 'Whether to limit access to the actor only' 36 | required: true 37 | default: 'auto' 38 | options: 39 | - auto 40 | - 'true' 41 | - 'false' 42 | 43 | jobs: 44 | test: 45 | if: ${{ inputs.container-runs-on == '(none)' }} 46 | runs-on: ${{ inputs.runs-on }} 47 | steps: 48 | - uses: msys2/setup-msys2@v2 49 | # The public preview of GitHub-hosted Windows/ARM64 runners lacks 50 | # a working MSYS2 installation, so we need to set it up ourselves. 51 | if: ${{ inputs.runs-on == 'windows-11-arm' }} 52 | with: 53 | msystem: 'CLANGARM64' 54 | # We cannot use `C:\` because `msys2/setup-msys2` erroneously 55 | # believes that an MSYS2 exists at `C:\msys64`, but it doesn't, 56 | # which is the entire reason why we need to set it up in this 57 | # here step... However, by using `C:\.\` we can fool that 58 | # overzealous check. 59 | location: C:\.\ 60 | - uses: actions/checkout@v4 61 | - uses: ./ 62 | with: 63 | limit-access-to-actor: ${{ inputs.limit-access-to-actor }} 64 | test-container: 65 | if: ${{ inputs.container-runs-on != '(none)' }} 66 | runs-on: ubuntu-latest 67 | container: 68 | image: ${{ inputs.container-runs-on }} 69 | steps: 70 | - uses: actions/checkout@v4 71 | - uses: ./ 72 | with: 73 | limit-access-to-actor: ${{ inputs.limit-access-to-actor }} 74 | -------------------------------------------------------------------------------- /.github/workflows/update-manual-test.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Update the `runs-on` options of the `manual-test.yml` workflow file with the 4 | // latest available images from the GitHub Actions runner images README file. 5 | 6 | (async () => { 7 | const fs = require('fs') 8 | 9 | const readme = await (await fetch("https://github.com/actions/runner-images/raw/HEAD/README.md")).text() 10 | 11 | // This will be the first `ubuntu` one. 12 | let defaultOption = '' 13 | 14 | const choices = readme 15 | // Get the "Available Images" section 16 | .split(/\n## Available Images\n/)[1] 17 | .split(/##\s*[^#]/)[0] 18 | // Split by lines 19 | .split('\n') 20 | .map(line => { 21 | // The relevant lines are table rows; The first column is the image name, 22 | // the second one contains a relatively free-form list of the `runs-on` 23 | // options that we are interested in. Those `runs-on` options are 24 | // surrounded by backticks. 25 | const match = line.match(/^\|\s*([^|]+)\s*\|([^|]*)`([^`|]+)`\s*\|/) 26 | if (!match) return false // Skip e.g. the table header and empty lines 27 | let runsOn = match[3] // default to the last `runs-on` option 28 | const alternatives = match[2] 29 | .split(/`([^`]*)`/) // split by backticks 30 | .filter((_, i) => (i % 2)) // keep only the text between backticks 31 | .sort((a, b) => a.length - b.length) // order by length 32 | if (alternatives.length > 0 && alternatives[0].length < runsOn.length) runsOn = alternatives[0] 33 | if (!defaultOption && match[3].startsWith('ubuntu-')) defaultOption = runsOn 34 | return runsOn 35 | }) 36 | .filter(runsOn => runsOn) 37 | 38 | // The Windows/ARM64 runners are in public preview (and for the time being, 39 | // not listed in the `runner-images` README file), so we need to add this 40 | // manually. 41 | if (!choices.includes('windows-11-arm')) choices.push('windows-11-arm') 42 | 43 | // Now edit the `manual-test` workflow definition 44 | const ymlPath = `${__dirname}/manual-test.yml` 45 | const yml = fs.readFileSync(ymlPath, 'utf8') 46 | 47 | // We want to replace the `runs-on` options and the `default` value. This 48 | // would be easy if there was a built-in YAML parser and renderer in Node.js, 49 | // but there is none. Therefore, we use a regular expression to find certain 50 | // "needles" near the beginning of the file: first `workflow_dispatch:`, 51 | // after that `runs-on:` and then `default:` and `options:`. Then we replace 52 | // the `default` value and the `options` values with the new ones. 53 | const [, beforeDefault, beforeOptions, optionsIndent, afterOptions] = 54 | yml.match(/^([^]*?workflow_dispatch:[^]*?runs-on:[^]*?default:)(?:.*)([^]*?options:)(\n +- )(?:.*)(?:\3.*)*([^]*)/) || [] 55 | if (!beforeDefault) throw new Error(`The 'manual-test.yml' file does not match the expected format!`) 56 | const newYML = 57 | `${beforeDefault} ${defaultOption}${[beforeOptions, ...choices].join(optionsIndent)}${afterOptions}` 58 | fs.writeFileSync(ymlPath, newYML) 59 | })().catch(e => { 60 | console.error(e) 61 | process.exitCode = 1 62 | }) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | __tests__/runner/* 3 | coverage/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Max Schmitt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Debug your [GitHub Actions](https://github.com/features/actions) by using [tmate](https://tmate.io) 2 | 3 | [![GitHub Actions](https://github.com/mxschmitt/action-tmate/workflows/Node.js%20CI/badge.svg)](https://github.com/mxschmitt/action-tmate/actions) 4 | [![GitHub Marketplace](https://img.shields.io/badge/GitHub-Marketplace-green)](https://github.com/marketplace/actions/debugging-with-tmate) 5 | 6 | This GitHub Action offers you a direct way to interact with the host system on which the actual scripts (Actions) will run. 7 | 8 | ## Features 9 | 10 | - Debug your GitHub Actions by using SSH or Web shell 11 | - Continue your Workflows afterwards 12 | 13 | ## Supported Operating Systems 14 | 15 | - Linux 16 | - macOS 17 | - Windows 18 | 19 | ## Getting Started 20 | 21 | By using this minimal example a [tmate](https://tmate.io) session will be created. 22 | 23 | ```yaml 24 | name: CI 25 | on: [push] 26 | jobs: 27 | build: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | - name: Setup tmate session 32 | uses: mxschmitt/action-tmate@v3 33 | ``` 34 | 35 | To get the connection string, just open the `Checks` tab in your Pull Request and scroll to the bottom. There you can connect either directly per SSH or via a web based terminal. 36 | 37 | ![GitHub Checks tab](./docs/checks-tab.png "GitHub Checks tab") 38 | 39 | ## Manually triggered debug 40 | 41 | Instead of having to add/remove, or uncomment the required config and push commits each time you want to run your workflow with debug, you can make the debug step conditional on an optional parameter that you provide through a [`workflow_dispatch`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) "manual event". 42 | 43 | Add the following to the `on` events of your workflow: 44 | 45 | ```yaml 46 | on: 47 | workflow_dispatch: 48 | inputs: 49 | debug_enabled: 50 | type: boolean 51 | description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' 52 | required: false 53 | default: false 54 | ``` 55 | 56 | Then add an [`if`](https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions) condition to the debug step: 57 | 58 | 61 | ```yaml 62 | jobs: 63 | build: 64 | runs-on: ubuntu-latest 65 | steps: 66 | # Enable tmate debugging of manually-triggered workflows if the input option was provided 67 | - name: Setup tmate session 68 | uses: mxschmitt/action-tmate@v3 69 | if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} 70 | ``` 71 | 74 | 75 | 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. 76 | 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@v4 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 | 97 | As this mode has turned out to be so useful as to having the potential for being the default mode once time travel becomes available, it is also available as `mxschmitt/action-tmate/detached` for convenience. 98 | 99 | ### Using SSH command output in other jobs 100 | 101 | When running in detached mode, the action sets the following outputs that can be used in subsequent steps or jobs: 102 | 103 | - `ssh-command`: The SSH command to connect to the tmate session 104 | - `ssh-address`: The raw SSH address without the "ssh" prefix 105 | - `web-url`: The web URL to connect to the tmate session (if available) 106 | 107 | Example workflow using the SSH command in another job: 108 | 109 | ```yaml 110 | name: Debug with tmate 111 | on: [push] 112 | jobs: 113 | setup-tmate: 114 | runs-on: ubuntu-latest 115 | outputs: 116 | ssh-command: ${{ steps.tmate.outputs.ssh-command }} 117 | ssh-address: ${{ steps.tmate.outputs.ssh-address }} 118 | steps: 119 | - uses: actions/checkout@v4 120 | - name: Setup tmate session 121 | id: tmate 122 | uses: mxschmitt/action-tmate@v3 123 | with: 124 | detached: true 125 | 126 | use-ssh-command: 127 | needs: setup-tmate 128 | runs-on: ubuntu-latest 129 | steps: 130 | - name: Display SSH command 131 | run: | 132 | # Send a Slack message to someone telling them they can ssh to ${{ needs.setup-tmate.outputs.ssh-address }} 133 | ``` 134 | 135 | ## Without sudo 136 | 137 | 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. 138 | 139 | ```yaml 140 | name: CI 141 | on: [push] 142 | jobs: 143 | build: 144 | runs-on: ubuntu-latest 145 | steps: 146 | - uses: actions/checkout@v4 147 | - name: Setup tmate session 148 | uses: mxschmitt/action-tmate@v3 149 | with: 150 | sudo: false 151 | ``` 152 | 153 | ## Timeout 154 | 155 | By default the tmate session will remain open until the workflow times out. You can [specify your own timeout](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepstimeout-minutes) in minutes if you wish to reduce GitHub Actions usage. 156 | 157 | ```yaml 158 | name: CI 159 | on: [push] 160 | jobs: 161 | build: 162 | runs-on: ubuntu-latest 163 | steps: 164 | - uses: actions/checkout@v4 165 | - name: Setup tmate session 166 | uses: mxschmitt/action-tmate@v3 167 | timeout-minutes: 15 168 | ``` 169 | 170 | ## Only on failure 171 | By default a failed step will cause all following steps to be skipped. You can specify that the tmate session only starts if a previous step [failed](https://docs.github.com/en/actions/learn-github-actions/expressions#failure). 172 | 173 | 176 | ```yaml 177 | name: CI 178 | on: [push] 179 | jobs: 180 | build: 181 | runs-on: ubuntu-latest 182 | steps: 183 | - uses: actions/checkout@v4 184 | - name: Setup tmate session 185 | if: ${{ failure() }} 186 | uses: mxschmitt/action-tmate@v3 187 | ``` 188 | 191 | 192 | ## Use registered public SSH key(s) 193 | 194 | If [you have registered one or more public SSH keys with your GitHub profile](https://docs.github.com/en/github/authenticating-to-github/adding-a-new-ssh-key-to-your-github-account), tmate will be started such that only those keys are authorized to connect, otherwise anybody can connect to the tmate session. If you want to require a public SSH key to be installed with the tmate session, no matter whether the user who started the workflow has registered any in their GitHub profile, you will need to configure the setting `limit-access-to-actor` to `true`, like so: 195 | 196 | ```yaml 197 | name: CI 198 | on: [push] 199 | jobs: 200 | build: 201 | runs-on: ubuntu-latest 202 | steps: 203 | - uses: actions/checkout@v4 204 | - name: Setup tmate session 205 | uses: mxschmitt/action-tmate@v3 206 | with: 207 | limit-access-to-actor: true 208 | ``` 209 | 210 | If the registered public SSH key is not your default private SSH key, you will need to specify the path manually, like so: `ssh -i `. 211 | 212 | ## Use your own tmate servers 213 | 214 | By default the tmate session uses `ssh.tmate.io`. You can use your own tmate servers. [tmate-ssh-server](https://github.com/tmate-io/tmate-ssh-server) is the server side part of tmate. 215 | 216 | ```yaml 217 | name: CI 218 | on: [push] 219 | jobs: 220 | build: 221 | runs-on: ubuntu-latest 222 | steps: 223 | - uses: actions/checkout@v4 224 | - name: Setup tmate session 225 | uses: mxschmitt/action-tmate@v3 226 | with: 227 | tmate-server-host: ssh.tmate.io 228 | tmate-server-port: 22 229 | tmate-server-rsa-fingerprint: SHA256:Hthk2T/M/Ivqfk1YYUn5ijC2Att3+UPzD7Rn72P5VWs 230 | tmate-server-ed25519-fingerprint: SHA256:jfttvoypkHiQYUqUCwKeqd9d1fJj/ZiQlFOHVl6E9sI 231 | ``` 232 | 233 | ## Skip installing tmate 234 | 235 | By default, tmate and its dependencies are installed in a platform-dependent manner. When using self-hosted agents, this can become unnecessary or can even break. You can skip installing tmate and its dependencies using `install-dependencies`: 236 | 237 | ```yaml 238 | name: CI 239 | on: [push] 240 | jobs: 241 | build: 242 | runs-on: [self-hosted, linux] 243 | steps: 244 | - uses: mxschmitt/action-tmate@v3 245 | with: 246 | install-dependencies: false 247 | ``` 248 | 249 | ## Use a different MSYS2 location 250 | 251 | If you want to integrate with the msys2/setup-msys2 action or otherwise don't have an MSYS2 installation at `C:\msys64`, you can specify a different location for MSYS2: 252 | 253 | ```yaml 254 | name: CI 255 | on: [push] 256 | jobs: 257 | build: 258 | runs-on: windows-latest 259 | steps: 260 | - uses: msys2/setup-msys2@v2 261 | id: setup-msys2 262 | - uses: mxschmitt/action-tmate@v3 263 | with: 264 | msys2-location: ${{ steps.setup-msys2.outputs.msys2-location }} 265 | ``` 266 | 267 | ## Continue a workflow 268 | 269 | If you want to continue a workflow and you are inside a tmate session, just create a empty file with the name `continue` either in the root directory or in the project directory by running `touch continue` or `sudo touch /continue` (on Linux). 270 | 271 | ## Connection string / URL is not visible 272 | 273 | The connection string will be written in the logs every 5 seconds. For more information checkout issue [#1](https://github.com/mxschmitt/action-tmate/issues/1). 274 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Packaging 4 | 5 | The GitHub Action relies on `lib/index.js` as the entrypoint. This entrypoint needs to be committed after every change. Use the following command to package the code into `lib/index.js`. 6 | 7 | ```txt 8 | npm run build 9 | ``` 10 | 11 | ## Releases 12 | 13 | 1. Create a semver tag pointing to the commit you want to release. E.g. to create `v1.4.4` from tip-of-tree: 14 | 15 | ```txt 16 | git checkout master 17 | git pull origin master 18 | git tag v1.4.4 19 | git push origin v1.4.4 20 | ``` 21 | 22 | 1. Draft a new release on GitHub using the new semver tag. 23 | 1. Update the sliding tag (`v1`) to point to the new release commit. Note that existing users relying on the `v1` will get auto-updated. 24 | 25 | ### Updating sliding tag 26 | 27 | Follow these steps to move the `v1` to a new version `v1.4.4`. 28 | 29 | ```txt 30 | git tag -f v1 v1.4.4 31 | git push -f origin v1 32 | ``` 33 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Debugging with tmate' 2 | description: 'Debug your GitHub Actions Environment interactively by using SSH or a Web shell' 3 | branding: 4 | icon: terminal 5 | author: 'Max Schmitt' 6 | runs: 7 | using: 'node20' 8 | main: 'lib/index.js' 9 | post: 'lib/index.js' 10 | post-if: '!cancelled()' 11 | inputs: 12 | sudo: 13 | description: 'If apt should be executed with sudo or without' 14 | required: false 15 | default: 'auto' 16 | install-dependencies: 17 | description: 'Whether or not to install dependencies for tmate on linux (openssh-client, xz-utils)' 18 | required: false 19 | default: 'true' 20 | limit-access-to-actor: 21 | 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)' 22 | required: false 23 | 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' 28 | connect-timeout-seconds: 29 | description: 'How long in seconds to wait for a connection to be established' 30 | required: false 31 | default: '600' 32 | tmate-server-host: 33 | description: 'The hostname for your tmate server (e.g. ssh.example.org)' 34 | required: false 35 | default: '' 36 | tmate-server-port: 37 | description: 'The port for your tmate server (e.g. 2222)' 38 | required: false 39 | default: '' 40 | tmate-server-rsa-fingerprint: 41 | description: 'The RSA fingerprint for your tmate server' 42 | required: false 43 | default: '' 44 | tmate-server-ed25519-fingerprint: 45 | description: 'The ed25519 fingerprint for your tmate server' 46 | required: false 47 | default: '' 48 | msys2-location: 49 | description: 'The root of the MSYS2 installation (on Windows runners)' 50 | required: false 51 | default: 'C:\msys64' 52 | github-token: 53 | description: > 54 | Personal access token (PAT) used to call into GitHub's REST API. 55 | We recommend using a service account with the least permissions necessary. 56 | Also when generating a new PAT, select the least scopes necessary. 57 | [Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) 58 | default: ${{ github.token }} 59 | 60 | outputs: 61 | ssh-command: 62 | description: 'The SSH command to connect to the tmate session (only set when detached mode is enabled)' 63 | ssh-address: 64 | description: 'The raw SSH address without the "ssh" prefix (only set when detached mode is enabled)' 65 | web-url: 66 | description: 'The web URL to connect to the tmate session (only set when detached mode is enabled and web URL is available)' 67 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | }, 10 | ], 11 | ], 12 | } -------------------------------------------------------------------------------- /detached/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Debugging with tmate' 2 | description: 'Debug your GitHub Actions Environment interactively by using SSH or a Web shell' 3 | branding: 4 | icon: terminal 5 | author: 'Max Schmitt' 6 | runs: 7 | using: 'node20' 8 | main: '../lib/index.js' 9 | post: '../lib/index.js' 10 | post-if: '!cancelled()' 11 | inputs: 12 | sudo: 13 | description: 'If apt should be executed with sudo or without' 14 | required: false 15 | default: 'auto' 16 | install-dependencies: 17 | description: 'Whether or not to install dependencies for tmate on linux (openssh-client, xz-utils)' 18 | required: false 19 | default: 'true' 20 | limit-access-to-actor: 21 | 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)' 22 | required: false 23 | 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: 'true' 28 | connect-timeout-seconds: 29 | description: 'How long in seconds to wait for a connection to be established' 30 | required: false 31 | default: '600' 32 | tmate-server-host: 33 | description: 'The hostname for your tmate server (e.g. ssh.example.org)' 34 | required: false 35 | default: '' 36 | tmate-server-port: 37 | description: 'The port for your tmate server (e.g. 2222)' 38 | required: false 39 | default: '' 40 | tmate-server-rsa-fingerprint: 41 | description: 'The RSA fingerprint for your tmate server' 42 | required: false 43 | default: '' 44 | tmate-server-ed25519-fingerprint: 45 | description: 'The ed25519 fingerprint for your tmate server' 46 | required: false 47 | default: '' 48 | msys2-location: 49 | description: 'The root of the MSYS2 installation (on Windows runners)' 50 | required: false 51 | default: 'C:\msys64' 52 | github-token: 53 | description: > 54 | Personal access token (PAT) used to call into GitHub's REST API. 55 | We recommend using a service account with the least permissions necessary. 56 | Also when generating a new PAT, select the least scopes necessary. 57 | [Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) 58 | default: ${{ github.token }} 59 | 60 | outputs: 61 | ssh-command: 62 | description: 'The SSH command to connect to the tmate session (only set when detached mode is enabled)' 63 | ssh-address: 64 | description: 'The raw SSH address without the "ssh" prefix (only set when detached mode is enabled)' 65 | web-url: 66 | description: 'The web URL to connect to the tmate session (only set when detached mode is enabled and web URL is available)' 67 | -------------------------------------------------------------------------------- /docs/checks-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mxschmitt/action-tmate/c02a7a6fa90fbf0fec054092dc439b42b775cfb3/docs/checks-tab.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | testEnvironment: 'node', 4 | verbose: true, 5 | collectCoverage: true, 6 | transform: { 7 | "^.+\\.(js)$": "babel-jest", 8 | }, 9 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "action-tmate", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Debug your GitHub Actions using tmate and get an interactive SSH session into your runner.", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "start": "node src/index.js", 9 | "build": "ncc build src/main.js -o lib", 10 | "update-detached-action.yml": "sed '/^runs:$/{N;N;N;s/lib\\//..\\/&/g;};/^ detached:/{N;N;N;s/\\(default: .\\)false/\\1true/g;}' action.yml >detached/action.yml", 11 | "test": "GITHUB_EVENT_PATH= jest" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/mxschmitt/action-tmate.git" 16 | }, 17 | "keywords": [ 18 | "actions", 19 | "node", 20 | "setup" 21 | ], 22 | "author": "Max Schmitt ", 23 | "license": "MIT", 24 | "dependencies": { 25 | "@actions/core": "^1.10.0", 26 | "@actions/github": "^5.1.1", 27 | "@actions/tool-cache": "^2.0.1", 28 | "@octokit/rest": "^20.0.1" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.22.11", 32 | "@babel/preset-env": "^7.22.14", 33 | "@vercel/ncc": "^0.36.1", 34 | "babel-jest": "^29.6.4", 35 | "jest": "^29.6.4", 36 | "jest-circus": "^29.6.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { spawn } from 'child_process' 3 | import * as core from "@actions/core" 4 | import fs from 'fs' 5 | import os from 'os' 6 | 7 | /** 8 | * @returns {boolean} 9 | */ 10 | export const useSudoPrefix = () => { 11 | const input = core.getInput("sudo"); 12 | return input === "auto" ? os.userInfo().uid !== 0 : input === "true"; 13 | } 14 | 15 | /** 16 | * @param {string} cmd 17 | * @param {{quiet: boolean} | undefined} [options] 18 | * @returns {Promise} 19 | */ 20 | export const execShellCommand = (cmd, options) => { 21 | core.debug(`Executing shell command: [${cmd}]`) 22 | return new Promise((resolve, reject) => { 23 | const proc = process.platform !== "win32" ? 24 | spawn(cmd, [], { 25 | shell: true, 26 | env: { 27 | ...process.env, 28 | HOMEBREW_GITHUB_API_TOKEN: core.getInput('github-token') || undefined 29 | } 30 | }) : 31 | spawn(`${core.getInput("msys2-location") || "C:\\msys64"}\\usr\\bin\\bash.exe`, ["-lc", cmd], { 32 | env: { 33 | ...process.env, 34 | "MSYS2_PATH_TYPE": "inherit", /* Inherit previous path */ 35 | "CHERE_INVOKING": "1", /* do not `cd` to home */ 36 | "MSYSTEM": "MINGW64", /* include the MINGW programs in C:/msys64/mingw64/bin/ */ 37 | } 38 | }) 39 | let stdout = "" 40 | proc.stdout.on('data', (data) => { 41 | if (!options || !options.quiet) process.stdout.write(data); 42 | stdout += data.toString(); 43 | }); 44 | 45 | proc.stderr.on('data', (data) => { 46 | process.stderr.write(data) 47 | }); 48 | 49 | proc.on('exit', (code) => { 50 | if (code !== 0) { 51 | reject(new Error(code ? code.toString() : undefined)) 52 | } 53 | resolve(stdout.trim()) 54 | }); 55 | }); 56 | } 57 | 58 | /** 59 | * @param {string} key 60 | * @param {RegExp} re regex to use for validation 61 | * @return {string} {undefined} or throws an error if input doesn't match regex 62 | */ 63 | export const getValidatedInput = (key, re) => { 64 | const value = core.getInput(key); 65 | if (value !== undefined && !re.test(value)) { 66 | throw new Error(`Invalid value for '${key}': '${value}'`); 67 | } 68 | return value; 69 | } 70 | 71 | 72 | /** 73 | * @return {Promise} 74 | */ 75 | export const getLinuxDistro = async () => { 76 | try { 77 | const osRelease = await fs.promises.readFile("/etc/os-release") 78 | const match = osRelease.toString().match(/^ID=(.*)$/m) 79 | return match ? match[1] : "(unknown)" 80 | } catch (e) { 81 | return "(unknown)" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import os from "os" 3 | import fs from "fs" 4 | import path from "path" 5 | import * as core from "@actions/core" 6 | import * as github from "@actions/github" 7 | import * as tc from "@actions/tool-cache" 8 | import { Octokit } from "@octokit/rest" 9 | 10 | import { execShellCommand, getValidatedInput, getLinuxDistro, useSudoPrefix } from "./helpers" 11 | 12 | const TMATE_LINUX_VERSION = "2.4.0" 13 | 14 | // Map os.arch() values to the architectures in tmate release binary filenames. 15 | // Possible os.arch() values documented here: 16 | // https://nodejs.org/api/os.html#os_os_arch 17 | // Available tmate binaries listed here: 18 | // https://github.com/tmate-io/tmate/releases/ 19 | const TMATE_ARCH_MAP = { 20 | arm64: 'arm64v8', 21 | x64: 'amd64', 22 | }; 23 | 24 | /** @param {number} ms */ 25 | const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); 26 | 27 | export async function run() { 28 | 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 | 53 | let connectTimeoutSeconds = parseInt(core.getInput("connect-timeout-seconds")) 54 | if (Number.isNaN(connectTimeoutSeconds) || connectTimeoutSeconds <= 0) { 55 | connectTimeoutSeconds = 10 * 60 56 | } 57 | 58 | for (let seconds = connectTimeoutSeconds; seconds > 0; ) { 59 | console.log(`${ 60 | await hasAnyoneConnectedYet() 61 | ? 'Waiting for session to end' 62 | : `Waiting for client to connect (at most ${seconds} more second(s))` 63 | }\n${message}`) 64 | 65 | if (continueFileExists()) { 66 | core.info("Exiting debugging session because the continue file was created") 67 | break 68 | } 69 | 70 | if (didTmateQuit()) { 71 | core.info("Exiting debugging session 'tmate' quit") 72 | break 73 | } 74 | 75 | await sleep(5000) 76 | if (!await hasAnyoneConnectedYet()) seconds -= 5 77 | } 78 | } 79 | return 80 | } 81 | 82 | let tmateExecutable = "tmate" 83 | if (core.getInput("install-dependencies") !== "false") { 84 | core.debug("Installing dependencies") 85 | if (process.platform === "darwin") { 86 | await execShellCommand('brew install tmate'); 87 | } else if (process.platform === "win32") { 88 | await execShellCommand('pacman -S --noconfirm tmate'); 89 | } else { 90 | const optionalSudoPrefix = useSudoPrefix() ? "sudo " : ""; 91 | const distro = await getLinuxDistro(); 92 | core.debug("linux distro: [" + distro + "]"); 93 | if (distro === "alpine") { 94 | // for set -e workaround, we need to install bash because alpine doesn't have it 95 | await execShellCommand(optionalSudoPrefix + 'apk add openssh-client xz bash'); 96 | } else if (distro === "arch") { 97 | // partial upgrades are not supported so also upgrade everything 98 | await execShellCommand(optionalSudoPrefix + 'pacman -Syu --noconfirm xz openssh'); 99 | } else if (distro === "fedora" || distro === "centos" || distro === "rhel" || distro === "almalinux") { 100 | await execShellCommand(optionalSudoPrefix + 'dnf install -y xz openssh'); 101 | } else { 102 | await execShellCommand(optionalSudoPrefix + 'apt-get update'); 103 | await execShellCommand(optionalSudoPrefix + 'apt-get install -y openssh-client xz-utils'); 104 | } 105 | 106 | const tmateArch = TMATE_ARCH_MAP[os.arch()]; 107 | if (!tmateArch) { 108 | throw new Error(`Unsupported architecture: ${os.arch()}`) 109 | } 110 | const tmateReleaseTar = await tc.downloadTool(`https://github.com/tmate-io/tmate/releases/download/${TMATE_LINUX_VERSION}/tmate-${TMATE_LINUX_VERSION}-static-linux-${tmateArch}.tar.xz`); 111 | const tmateDir = path.join(os.tmpdir(), "tmate") 112 | tmateExecutable = path.join(tmateDir, "tmate") 113 | 114 | if (fs.existsSync(tmateExecutable)) 115 | fs.unlinkSync(tmateExecutable) 116 | fs.mkdirSync(tmateDir, { recursive: true }) 117 | await execShellCommand(`tar x -C ${tmateDir} -f ${tmateReleaseTar} --strip-components=1`) 118 | fs.unlinkSync(tmateReleaseTar) 119 | } 120 | core.debug("Installed dependencies successfully"); 121 | } 122 | 123 | if (process.platform === "win32") { 124 | tmateExecutable = 'CHERE_INVOKING=1 tmate' 125 | } else { 126 | core.debug("Generating SSH keys") 127 | fs.mkdirSync(path.join(os.homedir(), ".ssh"), { recursive: true }) 128 | try { 129 | await execShellCommand(`echo -e 'y\n'|ssh-keygen -q -t rsa -N "" -f ~/.ssh/id_rsa`); 130 | } catch { } 131 | core.debug("Generated SSH-Key successfully") 132 | } 133 | 134 | let newSessionExtra = "" 135 | let tmateSSHDashI = "" 136 | let publicSSHKeysWarning = "" 137 | const limitAccessToActor = core.getInput("limit-access-to-actor") 138 | if (limitAccessToActor === "true" || limitAccessToActor === "auto") { 139 | const { actor, apiUrl } = github.context 140 | const auth = core.getInput('github-token') 141 | const octokit = new Octokit({ auth, baseUrl: apiUrl, request: { fetch }}); 142 | 143 | const keys = await octokit.users.listPublicKeysForUser({ 144 | username: actor 145 | }) 146 | if (keys.data.length === 0) { 147 | if (limitAccessToActor === "auto") publicSSHKeysWarning = `No public SSH keys found for ${actor}; continuing without them even if it is less secure (please consider adding an SSH key, see https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account)` 148 | else throw new Error(`No public SSH keys registered with ${actor}'s GitHub profile`) 149 | } else { 150 | const sshPath = path.join(os.homedir(), ".ssh") 151 | await fs.promises.mkdir(sshPath, { recursive: true }) 152 | const authorizedKeysPath = path.join(sshPath, "authorized_keys") 153 | await fs.promises.writeFile(authorizedKeysPath, keys.data.map(e => e.key).join('\n')) 154 | newSessionExtra = `-a "${authorizedKeysPath}"` 155 | tmateSSHDashI = "ssh -i " 156 | } 157 | } 158 | 159 | const tmate = `${tmateExecutable} -S /tmp/tmate.sock`; 160 | 161 | // Work around potential `set -e` commands in `~/.profile` (looking at you, `setup-miniconda`!) 162 | await execShellCommand(`echo 'set +e' >/tmp/tmate.bashrc`); 163 | let setDefaultCommand = `set-option -g default-command "bash --rcfile /tmp/tmate.bashrc" \\;`; 164 | 165 | // The regexes used here for validation are lenient, i.e. may accept 166 | // values that are not, strictly speaking, valid, but should be good 167 | // enough for detecting obvious errors, which is all we want here. 168 | const options = { 169 | "tmate-server-host": /^[a-z\d\-]+(\.[a-z\d\-]+)*$/i, 170 | "tmate-server-port": /^\d{1,5}$/, 171 | "tmate-server-rsa-fingerprint": /./, 172 | "tmate-server-ed25519-fingerprint": /./, 173 | } 174 | 175 | for (const [key, option] of Object.entries(options)) { 176 | if (core.getInput(key) === '') 177 | continue; 178 | const value = getValidatedInput(key, option); 179 | if (value !== undefined) { 180 | setDefaultCommand = `${setDefaultCommand} set-option -g ${key} "${value}" \\;`; 181 | } 182 | } 183 | 184 | core.debug("Creating new session") 185 | await execShellCommand(`${tmate} ${newSessionExtra} ${setDefaultCommand} new-session -d`); 186 | await execShellCommand(`${tmate} wait tmate-ready`); 187 | core.debug("Created new session successfully") 188 | 189 | core.debug("Fetching connection strings") 190 | const tmateSSH = await execShellCommand(`${tmate} display -p '#{tmate_ssh}'`); 191 | const tmateWeb = await execShellCommand(`${tmate} display -p '#{tmate_web}'`); 192 | 193 | /* 194 | * Publish a variable so that when the POST action runs, it can determine 195 | * it should run the appropriate logic. This is necessary since we don't 196 | * have a separate entry point. 197 | * 198 | * Inspired by https://github.com/actions/checkout/blob/v3.1.0/src/state-helper.ts#L56-L60 199 | */ 200 | core.saveState('isPost', 'true') 201 | 202 | const detached = core.getInput("detached") 203 | if (detached === "true") { 204 | core.debug("Entering detached mode") 205 | 206 | let message = '' 207 | if (publicSSHKeysWarning) { 208 | message += `::warning::${publicSSHKeysWarning}\n` 209 | } 210 | if (tmateWeb) { 211 | message += `::notice::Web shell: ${tmateWeb}\n` 212 | } 213 | message += `::notice::SSH: ${tmateSSH}\n` 214 | if (tmateSSHDashI) { 215 | message += `::notice::or: ${tmateSSH.replace(/^ssh/, tmateSSHDashI)}\n` 216 | } 217 | core.saveState('message', message) 218 | core.saveState('tmate', tmate) 219 | 220 | // Set the SSH command as an output so other jobs can use it 221 | core.setOutput('ssh-command', tmateSSH) 222 | // Extract and set the raw SSH address (without the "ssh" prefix) 223 | core.setOutput('ssh-address', tmateSSH.replace(/^ssh /, '')) 224 | if (tmateWeb) { 225 | core.setOutput('web-url', tmateWeb) 226 | } 227 | 228 | console.log(message) 229 | return 230 | } 231 | 232 | core.debug("Entering main loop") 233 | while (true) { 234 | if (publicSSHKeysWarning) { 235 | core.warning(publicSSHKeysWarning) 236 | } 237 | if (tmateWeb) { 238 | core.info(`Web shell: ${tmateWeb}`); 239 | } 240 | core.info(`SSH: ${tmateSSH}`); 241 | if (tmateSSHDashI) { 242 | core.info(`or: ${tmateSSH.replace(/^ssh/, tmateSSHDashI)}`) 243 | } 244 | 245 | if (continueFileExists()) { 246 | core.info("Exiting debugging session because the continue file was created") 247 | break 248 | } 249 | 250 | if (didTmateQuit()) { 251 | core.info("Exiting debugging session 'tmate' quit") 252 | break 253 | } 254 | 255 | await sleep(5000) 256 | } 257 | 258 | } catch (error) { 259 | core.setFailed(error); 260 | } 261 | } 262 | 263 | function didTmateQuit() { 264 | const tmateSocketPath = process.platform === "win32" ? `${core.getInput("msys2-location") || "C:\\msys64"}/tmp/tmate.sock` : "/tmp/tmate.sock" 265 | return !fs.existsSync(tmateSocketPath) 266 | } 267 | 268 | function continueFileExists() { 269 | const continuePath = process.platform === "win32" ? `${core.getInput("msys2-location") || "C:\\msys64"}/continue` : "/continue" 270 | return fs.existsSync(continuePath) || fs.existsSync(path.join(process.env.GITHUB_WORKSPACE, "continue")) 271 | } 272 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('@actions/core'); 2 | import * as core from "@actions/core" 3 | jest.mock('@actions/github'); 4 | jest.mock("@actions/tool-cache", () => ({ 5 | downloadTool: async () => "", 6 | extractTar: async () => "" 7 | })); 8 | jest.mock("fs", () => ({ 9 | mkdirSync: () => true, 10 | existsSync: () => true, 11 | unlinkSync: () => true, 12 | writeFileSync: () => true, 13 | promises: new Proxy({}, { 14 | get: () => { 15 | return () => true 16 | } 17 | }) 18 | })); 19 | jest.mock('./helpers', () => { 20 | const originalModule = jest.requireActual('./helpers'); 21 | return { 22 | __esModule: true, 23 | ...originalModule, 24 | execShellCommand: jest.fn(() => 'mocked execShellCommand'), 25 | }; 26 | }); 27 | import { execShellCommand } from "./helpers" 28 | import { run } from "." 29 | 30 | describe('Tmate GitHub integration', () => { 31 | const originalPlatform = process.platform; 32 | 33 | afterAll(() => { 34 | Object.defineProperty(process, "platform", { 35 | value: originalPlatform 36 | }) 37 | }); 38 | 39 | it('should handle the main loop for Windows', async () => { 40 | Object.defineProperty(process, "platform", { 41 | value: "win32" 42 | }) 43 | core.getInput.mockReturnValueOnce("true").mockReturnValueOnce("false") 44 | const customConnectionString = "foobar" 45 | execShellCommand.mockReturnValue(Promise.resolve(customConnectionString)) 46 | await run() 47 | expect(execShellCommand).toHaveBeenNthCalledWith(1, "pacman -S --noconfirm tmate"); 48 | expect(core.info).toHaveBeenNthCalledWith(1, `Web shell: ${customConnectionString}`); 49 | expect(core.info).toHaveBeenNthCalledWith(2, `SSH: ${customConnectionString}`); 50 | expect(core.info).toHaveBeenNthCalledWith(3, "Exiting debugging session because the continue file was created"); 51 | }); 52 | it('should handle the main loop for Windows without dependency installation', async () => { 53 | Object.defineProperty(process, "platform", { 54 | value: "win32" 55 | }) 56 | core.getInput.mockReturnValueOnce("false") 57 | const customConnectionString = "foobar" 58 | execShellCommand.mockReturnValue(Promise.resolve(customConnectionString)) 59 | await run() 60 | expect(execShellCommand).not.toHaveBeenNthCalledWith(1, "pacman -S --noconfirm tmate"); 61 | expect(core.info).toHaveBeenNthCalledWith(1, `Web shell: ${customConnectionString}`); 62 | expect(core.info).toHaveBeenNthCalledWith(2, `SSH: ${customConnectionString}`); 63 | expect(core.info).toHaveBeenNthCalledWith(3, "Exiting debugging session because the continue file was created"); 64 | }); 65 | it('should handle the main loop for linux', async () => { 66 | Object.defineProperty(process, "platform", { 67 | value: "linux" 68 | }) 69 | core.getInput.mockReturnValueOnce("true").mockReturnValueOnce("true").mockReturnValueOnce("false") 70 | const customConnectionString = "foobar" 71 | execShellCommand.mockReturnValue(Promise.resolve(customConnectionString)) 72 | await run() 73 | expect(execShellCommand).toHaveBeenNthCalledWith(1, "sudo apt-get update") 74 | expect(core.info).toHaveBeenNthCalledWith(1, `Web shell: ${customConnectionString}`); 75 | expect(core.info).toHaveBeenNthCalledWith(2, `SSH: ${customConnectionString}`); 76 | expect(core.info).toHaveBeenNthCalledWith(3, "Exiting debugging session because the continue file was created"); 77 | }); 78 | it('should handle the main loop for linux without sudo', async () => { 79 | Object.defineProperty(process, "platform", { 80 | value: "linux" 81 | }) 82 | core.getInput.mockReturnValueOnce("true").mockReturnValueOnce("false").mockReturnValueOnce("false") 83 | const customConnectionString = "foobar" 84 | execShellCommand.mockReturnValue(Promise.resolve(customConnectionString)) 85 | await run() 86 | expect(execShellCommand).toHaveBeenNthCalledWith(1, "apt-get update") 87 | expect(core.info).toHaveBeenNthCalledWith(1, `Web shell: ${customConnectionString}`); 88 | expect(core.info).toHaveBeenNthCalledWith(2, `SSH: ${customConnectionString}`); 89 | expect(core.info).toHaveBeenNthCalledWith(3, "Exiting debugging session because the continue file was created"); 90 | }); 91 | it('should handle the main loop for linux without installing dependencies', async () => { 92 | Object.defineProperty(process, "platform", { 93 | value: "linux" 94 | }) 95 | core.getInput.mockReturnValueOnce("false").mockReturnValueOnce("false") 96 | const customConnectionString = "foobar" 97 | execShellCommand.mockReturnValue(Promise.resolve(customConnectionString)) 98 | await run() 99 | expect(execShellCommand).not.toHaveBeenNthCalledWith(1, "apt-get update") 100 | expect(core.info).toHaveBeenNthCalledWith(1, `Web shell: ${customConnectionString}`); 101 | expect(core.info).toHaveBeenNthCalledWith(2, `SSH: ${customConnectionString}`); 102 | expect(core.info).toHaveBeenNthCalledWith(3, "Exiting debugging session because the continue file was created"); 103 | }); 104 | it('should install tmate via brew for darwin', async () => { 105 | Object.defineProperty(process, "platform", { 106 | value: "darwin" 107 | }) 108 | core.getInput.mockReturnValueOnce("true") 109 | await run() 110 | expect(core.getInput).toHaveBeenNthCalledWith(1, "install-dependencies") 111 | expect(execShellCommand).toHaveBeenNthCalledWith(1, "brew install tmate") 112 | }); 113 | it('should not install dependencies for darwin', async () => { 114 | Object.defineProperty(process, "platform", { 115 | value: "darwin" 116 | }) 117 | core.getInput.mockReturnValueOnce("false") 118 | await run() 119 | expect(execShellCommand).not.toHaveBeenNthCalledWith(1, "brew install tmate") 120 | }); 121 | it('should work without any options', async () => { 122 | core.getInput.mockReturnValue(""); 123 | 124 | await run() 125 | 126 | expect(core.setFailed).not.toHaveBeenCalled(); 127 | }); 128 | it('should validate correct tmate options', async () => { 129 | // Check for the happy path first. 130 | core.getInput.mockImplementation(function(opt) { 131 | switch (opt) { 132 | case "tmate-server-host": return "ssh.tmate.io"; 133 | case "tmate-server-port": return "22"; 134 | case "tmate-server-rsa-fingerprint": return "SHA256:Hthk2T/M/Ivqfk1YYUn5ijC2Att3+UPzD7Rn72P5VWs"; 135 | case "tmate-server-ed25519-fingerprint": return "SHA256:jfttvoypkHiQYUqUCwKeqd9d1fJj/ZiQlFOHVl6E9sI"; 136 | default: return ""; 137 | } 138 | }) 139 | 140 | await run() 141 | 142 | // Find the command launching tmate with its various options. 143 | let tmateCmd; 144 | for (const call of execShellCommand.mock.calls) { 145 | const cmd = call[0] 146 | if (cmd.includes("set-option -g")) { 147 | tmateCmd = cmd 148 | break 149 | } 150 | } 151 | 152 | expect(tmateCmd).toBeDefined(); 153 | 154 | const re = /set-option -g tmate-server-host "([^"]+)"/; 155 | const match = re.exec(tmateCmd); 156 | expect(match).toBeTruthy(); 157 | expect(match[1]).toEqual("ssh.tmate.io"); 158 | }); 159 | it('should fail to validate wrong tmate options', async () => { 160 | core.getInput.mockImplementation(function(opt) { 161 | switch (opt) { 162 | case "tmate-server-host": return "not/a/valid/hostname"; 163 | default: return ""; 164 | } 165 | }) 166 | 167 | await run() 168 | 169 | expect(core.setFailed).toHaveBeenCalledWith( 170 | Error("Invalid value for 'tmate-server-host': 'not/a/valid/hostname'") 171 | ) 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { run } from "./index" 2 | 3 | run() --------------------------------------------------------------------------------