├── _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 | [](https://github.com/canonical/action-tmate/actions)
10 | [](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 | 
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 |
--------------------------------------------------------------------------------