diff --git a/.github/.jira_sync_config.yaml b/.github/.jira_sync_config.yaml new file mode 100644 index 00000000..fb3ce0af --- /dev/null +++ b/.github/.jira_sync_config.yaml @@ -0,0 +1,13 @@ +settings: + add_gh_comment: true + components: + - GitHub runner + epic_key: ISD-3981 + jira_project_key: ISD + label_mapping: + bug: Bug + enhancement: Story + status_mapping: + closed: done + not_planned: rejected + opened: Untriaged diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..a300d58f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +Applicable spec: + +### Overview + + + +### Rationale + + + +### Module Changes + + + +### Checklist + +- [ ] The [contributing guide](https://github.com/canonical/is-charms-contributing-guide) was applied +- [ ] The documentation on README.md is updated. +- [ ] The PR is tagged with appropriate label (`urgent`, `trivial`, `complex`) + + diff --git a/.github/workflows/checkin.yml b/.github/workflows/checkin.yml index fe74feb7..88f01383 100644 --- a/.github/workflows/checkin.yml +++ b/.github/workflows/checkin.yml @@ -8,11 +8,19 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v3 with: node-version: 20 + - name: Verify that action.yml files are in sync + run: | + npm run update-detached-action.yml && + if ! git diff --exit-code \*action.yml + then + echo '::error::action.yml files are not in sync, maybe run `npm run update-detached-action.yml`?' + exit 1 + fi - name: Install dependencies run: npm ci - name: Run tests @@ -24,7 +32,8 @@ jobs: - name: Verify that the project is built run: | if [[ -n $(git status -s) ]]; then - echo "ERROR: generated dist/ differs from the current sources" + echo "ERROR: generated lib/ differs from the current sources" + git status -s git diff exit 1 fi diff --git a/.github/workflows/manual-detached-test.yml b/.github/workflows/manual-detached-test.yml index a623483f..f3efbf90 100644 --- a/.github/workflows/manual-detached-test.yml +++ b/.github/workflows/manual-detached-test.yml @@ -5,11 +5,10 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: ./ + - uses: actions/checkout@v4 + - uses: ./detached with: - limit-access-to-actor: true - detached: true + connect-timeout-seconds: 60 - run: | echo "A busy loop" for value in $(seq 10) diff --git a/.github/workflows/manual-test.yml b/.github/workflows/manual-test.yml index bf1b27bd..a68f773f 100644 --- a/.github/workflows/manual-test.yml +++ b/.github/workflows/manual-test.yml @@ -1,43 +1,73 @@ -name: Manual test matrix -on: workflow_dispatch +name: Manual test +on: + workflow_dispatch: + inputs: + runs-on: + type: choice + description: 'The runner pool to run the job on' + required: true + default: ubuntu-24.04 + options: + - ubuntu-24.04 + - ubuntu-22.04 + - macos-15-large + - macos-15 + - macos-14-large + - macos-14 + - macos-13 + - macos-13-xlarge + - windows-2025 + - windows-2022 + - windows-2019 + - windows-11-arm + container-runs-on: + type: choice + description: 'The Docker container to run the job on (this overrides the `runs-on` input)' + required: false + default: '(none)' + options: + - '(none)' + - fedora:latest + - archlinux:latest + - ubuntu:latest + limit-access-to-actor: + type: choice + description: 'Whether to limit access to the actor only' + required: true + default: 'auto' + options: + - auto + - 'true' + - 'false' jobs: test: - strategy: - fail-fast: false - matrix: - runs-on: - - ubuntu-20.04 - - ubuntu-22.04 - - macos-11.0 - - macos-12.0 - - windows-2019 - - windows-2022 - limit-access-to-actor: - - true - - false - runs-on: ${{ matrix.runs-on }} + if: ${{ inputs.container-runs-on == '(none)' }} + runs-on: ${{ inputs.runs-on }} steps: - - uses: actions/checkout@v3 + - uses: msys2/setup-msys2@v2 + # The public preview of GitHub-hosted Windows/ARM64 runners lacks + # a working MSYS2 installation, so we need to set it up ourselves. + if: ${{ inputs.runs-on == 'windows-11-arm' }} + with: + msystem: 'CLANGARM64' + # We cannot use `C:\` because `msys2/setup-msys2` erroneously + # believes that an MSYS2 exists at `C:\msys64`, but it doesn't, + # which is the entire reason why we need to set it up in this + # here step... However, by using `C:\.\` we can fool that + # overzealous check. + location: C:\.\ + - uses: actions/checkout@v4 - uses: ./ with: - limit-access-to-actor: ${{ matrix.limit-access-to-actor }} + limit-access-to-actor: ${{ inputs.limit-access-to-actor }} test-container: - strategy: - fail-fast: false - matrix: - container-runs-on: - - fedora:latest - - archlinux:latest - - ubuntu:latest - limit-access-to-actor: - - true - - false + if: ${{ inputs.container-runs-on != '(none)' }} runs-on: ubuntu-latest container: - image: ${{ matrix.container-runs-on }} + image: ${{ inputs.container-runs-on }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ./ with: - limit-access-to-actor: ${{ matrix.limit-access-to-actor }} + limit-access-to-actor: ${{ inputs.limit-access-to-actor }} diff --git a/.github/workflows/update-manual-test.js b/.github/workflows/update-manual-test.js new file mode 100644 index 00000000..5f173370 --- /dev/null +++ b/.github/workflows/update-manual-test.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node + +// Update the `runs-on` options of the `manual-test.yml` workflow file with the +// latest available images from the GitHub Actions runner images README file. + +(async () => { + const fs = require('fs') + + const readme = await (await fetch("https://github.com/actions/runner-images/raw/HEAD/README.md")).text() + + // This will be the first `ubuntu` one. + let defaultOption = '' + + const choices = readme + // Get the "Available Images" section + .split(/\n## Available Images\n/)[1] + .split(/##\s*[^#]/)[0] + // Split by lines + .split('\n') + .map(line => { + // The relevant lines are table rows; The first column is the image name, + // the second one contains a relatively free-form list of the `runs-on` + // options that we are interested in. Those `runs-on` options are + // surrounded by backticks. + const match = line.match(/^\|\s*([^|]+)\s*\|([^|]*)`([^`|]+)`\s*\|/) + if (!match) return false // Skip e.g. the table header and empty lines + let runsOn = match[3] // default to the last `runs-on` option + const alternatives = match[2] + .split(/`([^`]*)`/) // split by backticks + .filter((_, i) => (i % 2)) // keep only the text between backticks + .sort((a, b) => a.length - b.length) // order by length + if (alternatives.length > 0 && alternatives[0].length < runsOn.length) runsOn = alternatives[0] + if (!defaultOption && match[3].startsWith('ubuntu-')) defaultOption = runsOn + return runsOn + }) + .filter(runsOn => runsOn) + + // The Windows/ARM64 runners are in public preview (and for the time being, + // not listed in the `runner-images` README file), so we need to add this + // manually. + if (!choices.includes('windows-11-arm')) choices.push('windows-11-arm') + + // Now edit the `manual-test` workflow definition + const ymlPath = `${__dirname}/manual-test.yml` + const yml = fs.readFileSync(ymlPath, 'utf8') + + // We want to replace the `runs-on` options and the `default` value. This + // would be easy if there was a built-in YAML parser and renderer in Node.js, + // but there is none. Therefore, we use a regular expression to find certain + // "needles" near the beginning of the file: first `workflow_dispatch:`, + // after that `runs-on:` and then `default:` and `options:`. Then we replace + // the `default` value and the `options` values with the new ones. + const [, beforeDefault, beforeOptions, optionsIndent, afterOptions] = + yml.match(/^([^]*?workflow_dispatch:[^]*?runs-on:[^]*?default:)(?:.*)([^]*?options:)(\n +- )(?:.*)(?:\3.*)*([^]*)/) || [] + if (!beforeDefault) throw new Error(`The 'manual-test.yml' file does not match the expected format!`) + const newYML = + `${beforeDefault} ${defaultOption}${[beforeOptions, ...choices].join(optionsIndent)}${afterOptions}` + fs.writeFileSync(ymlPath, newYML) +})().catch(e => { + console.error(e) + process.exitCode = 1 +}) \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..e9fadb72 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @canonical/platform-engineering diff --git a/README.md b/README.md index af43bc6e..fdac680e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ This is a forked version of [action-tmate](https://github.com/mxschmitt/action-t be used with [GitHub Runner Operator](https://github.com/canonical/github-runner-operator/) to provide automatic SSH debug access within the Canonical VPN. +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. + [![GitHub Actions](https://github.com/canonical/action-tmate/workflows/Node.js%20CI/badge.svg)](https://github.com/canonical/action-tmate/actions) [![GitHub Marketplace](https://img.shields.io/badge/GitHub-Marketplace-green)](https://github.com/marketplace/actions/debugging-with-tmate) @@ -31,9 +33,9 @@ jobs: build: runs-on: self-hosted steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup tmate session - uses: canonical/action-tmate@master + uses: canonical/action-tmate@main ``` 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. @@ -69,7 +71,7 @@ jobs: steps: # Enable tmate debugging of manually-triggered workflows if the input option was provided - name: Setup tmate session - uses: canonical/action-tmate@mxschmitt + uses: canonical/action-tmate@main if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} ``` * (any, kinda silly) -// ~2, ~2.x, ~2.x.x, ~>2, ~>2.x ~>2.x.x --> >=2.0.0 <3.0.0 -// ~2.0, ~2.0.x, ~>2.0, ~>2.0.x --> >=2.0.0 <2.1.0 -// ~1.2, ~1.2.x, ~>1.2, ~>1.2.x --> >=1.2.0 <1.3.0 -// ~1.2.3, ~>1.2.3 --> >=1.2.3 <1.3.0 -// ~1.2.0, ~>1.2.0 --> >=1.2.0 <1.3.0 -function replaceTildes (comp, options) { - return comp.trim().split(/\s+/).map(function (comp) { - return replaceTilde(comp, options) - }).join(' ') -} +module.exports = Hook; +// expose constructors as a named property for TypeScript +module.exports.Hook = Hook; +module.exports.Singular = Hook.Singular; +module.exports.Collection = Hook.Collection; -function replaceTilde (comp, options) { - var r = options.loose ? safeRe[t.TILDELOOSE] : safeRe[t.TILDE] - return comp.replace(r, function (_, M, m, p, pr) { - debug('tilde', comp, _, M, m, p, pr) - var ret - - if (isX(M)) { - ret = '' - } else if (isX(m)) { - ret = '>=' + M + '.0.0 <' + (+M + 1) + '.0.0' - } else if (isX(p)) { - // ~1.2 == >=1.2.0 <1.3.0 - ret = '>=' + M + '.' + m + '.0 <' + M + '.' + (+m + 1) + '.0' - } else if (pr) { - debug('replaceTilde pr', pr) - ret = '>=' + M + '.' + m + '.' + p + '-' + pr + - ' <' + M + '.' + (+m + 1) + '.0' - } else { - // ~1.2.3 == >=1.2.3 <1.3.0 - ret = '>=' + M + '.' + m + '.' + p + - ' <' + M + '.' + (+m + 1) + '.0' - } - debug('tilde return', ret) - return ret - }) -} +/***/ }), -// ^ --> * (any, kinda silly) -// ^2, ^2.x, ^2.x.x --> >=2.0.0 <3.0.0 -// ^2.0, ^2.0.x --> >=2.0.0 <3.0.0 -// ^1.2, ^1.2.x --> >=1.2.0 <2.0.0 -// ^1.2.3 --> >=1.2.3 <2.0.0 -// ^1.2.0 --> >=1.2.0 <2.0.0 -function replaceCarets (comp, options) { - return comp.trim().split(/\s+/).map(function (comp) { - return replaceCaret(comp, options) - }).join(' ') -} +/***/ 5549: +/***/ ((module) => { -function replaceCaret (comp, options) { - debug('caret', comp, options) - var r = options.loose ? safeRe[t.CARETLOOSE] : safeRe[t.CARET] - return comp.replace(r, function (_, M, m, p, pr) { - debug('caret', comp, _, M, m, p, pr) - var ret - - if (isX(M)) { - ret = '' - } else if (isX(m)) { - ret = '>=' + M + '.0.0 <' + (+M + 1) + '.0.0' - } else if (isX(p)) { - if (M === '0') { - ret = '>=' + M + '.' + m + '.0 <' + M + '.' + (+m + 1) + '.0' - } else { - ret = '>=' + M + '.' + m + '.0 <' + (+M + 1) + '.0.0' - } - } else if (pr) { - debug('replaceCaret pr', pr) - if (M === '0') { - if (m === '0') { - ret = '>=' + M + '.' + m + '.' + p + '-' + pr + - ' <' + M + '.' + m + '.' + (+p + 1) - } else { - ret = '>=' + M + '.' + m + '.' + p + '-' + pr + - ' <' + M + '.' + (+m + 1) + '.0' - } - } else { - ret = '>=' + M + '.' + m + '.' + p + '-' + pr + - ' <' + (+M + 1) + '.0.0' - } - } else { - debug('no pr') - if (M === '0') { - if (m === '0') { - ret = '>=' + M + '.' + m + '.' + p + - ' <' + M + '.' + m + '.' + (+p + 1) - } else { - ret = '>=' + M + '.' + m + '.' + p + - ' <' + M + '.' + (+m + 1) + '.0' - } - } else { - ret = '>=' + M + '.' + m + '.' + p + - ' <' + (+M + 1) + '.0.0' - } - } +module.exports = addHook; - debug('caret return', ret) - return ret - }) -} +function addHook(state, kind, name, hook) { + var orig = hook; + if (!state.registry[name]) { + state.registry[name] = []; + } -function replaceXRanges (comp, options) { - debug('replaceXRanges', comp, options) - return comp.split(/\s+/).map(function (comp) { - return replaceXRange(comp, options) - }).join(' ') -} + if (kind === "before") { + hook = function (method, options) { + return Promise.resolve() + .then(orig.bind(null, options)) + .then(method.bind(null, options)); + }; + } -function replaceXRange (comp, options) { - comp = comp.trim() - var r = options.loose ? safeRe[t.XRANGELOOSE] : safeRe[t.XRANGE] - return comp.replace(r, function (ret, gtlt, M, m, p, pr) { - debug('xRange', comp, ret, gtlt, M, m, p, pr) - var xM = isX(M) - var xm = xM || isX(m) - var xp = xm || isX(p) - var anyX = xp - - if (gtlt === '=' && anyX) { - gtlt = '' - } + if (kind === "after") { + hook = function (method, options) { + var result; + return Promise.resolve() + .then(method.bind(null, options)) + .then(function (result_) { + result = result_; + return orig(result, options); + }) + .then(function () { + return result; + }); + }; + } - // if we're including prereleases in the match, then we need - // to fix this to -0, the lowest possible prerelease value - pr = options.includePrerelease ? '-0' : '' + if (kind === "error") { + hook = function (method, options) { + return Promise.resolve() + .then(method.bind(null, options)) + .catch(function (error) { + return orig(error, options); + }); + }; + } - if (xM) { - if (gtlt === '>' || gtlt === '<') { - // nothing is allowed - ret = '<0.0.0-0' - } else { - // nothing is forbidden - ret = '*' - } - } else if (gtlt && anyX) { - // we know patch is an x, because we have any x at all. - // replace X with 0 - if (xm) { - m = 0 - } - p = 0 - - if (gtlt === '>') { - // >1 => >=2.0.0 - // >1.2 => >=1.3.0 - // >1.2.3 => >= 1.2.4 - gtlt = '>=' - if (xm) { - M = +M + 1 - m = 0 - p = 0 - } else { - m = +m + 1 - p = 0 - } - } else if (gtlt === '<=') { - // <=0.7.x is actually <0.8.0, since any 0.7.x should - // pass. Similarly, <=7.x is actually <8.0.0, etc. - gtlt = '<' - if (xm) { - M = +M + 1 - } else { - m = +m + 1 - } - } + state.registry[name].push({ + hook: hook, + orig: orig, + }); +} - ret = gtlt + M + '.' + m + '.' + p + pr - } else if (xm) { - ret = '>=' + M + '.0.0' + pr + ' <' + (+M + 1) + '.0.0' + pr - } else if (xp) { - ret = '>=' + M + '.' + m + '.0' + pr + - ' <' + M + '.' + (+m + 1) + '.0' + pr - } - debug('xRange return', ret) +/***/ }), - return ret - }) -} +/***/ 4670: +/***/ ((module) => { -// Because * is AND-ed with everything else in the comparator, -// and '' means "any version", just remove the *s entirely. -function replaceStars (comp, options) { - debug('replaceStars', comp, options) - // Looseness is ignored here. star is always as loose as it gets! - return comp.trim().replace(safeRe[t.STAR], '') -} +module.exports = register; -// This function is passed to string.replace(re[t.HYPHENRANGE]) -// M, m, patch, prerelease, build -// 1.2 - 3.4.5 => >=1.2.0 <=3.4.5 -// 1.2.3 - 3.4 => >=1.2.0 <3.5.0 Any 3.4.x will do -// 1.2 - 3.4 => >=1.2.0 <3.5.0 -function hyphenReplace ($0, - from, fM, fm, fp, fpr, fb, - to, tM, tm, tp, tpr, tb) { - if (isX(fM)) { - from = '' - } else if (isX(fm)) { - from = '>=' + fM + '.0.0' - } else if (isX(fp)) { - from = '>=' + fM + '.' + fm + '.0' - } else { - from = '>=' + from +function register(state, name, method, options) { + if (typeof method !== "function") { + throw new Error("method for before hook must be a function"); } - if (isX(tM)) { - to = '' - } else if (isX(tm)) { - to = '<' + (+tM + 1) + '.0.0' - } else if (isX(tp)) { - to = '<' + tM + '.' + (+tm + 1) + '.0' - } else if (tpr) { - to = '<=' + tM + '.' + tm + '.' + tp + '-' + tpr - } else { - to = '<=' + to + if (!options) { + options = {}; } - return (from + ' ' + to).trim() -} - -// if ANY of the sets match ALL of its comparators, then pass -Range.prototype.test = function (version) { - if (!version) { - return false + if (Array.isArray(name)) { + return name.reverse().reduce(function (callback, name) { + return register.bind(null, state, name, callback, options); + }, method)(); } - if (typeof version === 'string') { - try { - version = new SemVer(version, this.options) - } catch (er) { - return false + return Promise.resolve().then(function () { + if (!state.registry[name]) { + return method(options); } - } - for (var i = 0; i < this.set.length; i++) { - if (testSet(this.set[i], version, this.options)) { - return true - } - } - return false + return state.registry[name].reduce(function (method, registered) { + return registered.hook.bind(null, method, options); + }, method)(); + }); } -function testSet (set, version, options) { - for (var i = 0; i < set.length; i++) { - if (!set[i].test(version)) { - return false - } - } - if (version.prerelease.length && !options.includePrerelease) { - // Find the set of versions that are allowed to have prereleases - // For example, ^1.2.3-pr.1 desugars to >=1.2.3-pr.1 <2.0.0 - // That should allow `1.2.3-pr.2` to pass. - // However, `1.2.4-alpha.notready` should NOT be allowed, - // even though it's within the range set by the comparators. - for (i = 0; i < set.length; i++) { - debug(set[i].semver) - if (set[i].semver === ANY) { - continue - } +/***/ }), - if (set[i].semver.prerelease.length > 0) { - var allowed = set[i].semver - if (allowed.major === version.major && - allowed.minor === version.minor && - allowed.patch === version.patch) { - return true - } - } - } +/***/ 6819: +/***/ ((module) => { - // Version has a -pre, but it's not one of the ones we like. - return false +module.exports = removeHook; + +function removeHook(state, name, method) { + if (!state.registry[name]) { + return; } - return true -} + var index = state.registry[name] + .map(function (registered) { + return registered.orig; + }) + .indexOf(method); -exports.satisfies = satisfies -function satisfies (version, range, options) { - try { - range = new Range(range, options) - } catch (er) { - return false + if (index === -1) { + return; } - return range.test(version) -} -exports.maxSatisfying = maxSatisfying -function maxSatisfying (versions, range, options) { - var max = null - var maxSV = null - try { - var rangeObj = new Range(range, options) - } catch (er) { - return null - } - versions.forEach(function (v) { - if (rangeObj.test(v)) { - // satisfies(v, range, options) - if (!max || maxSV.compare(v) === -1) { - // compare(max, v, true) - max = v - maxSV = new SemVer(max, options) - } - } - }) - return max + state.registry[name].splice(index, 1); } -exports.minSatisfying = minSatisfying -function minSatisfying (versions, range, options) { - var min = null - var minSV = null - try { - var rangeObj = new Range(range, options) - } catch (er) { - return null - } - versions.forEach(function (v) { - if (rangeObj.test(v)) { - // satisfies(v, range, options) - if (!min || minSV.compare(v) === 1) { - // compare(min, v, true) - min = v - minSV = new SemVer(min, options) - } - } - }) - return min -} -exports.minVersion = minVersion -function minVersion (range, loose) { - range = new Range(range, loose) +/***/ }), - var minver = new SemVer('0.0.0') - if (range.test(minver)) { - return minver - } +/***/ 8932: +/***/ ((__unused_webpack_module, exports) => { - minver = new SemVer('0.0.0-0') - if (range.test(minver)) { - return minver - } +"use strict"; - minver = null - for (var i = 0; i < range.set.length; ++i) { - var comparators = range.set[i] - - comparators.forEach(function (comparator) { - // Clone to avoid manipulating the comparator's semver object. - var compver = new SemVer(comparator.semver.version) - switch (comparator.operator) { - case '>': - if (compver.prerelease.length === 0) { - compver.patch++ - } else { - compver.prerelease.push(0) - } - compver.raw = compver.format() - /* fallthrough */ - case '': - case '>=': - if (!minver || gt(minver, compver)) { - minver = compver - } - break - case '<': - case '<=': - /* Ignore maximum versions */ - break - /* istanbul ignore next */ - default: - throw new Error('Unexpected operation: ' + comparator.operator) - } - }) - } - if (minver && range.test(minver)) { - return minver - } +Object.defineProperty(exports, "__esModule", ({ value: true })); - return null -} +class Deprecation extends Error { + constructor(message) { + super(message); // Maintains proper stack trace (only available on V8) -exports.validRange = validRange -function validRange (range, options) { - try { - // Return '*' instead of '' so that truthiness works. - // This will throw if it's invalid anyway - return new Range(range, options).range || '*' - } catch (er) { - return null + /* istanbul ignore next */ + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + + this.name = 'Deprecation'; } -} -// Determine if version is less than all the versions possible in the range -exports.ltr = ltr -function ltr (version, range, options) { - return outside(version, range, '<', options) } -// Determine if version is greater than all the versions possible in the range. -exports.gtr = gtr -function gtr (version, range, options) { - return outside(version, range, '>', options) -} +exports.Deprecation = Deprecation; -exports.outside = outside -function outside (version, range, hilo, options) { - version = new SemVer(version, options) - range = new Range(range, options) - - var gtfn, ltefn, ltfn, comp, ecomp - switch (hilo) { - case '>': - gtfn = gt - ltefn = lte - ltfn = lt - comp = '>' - ecomp = '>=' - break - case '<': - gtfn = lt - ltefn = gte - ltfn = gt - comp = '<' - ecomp = '<=' - break - default: - throw new TypeError('Must provide a hilo val of "<" or ">"') - } - // If it satisifes the range it is not outside - if (satisfies(version, range, options)) { - return false - } +/***/ }), - // From now on, variable terms are as if we're in "gtr" mode. - // but note that everything is flipped for the "ltr" function. +/***/ 3287: +/***/ ((__unused_webpack_module, exports) => { - for (var i = 0; i < range.set.length; ++i) { - var comparators = range.set[i] +"use strict"; - var high = null - var low = null - comparators.forEach(function (comparator) { - if (comparator.semver === ANY) { - comparator = new Comparator('>=0.0.0') - } - high = high || comparator - low = low || comparator - if (gtfn(comparator.semver, high.semver, options)) { - high = comparator - } else if (ltfn(comparator.semver, low.semver, options)) { - low = comparator - } - }) +Object.defineProperty(exports, "__esModule", ({ value: true })); - // If the edge version comparator has a operator then our version - // isn't outside it - if (high.operator === comp || high.operator === ecomp) { - return false - } +/*! + * is-plain-object + * + * Copyright (c) 2014-2017, Jon Schlinkert. + * Released under the MIT License. + */ - // If the lowest version comparator has an operator and our version - // is less than it then it isn't higher than the range - if ((!low.operator || low.operator === comp) && - ltefn(version, low.semver)) { - return false - } else if (low.operator === ecomp && ltfn(version, low.semver)) { - return false - } - } - return true +function isObject(o) { + return Object.prototype.toString.call(o) === '[object Object]'; } -exports.prerelease = prerelease -function prerelease (version, options) { - var parsed = parse(version, options) - return (parsed && parsed.prerelease.length) ? parsed.prerelease : null -} +function isPlainObject(o) { + var ctor,prot; -exports.intersects = intersects -function intersects (r1, r2, options) { - r1 = new Range(r1, options) - r2 = new Range(r2, options) - return r1.intersects(r2) -} + if (isObject(o) === false) return false; -exports.coerce = coerce -function coerce (version, options) { - if (version instanceof SemVer) { - return version - } + // If has modified constructor + ctor = o.constructor; + if (ctor === undefined) return true; - if (typeof version === 'number') { - version = String(version) - } + // If has modified prototype + prot = ctor.prototype; + if (isObject(prot) === false) return false; - if (typeof version !== 'string') { - return null + // If constructor does not have an Object-specific method + if (prot.hasOwnProperty('isPrototypeOf') === false) { + return false; } - options = options || {} + // Most likely a plain Object + return true; +} - var match = null - if (!options.rtl) { - match = version.match(safeRe[t.COERCE]) - } else { - // Find the right-most coercible string that does not share - // a terminus with a more left-ward coercible string. - // Eg, '1.2.3.4' wants to coerce '2.3.4', not '3.4' or '4' - // - // Walk through the string checking with a /g regexp - // Manually set the index so as to pick up overlapping matches. - // Stop when we get a match that ends at the string end, since no - // coercible string can be more right-ward without the same terminus. - var next - while ((next = safeRe[t.COERCERTL].exec(version)) && - (!match || match.index + match[0].length !== version.length) - ) { - if (!match || - next.index + next[0].length !== match.index + match[0].length) { - match = next - } - safeRe[t.COERCERTL].lastIndex = next.index + next[1].length + next[2].length - } - // leave it in a clean state - safeRe[t.COERCERTL].lastIndex = -1 - } +exports.isPlainObject = isPlainObject; + + +/***/ }), + +/***/ 1223: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +var wrappy = __nccwpck_require__(2940) +module.exports = wrappy(once) +module.exports.strict = wrappy(onceStrict) + +once.proto = once(function () { + Object.defineProperty(Function.prototype, 'once', { + value: function () { + return once(this) + }, + configurable: true + }) + + Object.defineProperty(Function.prototype, 'onceStrict', { + value: function () { + return onceStrict(this) + }, + configurable: true + }) +}) - if (match === null) { - return null +function once (fn) { + var f = function () { + if (f.called) return f.value + f.called = true + return f.value = fn.apply(this, arguments) } + f.called = false + return f +} - return parse(match[2] + - '.' + (match[3] || '0') + - '.' + (match[4] || '0'), options) +function onceStrict (fn) { + var f = function () { + if (f.called) + throw new Error(f.onceError) + f.called = true + return f.value = fn.apply(this, arguments) + } + var name = fn.name || 'Function wrapped with `once`' + f.onceError = name + " shouldn't be called more than once" + f.called = false + return f } @@ -17066,14 +13245,6 @@ module.exports = require("assert"); /***/ }), -/***/ 2081: -/***/ ((module) => { - -"use strict"; -module.exports = require("child_process"); - -/***/ }), - /***/ 6113: /***/ ((module) => { @@ -17279,28 +13450,26 @@ var __webpack_exports__ = {}; // ESM COMPAT FLAG __nccwpck_require__.r(__webpack_exports__); -// EXTERNAL MODULE: external "os" -var external_os_ = __nccwpck_require__(2037); -var external_os_default = /*#__PURE__*/__nccwpck_require__.n(external_os_); -// EXTERNAL MODULE: external "fs" -var external_fs_ = __nccwpck_require__(7147); -var external_fs_default = /*#__PURE__*/__nccwpck_require__.n(external_fs_); -// EXTERNAL MODULE: external "path" -var external_path_ = __nccwpck_require__(1017); -var external_path_default = /*#__PURE__*/__nccwpck_require__.n(external_path_); // EXTERNAL MODULE: ./node_modules/@actions/core/lib/core.js var core = __nccwpck_require__(2186); // EXTERNAL MODULE: ./node_modules/@actions/github/lib/github.js var github = __nccwpck_require__(5438); -// EXTERNAL MODULE: ./node_modules/@actions/tool-cache/lib/tool-cache.js -var tool_cache = __nccwpck_require__(7784); // EXTERNAL MODULE: ./node_modules/@octokit/rest/dist-node/index.js var dist_node = __nccwpck_require__(5375); +// EXTERNAL MODULE: external "fs" +var external_fs_ = __nccwpck_require__(7147); +var external_fs_default = /*#__PURE__*/__nccwpck_require__.n(external_fs_); +// EXTERNAL MODULE: external "os" +var external_os_ = __nccwpck_require__(2037); +var external_os_default = /*#__PURE__*/__nccwpck_require__.n(external_os_); +// EXTERNAL MODULE: external "path" +var external_path_ = __nccwpck_require__(1017); +var external_path_default = /*#__PURE__*/__nccwpck_require__.n(external_path_); ;// CONCATENATED MODULE: external "process" const external_process_namespaceObject = require("process"); var external_process_default = /*#__PURE__*/__nccwpck_require__.n(external_process_namespaceObject); -// EXTERNAL MODULE: external "child_process" -var external_child_process_ = __nccwpck_require__(2081); +;// CONCATENATED MODULE: external "child_process" +const external_child_process_namespaceObject = require("child_process"); ;// CONCATENATED MODULE: ./src/helpers.js // @ts-check @@ -17309,6 +13478,7 @@ var external_child_process_ = __nccwpck_require__(2081); + /** * @returns {boolean} */ @@ -17326,14 +13496,14 @@ const execShellCommand = (cmd, options) => { core.debug(`Executing shell command: [${cmd}]`) return new Promise((resolve, reject) => { const proc = (external_process_default()).platform !== "win32" ? - (0,external_child_process_.spawn)(cmd, [], { + (0,external_child_process_namespaceObject.spawn)(cmd, [], { shell: true, env: { ...(external_process_default()).env, - HOMEBREW_GITHUB_API_TOKEN: core.getInput('github-token') || undefined + HOMEBREW_GITHUB_API_TOKEN: core.getInput("github-token") || undefined } }) : - (0,external_child_process_.spawn)("C:\\msys64\\usr\\bin\\bash.exe", ["-lc", cmd], { + (0,external_child_process_namespaceObject.spawn)(`${core.getInput("msys2-location") || "C:\\msys64"}\\usr\\bin\\bash.exe`, ["-lc", cmd], { env: { ...(external_process_default()).env, "MSYS2_PATH_TYPE": "inherit", /* Inherit previous path */ @@ -17342,16 +13512,16 @@ const execShellCommand = (cmd, options) => { } }) let stdout = "" - proc.stdout.on('data', (data) => { + proc.stdout.on("data", (data) => { if (!options || !options.quiet) external_process_default().stdout.write(data); stdout += data.toString(); }); - proc.stderr.on('data', (data) => { + proc.stderr.on("data", (data) => { external_process_default().stderr.write(data) }); - proc.on('exit', (code) => { + proc.on("exit", (code) => { if (code !== 0) { reject(new Error(code ? code.toString() : undefined)) } @@ -17364,12 +13534,13 @@ const execShellCommand = (cmd, options) => { /** * @param {string} key * @param {RegExp} re regex to use for validation - * @return {string|undefined} {undefined} or throws an error if input doesn't match regex + * @return {string|undefined} {undefined} or throws an error if input doesn"t match regex */ const getValidatedEnvVars = (key, re) => { - const value = (external_process_default()).env[key] || "" + const envVarKey = key.toUpperCase().replace(/-/gi, "_") + const value = (external_process_default()).env[envVarKey] || "" if (value !== undefined && !re.test(value)) { - throw new Error(`Invalid value for '${key}': '${value}'`); + throw new Error(`Invalid value for "${key}(${envVarKey})": "${value}"`); } return value; } @@ -17380,7 +13551,7 @@ const getValidatedEnvVars = (key, re) => { */ const getLinuxDistro = async () => { try { - const osRelease = await external_fs_default().promises.readFile("/etc/os-release") + const osRelease = await fs.promises.readFile("/etc/os-release") const match = osRelease.toString().match(/^ID=(.*)$/m) return match ? match[1] : "(unknown)" } catch (e) { @@ -17388,6 +13559,34 @@ const getLinuxDistro = async () => { } } +/** + * @param {string} host + * @return {Promise} + */ +const updateSshConfig = async (host) => { + try { + core.info(`Updating SSH config for host: ${host}`) + const sshConfigEntry = ` + +Host ${host} + HostKeyAlgorithms ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256 + KexAlgorithms ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256,ext-info-c,kex-strict-c-v00@openssh.com + Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-cbc,3des-cbc,aes192-cbc,aes256-cbc,aes128-gcm@openssh.com,aes256-gcm@openssh.com + MACs hmac-sha1,hmac-sha2-256,hmac-sha2-512,hmac-sha1-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com +` + + const sshDir = external_path_default().join(external_os_default().homedir(), ".ssh") + await external_fs_default().promises.mkdir(sshDir, {recursive: true, mode: 0o700}) + const sshConfigPath = external_path_default().join(sshDir, "config") + await external_fs_default().promises.appendFile(sshConfigPath, sshConfigEntry) + await external_fs_default().promises.chmod(sshConfigPath, 0o600) + + core.info(`Added SSH config entry:\n${sshConfigEntry.trim()}`) + } catch (error) { + core.warning(`Failed to update SSH config: ${error.message || error}`) + } +} + ;// CONCATENATED MODULE: ./src/index.js // @ts-check @@ -17400,17 +13599,20 @@ const getLinuxDistro = async () => { - -const TMATE_LINUX_VERSION = "2.4.0" - // Map os.arch() values to the architectures in tmate release binary filenames. // Possible os.arch() values documented here: // https://nodejs.org/api/os.html#os_os_arch // Available tmate binaries listed here: -// https://github.com/tmate-io/tmate/releases/ +// https://packages.ubuntu.com/jammy/tmate +// For different Ubuntu releases, change the release (i.e. jammy) to the +// appropriate release. const TMATE_ARCH_MAP = { arm64: 'arm64v8', + armhf: 'armhf', x64: 'amd64', + ppc64: 'ppc64', + riscv64: 'riscv64', + s390x: 's390x' }; /** @param {number} ms */ @@ -17419,7 +13621,15 @@ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); async function run() { try { /* Indicates whether the POST action is running */ - if (!!core.getState('isPost')) { + const isPost = !!core.getState('isPost') + // Since main and post share the same entry point (lib/index.js), we use + // saveState/getState to distinguish them. This call has no effect on the + // current run — it tells the GitHub Actions runner to pass 'isPost' to + // the post step so it can detect itself. Must happen before any early + // return (e.g. connectivity-check). + core.saveState('isPost', 'true') + + if (isPost) { const message = core.getState('message') const tmate = core.getState('tmate') if (tmate && message) { @@ -17441,12 +13651,17 @@ async function run() { && '0' !== await execShellCommand(`${tmate} display -p '#{tmate_num_clients}'`, { quiet: true }) } })() - for (let seconds = 10 * 60; seconds > 0; ) { - console.log(`${ - await hasAnyoneConnectedYet() + + let connectTimeoutSeconds = parseInt(core.getInput("connect-timeout-seconds")) + if (Number.isNaN(connectTimeoutSeconds) || connectTimeoutSeconds <= 0) { + connectTimeoutSeconds = 10 * 60 + } + + for (let seconds = connectTimeoutSeconds; seconds > 0;) { + console.log(`${await hasAnyoneConnectedYet() ? 'Waiting for session to end' : `Waiting for client to connect (at most ${seconds} more second(s))` - }\n${message}`) + }\n${message}`) if (continueFileExists()) { core.info("Exiting debugging session because the continue file was created") @@ -17468,43 +13683,31 @@ async function run() { let tmateExecutable = "tmate" if (core.getInput("install-dependencies") !== "false") { core.debug("Installing dependencies") - if ((external_process_default()).platform === "darwin") { - await execShellCommand('brew install tmate'); - } else if ((external_process_default()).platform === "win32") { - await execShellCommand('pacman -S --noconfirm tmate'); - } else { - const optionalSudoPrefix = useSudoPrefix() ? "sudo " : ""; - const distro = await getLinuxDistro(); - core.debug("linux distro: [" + distro + "]"); - if (distro === "alpine") { - // for set -e workaround, we need to install bash because alpine doesn't have it - await execShellCommand(optionalSudoPrefix + 'apk add openssh-client xz bash'); - } else if (distro === "arch") { - // partial upgrades are not supported so also upgrade everything - await execShellCommand(optionalSudoPrefix + 'pacman -Syu --noconfirm xz openssh'); - } else if (distro === "fedora") { - await execShellCommand(optionalSudoPrefix + 'dnf install -y xz openssh'); - } else { - await execShellCommand(optionalSudoPrefix + 'apt-get update'); - await execShellCommand(optionalSudoPrefix + 'apt-get install -y openssh-client xz-utils'); - } + const optionalSudoPrefix = useSudoPrefix() ? "sudo " : ""; + await execShellCommand(optionalSudoPrefix + 'DEBIAN_FRONTEND=noninteractive apt-get update'); + await execShellCommand(optionalSudoPrefix + 'DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-client xz-utils'); + await execShellCommand(optionalSudoPrefix + 'DEBIAN_FRONTEND=noninteractive apt-get install -y tmate'); + + const tmateArch = TMATE_ARCH_MAP[external_os_default().arch()]; + if (!tmateArch) { + throw new Error(`Unsupported architecture: ${external_os_default().arch()}`) + } + // We change from downloading tmate from source built tar from GitHub to the + // Ubuntu packages tmate binary. Hence we've removed support for non Ubuntu/Linux + // platforms/distributions. + // This decision is to support different architectures. + tmateExecutable = external_path_default().join("/usr/bin/", "tmate") - const tmateArch = TMATE_ARCH_MAP[external_os_default().arch()]; - if (!tmateArch) { - throw new Error(`Unsupported architecture: ${external_os_default().arch()}`) - } - const tmateReleaseTar = await tool_cache.downloadTool(`https://github.com/tmate-io/tmate/releases/download/${TMATE_LINUX_VERSION}/tmate-${TMATE_LINUX_VERSION}-static-linux-${tmateArch}.tar.xz`); - const tmateDir = external_path_default().join(external_os_default().tmpdir(), "tmate") - tmateExecutable = external_path_default().join(tmateDir, "tmate") - - if (external_fs_default().existsSync(tmateExecutable)) - external_fs_default().unlinkSync(tmateExecutable) - external_fs_default().mkdirSync(tmateDir, { recursive: true }) - await execShellCommand(`tar x -C ${tmateDir} -f ${tmateReleaseTar} --strip-components=1`) - external_fs_default().unlinkSync(tmateReleaseTar) + // Optionally start the proxy service. + try { + await execShellCommand(optionalSudoPrefix + 'systemctl enable tmate-proxy --now'); + } catch (error) { + core.info(`tmate-proxy not enabled`); + core.debug(`tmate-proxy error: ${error.message || error}`); + if (error.stderr) core.debug(`stderr: ${error.stderr}`); } - core.debug("Installed dependencies successfully"); } + core.debug("Installed dependencies successfully"); if ((external_process_default()).platform === "win32") { tmateExecutable = 'CHERE_INVOKING=1 tmate' @@ -17524,7 +13727,7 @@ async function run() { if (limitAccessToActor === "true" || limitAccessToActor === "auto") { const { actor, apiUrl } = github.context const auth = core.getInput('github-token') - const octokit = new dist_node.Octokit({ auth, baseUrl: apiUrl, request: { fetch }}); + const octokit = new dist_node.Octokit({ auth, baseUrl: apiUrl, request: { fetch } }); const keys = await octokit.users.listPublicKeysForUser({ username: actor @@ -17536,7 +13739,7 @@ async function run() { const sshPath = external_path_default().join(external_os_default().homedir(), ".ssh") await external_fs_default().promises.mkdir(sshPath, { recursive: true }) const authorizedKeysPath = external_path_default().join(sshPath, "authorized_keys") - await external_fs_default().promises.writeFile(authorizedKeysPath, keys.data.map(e => e.key).join('\n')) + await external_fs_default().promises.appendFile(authorizedKeysPath, keys.data.map(e => e.key).join('\n')) newSessionExtra = `-a "${authorizedKeysPath}"` tmateSSHDashI = "ssh -i " } @@ -17552,10 +13755,10 @@ async function run() { // values that are not, strictly speaking, valid, but should be good // enough for detecting obvious errors, which is all we want here. const options = { - "TMATE_SERVER_HOST": /^[a-z\d\-]+(\.[a-z\d\-]+)*$/i, - "TMATE_SERVER_PORT": /^\d{1,5}$/, - "TMATE_SERVER_RSA_FINGERPRINT": /./, - "TMATE_SERVER_ED25519_FINGERPRINT": /./, + "tmate-server-host": /^[a-z\d\-]+(\.[a-z\d\-]+)*$/i, + "tmate-server-port": /^\d{1,5}$/, + "tmate-server-rsa-fingerprint": /./, + "tmate-server-ed25519-fingerprint": /./, } let host = ""; @@ -17564,15 +13767,17 @@ async function run() { const value = getValidatedEnvVars(key, option); if (value !== undefined) { setDefaultCommand = `${setDefaultCommand} set-option -g ${key} "${value}" \\;`; - if (key === "TMATE_SERVER_HOST") { + if (key === "tmate-server-host") { host = value; } - if (key === "TMATE_SERVER_PORT") { + if (key === "tmate-server-port") { port = value; } } } + await updateSshConfig(host || "ssh.tmate.io"); + core.debug("Creating new session") await execShellCommand(`${tmate} ${newSessionExtra} ${setDefaultCommand} new-session -d`); await execShellCommand(`${tmate} wait tmate-ready`); @@ -17580,18 +13785,20 @@ async function run() { core.debug("Fetching connection strings") const tmateSSH = await execShellCommand(`${tmate} display -p '#{tmate_ssh}'`); - const [ , ,tokenHost] = tmateSSH.split(" "); - const [token, ] = tokenHost.split("@") + const [, , tokenHost] = tmateSSH.split(" "); + const [token,] = tokenHost.split("@") const tmateWeb = await execShellCommand(`${tmate} display -p '#{tmate_web}'`); - /* - * Publish a variable so that when the POST action runs, it can determine - * it should run the appropriate logic. This is necessary since we don't - * have a separate entry point. - * - * Inspired by https://github.com/actions/checkout/blob/v3.1.0/src/state-helper.ts#L56-L60 - */ - core.saveState('isPost', 'true') + if (core.getInput("connectivity-check") === "true") { + core.info("Connectivity check: tmate session created successfully") + core.info(`SSH: ${tmateSSH}`) + if (tmateWeb) { + core.info(`Web shell: ${tmateWeb}`) + } + await execShellCommand(`${tmate} kill-session`) + core.info("Connectivity check: session terminated, connectivity verified") + return + } const detached = core.getInput("detached") if (detached === "true") { @@ -17610,6 +13817,15 @@ async function run() { } core.saveState('message', message) core.saveState('tmate', tmate) + + // Set the SSH command as an output so other jobs can use it + core.setOutput('ssh-command', tmateSSH) + // Extract and set the raw SSH address (without the "ssh" prefix) + core.setOutput('ssh-address', tmateSSH.replace(/^ssh /, '')) + if (tmateWeb) { + core.setOutput('web-url', tmateWeb) + } + console.log(message) return } @@ -17622,7 +13838,7 @@ async function run() { if (tmateWeb) { core.info(`Web shell: ${tmateWeb}`); } - core.info(`SSH: ssh -p ${port} ${token}@${host}`); + core.info(`SSH: ${tmateSSH}`); if (tmateSSHDashI) { core.info(`or: ${tmateSSH.replace(/^ssh/, tmateSSHDashI)}`) } @@ -17646,12 +13862,12 @@ async function run() { } function didTmateQuit() { - const tmateSocketPath = (external_process_default()).platform === "win32" ? "C:/msys64/tmp/tmate.sock" : "/tmp/tmate.sock" + const tmateSocketPath = (external_process_default()).platform === "win32" ? `${core.getInput("msys2-location") || "C:\\msys64"}/tmp/tmate.sock` : "/tmp/tmate.sock" return !external_fs_default().existsSync(tmateSocketPath) } function continueFileExists() { - const continuePath = (external_process_default()).platform === "win32" ? "C:/msys64/continue" : "/continue" + const continuePath = (external_process_default()).platform === "win32" ? `${core.getInput("msys2-location") || "C:\\msys64"}/continue` : "/continue" return external_fs_default().existsSync(continuePath) || external_fs_default().existsSync(external_path_default().join((external_process_default()).env.GITHUB_WORKSPACE, "continue")) } diff --git a/package-lock.json b/package-lock.json index 02aa63e9..063debbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3220,12 +3220,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -3434,9 +3434,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", + "integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==", "dev": true, "dependencies": { "path-key": "^3.1.0", @@ -3647,9 +3647,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -5751,12 +5751,12 @@ "dev": true }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -9079,12 +9079,12 @@ } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" } }, "browserslist": { @@ -9224,9 +9224,9 @@ } }, "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", + "integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==", "dev": true, "requires": { "path-key": "^3.1.0", @@ -9376,9 +9376,9 @@ } }, "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "requires": { "to-regex-range": "^5.0.1" @@ -10936,12 +10936,12 @@ "dev": true }, "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "requires": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" } }, diff --git a/package.json b/package.json index 7aa60e75..8fe1d52d 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,12 @@ "scripts": { "start": "node src/index.js", "build": "ncc build src/main.js -o lib", + "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", "test": "GITHUB_EVENT_PATH= jest" }, "repository": { "type": "git", - "url": "https://github.com/mxschmitt/action-tmate.git" + "url": "https://github.com/canonical/action-tmate.git" }, "keywords": [ "actions", diff --git a/src/helpers.js b/src/helpers.js index 9318170d..2b955d88 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -1,8 +1,9 @@ // @ts-check -import { spawn } from 'child_process' +import {spawn} from "child_process" import * as core from "@actions/core" -import fs from 'fs' -import os from 'os' +import fs from "fs" +import os from "os" +import path from "path" import process from "process" /** @@ -26,10 +27,10 @@ export const execShellCommand = (cmd, options) => { shell: true, env: { ...process.env, - HOMEBREW_GITHUB_API_TOKEN: core.getInput('github-token') || undefined + HOMEBREW_GITHUB_API_TOKEN: core.getInput("github-token") || undefined } }) : - spawn("C:\\msys64\\usr\\bin\\bash.exe", ["-lc", cmd], { + spawn(`${core.getInput("msys2-location") || "C:\\msys64"}\\usr\\bin\\bash.exe`, ["-lc", cmd], { env: { ...process.env, "MSYS2_PATH_TYPE": "inherit", /* Inherit previous path */ @@ -38,16 +39,16 @@ export const execShellCommand = (cmd, options) => { } }) let stdout = "" - proc.stdout.on('data', (data) => { + proc.stdout.on("data", (data) => { if (!options || !options.quiet) process.stdout.write(data); stdout += data.toString(); }); - proc.stderr.on('data', (data) => { + proc.stderr.on("data", (data) => { process.stderr.write(data) }); - proc.on('exit', (code) => { + proc.on("exit", (code) => { if (code !== 0) { reject(new Error(code ? code.toString() : undefined)) } @@ -60,12 +61,13 @@ export const execShellCommand = (cmd, options) => { /** * @param {string} key * @param {RegExp} re regex to use for validation - * @return {string|undefined} {undefined} or throws an error if input doesn't match regex + * @return {string|undefined} {undefined} or throws an error if input doesn"t match regex */ export const getValidatedEnvVars = (key, re) => { - const value = process.env[key] || "" + const envVarKey = key.toUpperCase().replace(/-/gi, "_") + const value = process.env[envVarKey] || "" if (value !== undefined && !re.test(value)) { - throw new Error(`Invalid value for '${key}': '${value}'`); + throw new Error(`Invalid value for "${key}(${envVarKey})": "${value}"`); } return value; } @@ -83,3 +85,31 @@ export const getLinuxDistro = async () => { return "(unknown)" } } + +/** + * @param {string} host + * @return {Promise} + */ +export const updateSshConfig = async (host) => { + try { + core.info(`Updating SSH config for host: ${host}`) + const sshConfigEntry = ` + +Host ${host} + HostKeyAlgorithms ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256 + KexAlgorithms ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256,kex-strict-c-v00@openssh.com + Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-cbc,3des-cbc,aes192-cbc,aes256-cbc,aes128-gcm@openssh.com,aes256-gcm@openssh.com + MACs hmac-sha1,hmac-sha2-256,hmac-sha2-512,hmac-sha1-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com +` + + const sshDir = path.join(os.homedir(), ".ssh") + await fs.promises.mkdir(sshDir, {recursive: true, mode: 0o700}) + const sshConfigPath = path.join(sshDir, "config") + await fs.promises.appendFile(sshConfigPath, sshConfigEntry) + await fs.promises.chmod(sshConfigPath, 0o600) + + core.info(`Added SSH config entry:\n${sshConfigEntry.trim()}`) + } catch (error) { + core.warning(`Failed to update SSH config: ${error.message || error}`) + } +} diff --git a/src/index.js b/src/index.js index 76f672c0..fa492a5f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,25 +1,28 @@ // @ts-check -import os from "os" -import fs from "fs" -import path from "path" import * as core from "@actions/core" import * as github from "@actions/github" -import * as tc from "@actions/tool-cache" import { Octokit } from "@octokit/rest" +import fs from "fs" +import os from "os" +import path from "path" import process from "process" -import { execShellCommand, getValidatedEnvVars, getLinuxDistro, useSudoPrefix } from "./helpers" - -const TMATE_LINUX_VERSION = "2.4.0" +import { execShellCommand, getValidatedEnvVars, updateSshConfig, useSudoPrefix } from "./helpers" // Map os.arch() values to the architectures in tmate release binary filenames. // Possible os.arch() values documented here: // https://nodejs.org/api/os.html#os_os_arch // Available tmate binaries listed here: -// https://github.com/tmate-io/tmate/releases/ +// https://packages.ubuntu.com/jammy/tmate +// For different Ubuntu releases, change the release (i.e. jammy) to the +// appropriate release. const TMATE_ARCH_MAP = { arm64: 'arm64v8', + armhf: 'armhf', x64: 'amd64', + ppc64: 'ppc64', + riscv64: 'riscv64', + s390x: 's390x' }; /** @param {number} ms */ @@ -28,7 +31,15 @@ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); export async function run() { try { /* Indicates whether the POST action is running */ - if (!!core.getState('isPost')) { + const isPost = !!core.getState('isPost') + // Since main and post share the same entry point (lib/index.js), we use + // saveState/getState to distinguish them. This call has no effect on the + // current run — it tells the GitHub Actions runner to pass 'isPost' to + // the post step so it can detect itself. Must happen before any early + // return (e.g. connectivity-check). + core.saveState('isPost', 'true') + + if (isPost) { const message = core.getState('message') const tmate = core.getState('tmate') if (tmate && message) { @@ -50,12 +61,17 @@ export async function run() { && '0' !== await execShellCommand(`${tmate} display -p '#{tmate_num_clients}'`, { quiet: true }) } })() - for (let seconds = 10 * 60; seconds > 0; ) { - console.log(`${ - await hasAnyoneConnectedYet() + + let connectTimeoutSeconds = parseInt(core.getInput("connect-timeout-seconds")) + if (Number.isNaN(connectTimeoutSeconds) || connectTimeoutSeconds <= 0) { + connectTimeoutSeconds = 10 * 60 + } + + for (let seconds = connectTimeoutSeconds; seconds > 0;) { + console.log(`${await hasAnyoneConnectedYet() ? 'Waiting for session to end' : `Waiting for client to connect (at most ${seconds} more second(s))` - }\n${message}`) + }\n${message}`) if (continueFileExists()) { core.info("Exiting debugging session because the continue file was created") @@ -77,43 +93,31 @@ export async function run() { let tmateExecutable = "tmate" if (core.getInput("install-dependencies") !== "false") { core.debug("Installing dependencies") - if (process.platform === "darwin") { - await execShellCommand('brew install tmate'); - } else if (process.platform === "win32") { - await execShellCommand('pacman -S --noconfirm tmate'); - } else { - const optionalSudoPrefix = useSudoPrefix() ? "sudo " : ""; - const distro = await getLinuxDistro(); - core.debug("linux distro: [" + distro + "]"); - if (distro === "alpine") { - // for set -e workaround, we need to install bash because alpine doesn't have it - await execShellCommand(optionalSudoPrefix + 'apk add openssh-client xz bash'); - } else if (distro === "arch") { - // partial upgrades are not supported so also upgrade everything - await execShellCommand(optionalSudoPrefix + 'pacman -Syu --noconfirm xz openssh'); - } else if (distro === "fedora") { - await execShellCommand(optionalSudoPrefix + 'dnf install -y xz openssh'); - } else { - await execShellCommand(optionalSudoPrefix + 'apt-get update'); - await execShellCommand(optionalSudoPrefix + 'apt-get install -y openssh-client xz-utils'); - } + const optionalSudoPrefix = useSudoPrefix() ? "sudo " : ""; + await execShellCommand(optionalSudoPrefix + 'DEBIAN_FRONTEND=noninteractive apt-get update'); + await execShellCommand(optionalSudoPrefix + 'DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-client xz-utils'); + await execShellCommand(optionalSudoPrefix + 'DEBIAN_FRONTEND=noninteractive apt-get install -y tmate'); - const tmateArch = TMATE_ARCH_MAP[os.arch()]; - if (!tmateArch) { - throw new Error(`Unsupported architecture: ${os.arch()}`) - } - const tmateReleaseTar = await tc.downloadTool(`https://github.com/tmate-io/tmate/releases/download/${TMATE_LINUX_VERSION}/tmate-${TMATE_LINUX_VERSION}-static-linux-${tmateArch}.tar.xz`); - const tmateDir = path.join(os.tmpdir(), "tmate") - tmateExecutable = path.join(tmateDir, "tmate") - - if (fs.existsSync(tmateExecutable)) - fs.unlinkSync(tmateExecutable) - fs.mkdirSync(tmateDir, { recursive: true }) - await execShellCommand(`tar x -C ${tmateDir} -f ${tmateReleaseTar} --strip-components=1`) - fs.unlinkSync(tmateReleaseTar) + const tmateArch = TMATE_ARCH_MAP[os.arch()]; + if (!tmateArch) { + throw new Error(`Unsupported architecture: ${os.arch()}`) + } + // We change from downloading tmate from source built tar from GitHub to the + // Ubuntu packages tmate binary. Hence we've removed support for non Ubuntu/Linux + // platforms/distributions. + // This decision is to support different architectures. + tmateExecutable = path.join("/usr/bin/", "tmate") + + // Optionally start the proxy service. + try { + await execShellCommand(optionalSudoPrefix + 'systemctl enable tmate-proxy --now'); + } catch (error) { + core.info(`tmate-proxy not enabled`); + core.debug(`tmate-proxy error: ${error.message || error}`); + if (error.stderr) core.debug(`stderr: ${error.stderr}`); } - core.debug("Installed dependencies successfully"); } + core.debug("Installed dependencies successfully"); if (process.platform === "win32") { tmateExecutable = 'CHERE_INVOKING=1 tmate' @@ -133,7 +137,7 @@ export async function run() { if (limitAccessToActor === "true" || limitAccessToActor === "auto") { const { actor, apiUrl } = github.context const auth = core.getInput('github-token') - const octokit = new Octokit({ auth, baseUrl: apiUrl, request: { fetch }}); + const octokit = new Octokit({ auth, baseUrl: apiUrl, request: { fetch } }); const keys = await octokit.users.listPublicKeysForUser({ username: actor @@ -145,7 +149,7 @@ export async function run() { const sshPath = path.join(os.homedir(), ".ssh") await fs.promises.mkdir(sshPath, { recursive: true }) const authorizedKeysPath = path.join(sshPath, "authorized_keys") - await fs.promises.writeFile(authorizedKeysPath, keys.data.map(e => e.key).join('\n')) + await fs.promises.appendFile(authorizedKeysPath, keys.data.map(e => e.key).join('\n')) newSessionExtra = `-a "${authorizedKeysPath}"` tmateSSHDashI = "ssh -i " } @@ -161,10 +165,10 @@ export async function run() { // values that are not, strictly speaking, valid, but should be good // enough for detecting obvious errors, which is all we want here. const options = { - "TMATE_SERVER_HOST": /^[a-z\d\-]+(\.[a-z\d\-]+)*$/i, - "TMATE_SERVER_PORT": /^\d{1,5}$/, - "TMATE_SERVER_RSA_FINGERPRINT": /./, - "TMATE_SERVER_ED25519_FINGERPRINT": /./, + "tmate-server-host": /^[a-z\d\-]+(\.[a-z\d\-]+)*$/i, + "tmate-server-port": /^\d{1,5}$/, + "tmate-server-rsa-fingerprint": /./, + "tmate-server-ed25519-fingerprint": /./, } let host = ""; @@ -173,15 +177,17 @@ export async function run() { const value = getValidatedEnvVars(key, option); if (value !== undefined) { setDefaultCommand = `${setDefaultCommand} set-option -g ${key} "${value}" \\;`; - if (key === "TMATE_SERVER_HOST") { + if (key === "tmate-server-host") { host = value; } - if (key === "TMATE_SERVER_PORT") { + if (key === "tmate-server-port") { port = value; } } } + await updateSshConfig(host || "ssh.tmate.io"); + core.debug("Creating new session") await execShellCommand(`${tmate} ${newSessionExtra} ${setDefaultCommand} new-session -d`); await execShellCommand(`${tmate} wait tmate-ready`); @@ -189,18 +195,20 @@ export async function run() { core.debug("Fetching connection strings") const tmateSSH = await execShellCommand(`${tmate} display -p '#{tmate_ssh}'`); - const [ , ,tokenHost] = tmateSSH.split(" "); - const [token, ] = tokenHost.split("@") + const [, , tokenHost] = tmateSSH.split(" "); + const [token,] = tokenHost.split("@") const tmateWeb = await execShellCommand(`${tmate} display -p '#{tmate_web}'`); - /* - * Publish a variable so that when the POST action runs, it can determine - * it should run the appropriate logic. This is necessary since we don't - * have a separate entry point. - * - * Inspired by https://github.com/actions/checkout/blob/v3.1.0/src/state-helper.ts#L56-L60 - */ - core.saveState('isPost', 'true') + if (core.getInput("connectivity-check") === "true") { + core.info("Connectivity check: tmate session created successfully") + core.info(`SSH: ${tmateSSH}`) + if (tmateWeb) { + core.info(`Web shell: ${tmateWeb}`) + } + await execShellCommand(`${tmate} kill-session`) + core.info("Connectivity check: session terminated, connectivity verified") + return + } const detached = core.getInput("detached") if (detached === "true") { @@ -219,6 +227,15 @@ export async function run() { } core.saveState('message', message) core.saveState('tmate', tmate) + + // Set the SSH command as an output so other jobs can use it + core.setOutput('ssh-command', tmateSSH) + // Extract and set the raw SSH address (without the "ssh" prefix) + core.setOutput('ssh-address', tmateSSH.replace(/^ssh /, '')) + if (tmateWeb) { + core.setOutput('web-url', tmateWeb) + } + console.log(message) return } @@ -231,7 +248,7 @@ export async function run() { if (tmateWeb) { core.info(`Web shell: ${tmateWeb}`); } - core.info(`SSH: ssh -p ${port} ${token}@${host}`); + core.info(`SSH: ${tmateSSH}`); if (tmateSSHDashI) { core.info(`or: ${tmateSSH.replace(/^ssh/, tmateSSHDashI)}`) } @@ -255,11 +272,11 @@ export async function run() { } function didTmateQuit() { - const tmateSocketPath = process.platform === "win32" ? "C:/msys64/tmp/tmate.sock" : "/tmp/tmate.sock" + const tmateSocketPath = process.platform === "win32" ? `${core.getInput("msys2-location") || "C:\\msys64"}/tmp/tmate.sock` : "/tmp/tmate.sock" return !fs.existsSync(tmateSocketPath) } function continueFileExists() { - const continuePath = process.platform === "win32" ? "C:/msys64/continue" : "/continue" + const continuePath = process.platform === "win32" ? `${core.getInput("msys2-location") || "C:\\msys64"}/continue` : "/continue" return fs.existsSync(continuePath) || fs.existsSync(path.join(process.env.GITHUB_WORKSPACE, "continue")) } diff --git a/src/index.test.js b/src/index.test.js index 45a7521a..a6b74676 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -22,9 +22,11 @@ jest.mock('./helpers', () => { __esModule: true, ...originalModule, execShellCommand: jest.fn(() => 'mocked execShellCommand'), + getValidatedEnvVars: jest.fn(originalModule.getValidatedEnvVars), + updateSshConfig: jest.fn(originalModule.updateSshConfig), }; }); -import { execShellCommand } from "./helpers" +import { execShellCommand, getValidatedEnvVars, updateSshConfig } from "./helpers" import { run } from "." describe('Tmate GitHub integration', () => { @@ -118,6 +120,37 @@ describe('Tmate GitHub integration', () => { await run() expect(execShellCommand).not.toHaveBeenNthCalledWith(1, "brew install tmate") }); + it('should create session and exit immediately in connectivity-check mode', async () => { + Object.defineProperty(process, "platform", { + value: "linux" + }) + const customConnectionString = "ssh -p2222 foobar@test.example.com" + const webUrl = "https://test.example.com" + execShellCommand.mockImplementation((cmd) => { + if (cmd.includes("tmate_web")) return Promise.resolve(webUrl) + if (cmd.includes("tmate_ssh")) return Promise.resolve(customConnectionString) + return Promise.resolve("") + }) + core.getInput.mockImplementation((name) => { + if (name === "install-dependencies") return "false" + if (name === "limit-access-to-actor") return "false" + if (name === "connectivity-check") return "true" + return "" + }) + getValidatedEnvVars.mockReturnValue(undefined) + updateSshConfig.mockResolvedValue(undefined) + + await run() + + expect(core.info).toHaveBeenCalledWith("Connectivity check: tmate session created successfully") + expect(core.info).toHaveBeenCalledWith(`SSH: ${customConnectionString}`) + expect(core.info).toHaveBeenCalledWith(`Web shell: ${webUrl}`) + expect(execShellCommand).toHaveBeenCalledWith( + expect.stringContaining("kill-session") + ) + expect(core.info).toHaveBeenCalledWith("Connectivity check: session terminated, connectivity verified") + expect(core.saveState).toHaveBeenCalledWith('isPost', 'true') + }); it('should work without any options', async () => { core.getInput.mockReturnValue("");