├── _config.yml ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── .jira_sync_config.yaml ├── workflows │ ├── manual-detached-test.yml │ ├── checkin.yml │ ├── manual-test.yml │ └── update-manual-test.js └── pull_request_template.md ├── CODEOWNERS ├── src ├── main.js ├── helpers.js ├── index.test.js └── index.js ├── .gitignore ├── docs └── checks-tab.png ├── jest.config.js ├── babel.config.js ├── RELEASE.md ├── LICENSE ├── package.json ├── action.yml ├── detached └── action.yml └── README.md /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @mxschmitt @dscho 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mxschmitt 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @canonical/platform-engineering 2 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { run } from "./index" 2 | 3 | run() -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | __tests__/runner/* 3 | coverage/ 4 | -------------------------------------------------------------------------------- /docs/checks-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canonical/action-tmate/main/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 | } -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | }, 10 | ], 11 | ], 12 | } -------------------------------------------------------------------------------- /.github/.jira_sync_config.yaml: -------------------------------------------------------------------------------- 1 | # See https://github.com/canonical/gh-jira-sync-bot for config 2 | settings: 3 | jira_project_key: "ISD" 4 | 5 | status_mapping: 6 | opened: Untriaged 7 | closed: done 8 | not_planned: rejected 9 | 10 | add_gh_comment: true 11 | 12 | epic_key: ISD-3981 13 | 14 | label_mapping: 15 | bug: Bug 16 | enhancement: Story 17 | -------------------------------------------------------------------------------- /.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/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Applicable spec: 2 | 3 | ### Overview 4 | 5 | 6 | 7 | ### Rationale 8 | 9 | 10 | 11 | ### Module Changes 12 | 13 | 14 | 15 | ### Checklist 16 | 17 | - [ ] The [contributing guide](https://github.com/canonical/is-charms-contributing-guide) was applied 18 | - [ ] The documentation on README.md is updated. 19 | - [ ] The PR is tagged with appropriate label (`urgent`, `trivial`, `complex`) 20 | 21 | 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/canonical/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | import process from "process" 7 | 8 | /** 9 | * @returns {boolean} 10 | */ 11 | export const useSudoPrefix = () => { 12 | const input = core.getInput("sudo"); 13 | return input === "auto" ? os.userInfo().uid !== 0 : input === "true"; 14 | } 15 | 16 | /** 17 | * @param {string} cmd 18 | * @param {{quiet: boolean} | undefined} [options] 19 | * @returns {Promise} 20 | */ 21 | export const execShellCommand = (cmd, options) => { 22 | core.debug(`Executing shell command: [${cmd}]`) 23 | return new Promise((resolve, reject) => { 24 | const proc = process.platform !== "win32" ? 25 | spawn(cmd, [], { 26 | shell: true, 27 | env: { 28 | ...process.env, 29 | HOMEBREW_GITHUB_API_TOKEN: core.getInput('github-token') || undefined 30 | } 31 | }) : 32 | spawn(`${core.getInput("msys2-location") || "C:\\msys64"}\\usr\\bin\\bash.exe`, ["-lc", cmd], { 33 | env: { 34 | ...process.env, 35 | "MSYS2_PATH_TYPE": "inherit", /* Inherit previous path */ 36 | "CHERE_INVOKING": "1", /* do not `cd` to home */ 37 | "MSYSTEM": "MINGW64", /* include the MINGW programs in C:/msys64/mingw64/bin/ */ 38 | } 39 | }) 40 | let stdout = "" 41 | proc.stdout.on('data', (data) => { 42 | if (!options || !options.quiet) process.stdout.write(data); 43 | stdout += data.toString(); 44 | }); 45 | 46 | proc.stderr.on('data', (data) => { 47 | process.stderr.write(data) 48 | }); 49 | 50 | proc.on('exit', (code) => { 51 | if (code !== 0) { 52 | reject(new Error(code ? code.toString() : undefined)) 53 | } 54 | resolve(stdout.trim()) 55 | }); 56 | }); 57 | } 58 | 59 | 60 | /** 61 | * @param {string} key 62 | * @param {RegExp} re regex to use for validation 63 | * @return {string|undefined} {undefined} or throws an error if input doesn't match regex 64 | */ 65 | export const getValidatedEnvVars = (key, re) => { 66 | const envVarKey = key.toUpperCase().replace(/-/gi, "_") 67 | const value = process.env[envVarKey] || "" 68 | if (value !== undefined && !re.test(value)) { 69 | throw new Error(`Invalid value for '${key}(${envVarKey})': '${value}'`); 70 | } 71 | return value; 72 | } 73 | 74 | 75 | /** 76 | * @return {Promise} 77 | */ 78 | export const getLinuxDistro = async () => { 79 | try { 80 | const osRelease = await fs.promises.readFile("/etc/os-release") 81 | const match = osRelease.toString().match(/^ID=(.*)$/m) 82 | return match ? match[1] : "(unknown)" 83 | } catch (e) { 84 | return "(unknown)" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | }) -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Debug your [GitHub Actions](https://github.com/features/actions) by using [tmate](https://tmate.io) 2 | 3 | This is a forked version of [action-tmate](https://github.com/mxschmitt/action-tmate), intended to 4 | be used with [GitHub Runner Operator](https://github.com/canonical/github-runner-operator/) to 5 | provide automatic SSH debug access within the Canonical VPN. 6 | 7 | You must have your SSH Key [registered on GitHub](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account) to be able to connect. 8 | 9 | [![GitHub Actions](https://github.com/canonical/action-tmate/workflows/Node.js%20CI/badge.svg)](https://github.com/canonical/action-tmate/actions) 10 | [![GitHub Marketplace](https://img.shields.io/badge/GitHub-Marketplace-green)](https://github.com/marketplace/actions/debugging-with-tmate) 11 | 12 | This GitHub Action offers you a direct way to interact with the host system on which the actual scripts (Actions) will run. 13 | 14 | ## Features 15 | 16 | - Debug your GitHub Actions by using SSH or Web shell 17 | - Continue your Workflows afterwards 18 | 19 | ## Supported Operating Systems 20 | 21 | - Linux 22 | - macOS 23 | - Windows 24 | 25 | ## Getting Started 26 | 27 | By using this minimal example a [tmate](https://tmate.io) session will be created. 28 | 29 | ```yaml 30 | name: CI 31 | on: [push] 32 | jobs: 33 | build: 34 | runs-on: self-hosted 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Setup tmate session 38 | uses: canonical/action-tmate@main 39 | ``` 40 | 41 | 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. 42 | 43 | ![GitHub Checks tab](./docs/checks-tab.png "GitHub Checks tab") 44 | 45 | ## Manually triggered debug 46 | 47 | 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". 48 | 49 | Add the following to the `on` events of your workflow: 50 | 51 | ```yaml 52 | on: 53 | workflow_dispatch: 54 | inputs: 55 | debug_enabled: 56 | type: boolean 57 | description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' 58 | required: false 59 | default: false 60 | ``` 61 | 62 | Then add an [`if`](https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions) condition to the debug step: 63 | 64 | 67 | ```yaml 68 | jobs: 69 | build: 70 | runs-on: self-hosted 71 | steps: 72 | # Enable tmate debugging of manually-triggered workflows if the input option was provided 73 | - name: Setup tmate session 74 | uses: canonical/action-tmate@main 75 | if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} 76 | ``` 77 | 80 | 81 | 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. 82 | 83 | ## Detached mode 84 | 85 | 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. 86 | 87 | ```yaml 88 | name: CI 89 | on: [push] 90 | jobs: 91 | build: 92 | runs-on: self-hosted 93 | steps: 94 | - uses: actions/checkout@v4 95 | - name: Setup tmate session 96 | uses: canonical/action-tmate@main 97 | with: 98 | detached: true 99 | ``` 100 | 101 | 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. 102 | 103 | 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. 104 | 105 | ### Using SSH command output in other jobs 106 | 107 | When running in detached mode, the action sets the following outputs that can be used in subsequent steps or jobs: 108 | 109 | - `ssh-command`: The SSH command to connect to the tmate session 110 | - `ssh-address`: The raw SSH address without the "ssh" prefix 111 | - `web-url`: The web URL to connect to the tmate session (if available) 112 | 113 | Example workflow using the SSH command in another job: 114 | 115 | ```yaml 116 | name: Debug with tmate 117 | on: [push] 118 | jobs: 119 | setup-tmate: 120 | runs-on: ubuntu-latest 121 | outputs: 122 | ssh-command: ${{ steps.tmate.outputs.ssh-command }} 123 | ssh-address: ${{ steps.tmate.outputs.ssh-address }} 124 | steps: 125 | - uses: actions/checkout@v4 126 | - name: Setup tmate session 127 | id: tmate 128 | uses: mxschmitt/action-tmate@v3 129 | with: 130 | detached: true 131 | 132 | use-ssh-command: 133 | needs: setup-tmate 134 | runs-on: ubuntu-latest 135 | steps: 136 | - name: Display SSH command 137 | run: | 138 | # Send a Slack message to someone telling them they can ssh to ${{ needs.setup-tmate.outputs.ssh-address }} 139 | ``` 140 | 141 | ## Without sudo 142 | 143 | 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. 144 | 145 | ```yaml 146 | name: CI 147 | on: [push] 148 | jobs: 149 | build: 150 | runs-on: self-hosted 151 | steps: 152 | - uses: actions/checkout@v4 153 | - name: Setup tmate session 154 | uses: canonical/action-tmate@main 155 | with: 156 | sudo: false 157 | ``` 158 | 159 | ## Timeout 160 | 161 | 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. 162 | 163 | ```yaml 164 | name: CI 165 | on: [push] 166 | jobs: 167 | build: 168 | runs-on: self-hosted 169 | steps: 170 | - uses: actions/checkout@v4 171 | - name: Setup tmate session 172 | uses: canonical/action-tmate@main 173 | timeout-minutes: 15 174 | ``` 175 | 176 | ## Only on failure 177 | 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). 178 | 179 | 182 | ```yaml 183 | name: CI 184 | on: [push] 185 | jobs: 186 | build: 187 | runs-on: self-hosted 188 | steps: 189 | - uses: actions/checkout@v4 190 | - name: Setup tmate session 191 | if: ${{ failure() }} 192 | uses: canonical/action-tmate@main 193 | ``` 194 | 197 | 198 | ## Use registered public SSH key(s) 199 | 200 | 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: 201 | 202 | ```yaml 203 | name: CI 204 | on: [push] 205 | jobs: 206 | build: 207 | runs-on: ubuntu-latest 208 | steps: 209 | - uses: actions/checkout@v4 210 | - name: Setup tmate session 211 | uses: canonical/action-tmate@v3 212 | with: 213 | limit-access-to-actor: true 214 | ``` 215 | 216 | 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 `. 217 | 218 | ## Use your own tmate servers 219 | 220 | By default, this action uses environment variables to pick up tmate ssh configuration settings and 221 | hence the following configurations have been removed. 222 | 223 | ```diff 224 | name: CI 225 | on: [push] 226 | jobs: 227 | build: 228 | runs-on: ubuntu-latest 229 | steps: 230 | - uses: actions/checkout@v4 231 | - name: Setup tmate session 232 | uses: canonical/action-tmate@main 233 | with: 234 | - tmate-server-host: ssh.tmate.io 235 | - tmate-server-port: 22 236 | - tmate-server-rsa-fingerprint: SHA256:Hthk2T/M/Ivqfk1YYUn5ijC2Att3+UPzD7Rn72P5VWs 237 | - tmate-server-ed25519-fingerprint: SHA256:jfttvoypkHiQYUqUCwKeqd9d1fJj/ZiQlFOHVl6E9sI 238 | ``` 239 | 240 | ## Use a different MSYS2 location 241 | 242 | 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: 243 | 244 | ```yaml 245 | name: CI 246 | on: [push] 247 | jobs: 248 | build: 249 | runs-on: windows-latest 250 | steps: 251 | - uses: msys2/setup-msys2@v2 252 | id: setup-msys2 253 | - uses: mxschmitt/action-tmate@v3 254 | with: 255 | msys2-location: ${{ steps.setup-msys2.outputs.msys2-location }} 256 | ``` 257 | 258 | ## Continue a workflow 259 | 260 | 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). 261 | 262 | ## Connection string / URL is not visible 263 | 264 | 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). 265 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import * as core from "@actions/core" 3 | import * as github from "@actions/github" 4 | import { Octokit } from "@octokit/rest" 5 | import fs from "fs" 6 | import os from "os" 7 | import path from "path" 8 | import process from "process" 9 | 10 | import { execShellCommand, getValidatedEnvVars, useSudoPrefix } from "./helpers" 11 | 12 | // Map os.arch() values to the architectures in tmate release binary filenames. 13 | // Possible os.arch() values documented here: 14 | // https://nodejs.org/api/os.html#os_os_arch 15 | // Available tmate binaries listed here: 16 | // https://packages.ubuntu.com/jammy/tmate 17 | // For different Ubuntu releases, change the release (i.e. jammy) to the 18 | // appropriate release. 19 | const TMATE_ARCH_MAP = { 20 | arm64: 'arm64v8', 21 | armhf: 'armhf', 22 | x64: 'amd64', 23 | ppc64: 'ppc64', 24 | riscv64: 'riscv64', 25 | s390x: 's390x' 26 | }; 27 | 28 | /** @param {number} ms */ 29 | const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); 30 | 31 | export async function run() { 32 | try { 33 | /* Indicates whether the POST action is running */ 34 | if (!!core.getState('isPost')) { 35 | const message = core.getState('message') 36 | const tmate = core.getState('tmate') 37 | if (tmate && message) { 38 | const shutdown = async () => { 39 | core.error('Got signal') 40 | await execShellCommand(`${tmate} kill-session`) 41 | process.exit(1) 42 | } 43 | // This is needed to fully support canceling the post-job Action, for details see 44 | // https://docs.github.com/en/actions/managing-workflow-runs/canceling-a-workflow#steps-github-takes-to-cancel-a-workflow-run 45 | process.on('SIGINT', shutdown) 46 | process.on('SIGTERM', shutdown) 47 | core.debug("Waiting") 48 | const hasAnyoneConnectedYet = (() => { 49 | let result = false 50 | return async () => { 51 | return result ||= 52 | !didTmateQuit() 53 | && '0' !== await execShellCommand(`${tmate} display -p '#{tmate_num_clients}'`, { quiet: true }) 54 | } 55 | })() 56 | 57 | let connectTimeoutSeconds = parseInt(core.getInput("connect-timeout-seconds")) 58 | if (Number.isNaN(connectTimeoutSeconds) || connectTimeoutSeconds <= 0) { 59 | connectTimeoutSeconds = 10 * 60 60 | } 61 | 62 | for (let seconds = connectTimeoutSeconds; seconds > 0;) { 63 | console.log(`${await hasAnyoneConnectedYet() 64 | ? 'Waiting for session to end' 65 | : `Waiting for client to connect (at most ${seconds} more second(s))` 66 | }\n${message}`) 67 | 68 | if (continueFileExists()) { 69 | core.info("Exiting debugging session because the continue file was created") 70 | break 71 | } 72 | 73 | if (didTmateQuit()) { 74 | core.info("Exiting debugging session 'tmate' quit") 75 | break 76 | } 77 | 78 | await sleep(5000) 79 | if (!await hasAnyoneConnectedYet()) seconds -= 5 80 | } 81 | } 82 | return 83 | } 84 | 85 | let tmateExecutable = "tmate" 86 | if (core.getInput("install-dependencies") !== "false") { 87 | core.debug("Installing dependencies") 88 | const optionalSudoPrefix = useSudoPrefix() ? "sudo " : ""; 89 | await execShellCommand(optionalSudoPrefix + 'DEBIAN_FRONTEND=noninteractive apt-get update'); 90 | await execShellCommand(optionalSudoPrefix + 'DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-client xz-utils'); 91 | await execShellCommand(optionalSudoPrefix + 'DEBIAN_FRONTEND=noninteractive apt-get install -y tmate'); 92 | 93 | const tmateArch = TMATE_ARCH_MAP[os.arch()]; 94 | if (!tmateArch) { 95 | throw new Error(`Unsupported architecture: ${os.arch()}`) 96 | } 97 | // We change from downloading tmate from source built tar from GitHub to the 98 | // Ubuntu packages tmate binary. Hence we've removed support for non Ubuntu/Linux 99 | // platforms/distributions. 100 | // This decision is to support different architectures. 101 | tmateExecutable = path.join("/usr/bin/", "tmate") 102 | 103 | // Optionally start the proxy service. 104 | try { 105 | await execShellCommand(optionalSudoPrefix + 'systemctl enable tmate-proxy --now'); 106 | } catch (error) { 107 | core.info(`tmate-proxy not enabled`); 108 | core.debug(`tmate-proxy error: ${error.message || error}`); 109 | if (error.stderr) core.debug(`stderr: ${error.stderr}`); 110 | } 111 | } 112 | core.debug("Installed dependencies successfully"); 113 | 114 | if (process.platform === "win32") { 115 | tmateExecutable = 'CHERE_INVOKING=1 tmate' 116 | } else { 117 | core.debug("Generating SSH keys") 118 | fs.mkdirSync(path.join(os.homedir(), ".ssh"), { recursive: true }) 119 | try { 120 | await execShellCommand(`echo -e 'y\n'|ssh-keygen -q -t rsa -N "" -f ~/.ssh/id_rsa`); 121 | } catch { } 122 | core.debug("Generated SSH-Key successfully") 123 | } 124 | 125 | let newSessionExtra = "" 126 | let tmateSSHDashI = "" 127 | let publicSSHKeysWarning = "" 128 | const limitAccessToActor = core.getInput("limit-access-to-actor") 129 | if (limitAccessToActor === "true" || limitAccessToActor === "auto") { 130 | const { actor, apiUrl } = github.context 131 | const auth = core.getInput('github-token') 132 | const octokit = new Octokit({ auth, baseUrl: apiUrl, request: { fetch } }); 133 | 134 | const keys = await octokit.users.listPublicKeysForUser({ 135 | username: actor 136 | }) 137 | if (keys.data.length === 0) { 138 | 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)` 139 | else throw new Error(`No public SSH keys registered with ${actor}'s GitHub profile`) 140 | } else { 141 | const sshPath = path.join(os.homedir(), ".ssh") 142 | await fs.promises.mkdir(sshPath, { recursive: true }) 143 | const authorizedKeysPath = path.join(sshPath, "authorized_keys") 144 | await fs.promises.appendFile(authorizedKeysPath, keys.data.map(e => e.key).join('\n')) 145 | newSessionExtra = `-a "${authorizedKeysPath}"` 146 | tmateSSHDashI = "ssh -i " 147 | } 148 | } 149 | 150 | const tmate = `${tmateExecutable} -S /tmp/tmate.sock`; 151 | 152 | // Work around potential `set -e` commands in `~/.profile` (looking at you, `setup-miniconda`!) 153 | await execShellCommand(`echo 'set +e' >/tmp/tmate.bashrc`); 154 | let setDefaultCommand = `set-option -g default-command "bash --rcfile /tmp/tmate.bashrc" \\;`; 155 | 156 | // The regexes used here for validation are lenient, i.e. may accept 157 | // values that are not, strictly speaking, valid, but should be good 158 | // enough for detecting obvious errors, which is all we want here. 159 | const options = { 160 | "tmate-server-host": /^[a-z\d\-]+(\.[a-z\d\-]+)*$/i, 161 | "tmate-server-port": /^\d{1,5}$/, 162 | "tmate-server-rsa-fingerprint": /./, 163 | "tmate-server-ed25519-fingerprint": /./, 164 | } 165 | 166 | let host = ""; 167 | let port = ""; 168 | for (const [key, option] of Object.entries(options)) { 169 | const value = getValidatedEnvVars(key, option); 170 | if (value !== undefined) { 171 | setDefaultCommand = `${setDefaultCommand} set-option -g ${key} "${value}" \\;`; 172 | if (key === "tmate-server-host") { 173 | host = value; 174 | } 175 | if (key === "tmate-server-port") { 176 | port = value; 177 | } 178 | } 179 | } 180 | 181 | core.debug("Creating new session") 182 | await execShellCommand(`${tmate} ${newSessionExtra} ${setDefaultCommand} new-session -d`); 183 | await execShellCommand(`${tmate} wait tmate-ready`); 184 | core.debug("Created new session successfully") 185 | 186 | core.debug("Fetching connection strings") 187 | const tmateSSH = await execShellCommand(`${tmate} display -p '#{tmate_ssh}'`); 188 | const [, , tokenHost] = tmateSSH.split(" "); 189 | const [token,] = tokenHost.split("@") 190 | const tmateWeb = await execShellCommand(`${tmate} display -p '#{tmate_web}'`); 191 | 192 | /* 193 | * Publish a variable so that when the POST action runs, it can determine 194 | * it should run the appropriate logic. This is necessary since we don't 195 | * have a separate entry point. 196 | * 197 | * Inspired by https://github.com/actions/checkout/blob/v3.1.0/src/state-helper.ts#L56-L60 198 | */ 199 | core.saveState('isPost', 'true') 200 | 201 | const detached = core.getInput("detached") 202 | if (detached === "true") { 203 | core.debug("Entering detached mode") 204 | 205 | let message = '' 206 | if (publicSSHKeysWarning) { 207 | message += `::warning::${publicSSHKeysWarning}\n` 208 | } 209 | if (tmateWeb) { 210 | message += `::notice::Web shell: ${tmateWeb}\n` 211 | } 212 | message += `::notice::SSH: ${tmateSSH}\n` 213 | if (tmateSSHDashI) { 214 | message += `::notice::or: ${tmateSSH.replace(/^ssh/, tmateSSHDashI)}\n` 215 | } 216 | core.saveState('message', message) 217 | core.saveState('tmate', tmate) 218 | 219 | // Set the SSH command as an output so other jobs can use it 220 | core.setOutput('ssh-command', tmateSSH) 221 | // Extract and set the raw SSH address (without the "ssh" prefix) 222 | core.setOutput('ssh-address', tmateSSH.replace(/^ssh /, '')) 223 | if (tmateWeb) { 224 | core.setOutput('web-url', tmateWeb) 225 | } 226 | 227 | console.log(message) 228 | return 229 | } 230 | 231 | core.debug("Entering main loop") 232 | while (true) { 233 | if (publicSSHKeysWarning) { 234 | core.warning(publicSSHKeysWarning) 235 | } 236 | if (tmateWeb) { 237 | core.info(`Web shell: ${tmateWeb}`); 238 | } 239 | core.info(`SSH: ${tmateSSH}`); 240 | if (tmateSSHDashI) { 241 | core.info(`or: ${tmateSSH.replace(/^ssh/, tmateSSHDashI)}`) 242 | } 243 | 244 | if (continueFileExists()) { 245 | core.info("Exiting debugging session because the continue file was created") 246 | break 247 | } 248 | 249 | if (didTmateQuit()) { 250 | core.info("Exiting debugging session 'tmate' quit") 251 | break 252 | } 253 | 254 | await sleep(5000) 255 | } 256 | 257 | } catch (error) { 258 | core.setFailed(error); 259 | } 260 | } 261 | 262 | function didTmateQuit() { 263 | const tmateSocketPath = process.platform === "win32" ? `${core.getInput("msys2-location") || "C:\\msys64"}/tmp/tmate.sock` : "/tmp/tmate.sock" 264 | return !fs.existsSync(tmateSocketPath) 265 | } 266 | 267 | function continueFileExists() { 268 | const continuePath = process.platform === "win32" ? `${core.getInput("msys2-location") || "C:\\msys64"}/continue` : "/continue" 269 | return fs.existsSync(continuePath) || fs.existsSync(path.join(process.env.GITHUB_WORKSPACE, "continue")) 270 | } 271 | --------------------------------------------------------------------------------