• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

open-turo / action-setup-tools / 12893804824

21 Jan 2025 06:50PM UTC coverage: 70.492% (-4.4%) from 74.865%
12893804824

Pull #267

github

web-flow
Merge 5b0fd519c into 1032d05da
Pull Request #267: feat: improve node version resolution

193 of 288 branches covered (67.01%)

Branch coverage included in aggregate %.

21 of 21 new or added lines in 1 file covered. (100.0%)

5 existing lines in 1 file now uncovered.

452 of 627 relevant lines covered (72.09%)

41.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.67
/node.js
1
import path from "path"
2
import assert from "assert"
3
import fsPromises from "fs/promises"
4
import fetch from "node-fetch"
5

6
import core from "@actions/core"
7
import findVersions from "find-versions"
8

9
import Tool from "./tool.js"
10
import { nodeVersions } from "./node-version-data.js"
11

12
export default class Node extends Tool {
13
    static tool = "node"
2✔
14
    static envVar = "NODENV_ROOT"
2✔
15
    static envPaths = ["bin", "shims"]
2✔
16
    static installer = "nodenv"
2✔
17

18
    constructor() {
19
        super(Node.tool)
15✔
20
    }
21

22
    async setup(desiredVersion) {
23
        const [checkVersion, isVersionOverridden] =
24
            await this.getNodeVersion(desiredVersion)
3✔
25
        if (!(await this.haveVersion(checkVersion))) {
2✔
26
            if (checkVersion) {
1!
27
                // Ensure yarn is present as well, but don't error if it breaks
28
                await this.installYarn().catch(() => {})
×
29
            }
30
            return checkVersion
1✔
31
        }
32

33
        // Check if nodenv exists and can be run, and capture the version info while
34
        // we're at it, should be pre-installed on self-hosted runners.
35
        await this.findInstaller()
1✔
36

37
        // Update nodeenv versions in case the user is requesting a node version
38
        // that did not exist when nodenenv was installed
39
        const updateVersionsCommand = `${this.installer} update-version-defs`
1✔
40
        // Remove NODENV related vars from the environment, as when running
41
        // nodenv it will pick them and try to use the specified node version
42
        // which can lead into issues if that node version does not exist
43
        // eslint-disable-next-line no-unused-vars
44
        const { NODENV_VERSION, ...envWithoutNodenv } = process.env
1✔
45
        await this.subprocessShell(updateVersionsCommand, {
1✔
46
            // Run the cmd in a tmp file so that nodenv doesn't pick any .node-version file in the repo with an unknown
47
            // node version
48
            cwd: process.env.RUNNER_TEMP,
49
            env: { ...envWithoutNodenv, ...this.getEnv() },
50
        }).catch((error) => {
51
            this.warning(
1✔
52
                `Failed to update nodenv version refs, install may fail`,
53
            )
54
            if (error.stderr) {
1!
55
                this.debug(error.stderr)
1✔
56
            }
57
        })
58

59
        // Set downstream environment variable for future steps in this Job
60
        if (isVersionOverridden) {
1!
61
            core.exportVariable("NODENV_VERSION", checkVersion)
1✔
62
        }
63

64
        // Install the desired version as it was not in the system
65
        const installCommand = `${this.installer} install -s ${checkVersion}`
1✔
66
        await this.subprocessShell(installCommand).catch(
1✔
67
            this.logAndExit(`failed to install node version ${checkVersion}`),
68
        )
69

70
        // Sanity check that the node command works and its reported version matches what we have
71
        // requested to be in place.
72
        await this.validateVersion(checkVersion)
1✔
73

74
        // Could make this conditional? But for right now we always install `yarn`
75
        await this.installYarn()
1✔
76

77
        // If we got this far, we have successfully configured node.
78
        this.info("node success!")
1✔
79
        return checkVersion
1✔
80
    }
81

82
    /**
83
     * Download Node version data. If requests fail, use a local file
84
     * @returns {Promise<void>}
85
     */
86
    async getVersionData() {
87
        const nodeVersionUrl = "https://nodejs.org/download/release/index.json"
10✔
88
        try {
10✔
89
            return await fetch(nodeVersionUrl).then((response) => {
10✔
90
                if (!response.ok) {
7✔
91
                    throw new Error(
1✔
92
                        `Failed to fetch Node version data: ${response.status} ${response.statusText}`,
93
                    )
94
                }
95
                return response.json()
6✔
96
            })
97
        } catch (e) {
98
            this.error(
4✔
99
                `Failed to fetch Node version data: ${e.message}. Returning local cache`,
100
            )
101
            return nodeVersions
4✔
102
        }
103
    }
104

105
    /**
106
     * Given an nvmrc node version spec, convert it to a SemVer node version
107
     * @param {String} fileName File where to look for the node version
108
     * @param {String} directVersion Direct node version to use instead of reading from file
109
     * @returns {Promise<string | undefined>} Parsed version
110
     */
111
    async parseNvmrcVersion(fileName, directVersion = null) {
9✔
112
        const nodeVersion = directVersion || this.getVersion(null, fileName)[0]
16✔
113
        if (!nodeVersion) {
16✔
114
            return undefined
3✔
115
        }
116

117
        // For direct versions that are complete (e.g., "20.18.1"), return as-is
118
        if (directVersion && /^\d+\.\d+\.\d+$/.test(directVersion)) {
13✔
119
            return directVersion
3✔
120
        }
121

122
        // Versions are sorted from newest to oldest
123
        const versionData = await this.getVersionData()
10✔
124
        let version
125

126
        // Handle major version numbers (e.g., "20")
127
        if (/^\d+$/.test(nodeVersion)) {
10✔
128
            const versions = versionData
6✔
129
                .filter(v => v.version.startsWith(`v${nodeVersion}.`))
1,509✔
130
                .sort((a, b) => {
131
                    const [aMaj, aMin, aPatch] = findVersions(a.version)[0].split('.').map(Number)
24✔
132
                    const [bMaj, bMin, bPatch] = findVersions(b.version)[0].split('.').map(Number)
24✔
133
                    if (aMaj !== bMaj) return bMaj - aMaj
24!
134
                    if (aMin !== bMin) return bMin - aMin
24✔
135
                    return bPatch - aPatch
9✔
136
                })
137

138
            if (versions.length === 0) {
6✔
139
                throw new Error(`No stable version found matching Node.js ${nodeVersion}.x`)
2✔
140
            }
141
            version = versions[0].version
4✔
142
        }
143
        // Handle LTS versions
144
        else if (/^lts\/.*/i.test(nodeVersion)) {
4✔
145
            if (nodeVersion === "lts/*") {
2✔
146
                // We just want the latest LTS
147
                version = versionData.find((v) => v.lts !== false)?.version
1✔
148
            } else {
149
                version = versionData.find(
1✔
150
                    (v) =>
151
                        nodeVersion.substring(4).toLowerCase() ===
7✔
152
                        (v.lts || "").toLowerCase(),
11✔
153
                )?.version
154
            }
155
        }
156
        // Handle "node" keyword
157
        else if (nodeVersion === "node") {
2!
158
            // We need the latest version
UNCOV
159
            version = versionData[0].version
×
160
        }
161
        // Handle specific or partial versions
162
        else {
163
            // This could be a full or a partial version, so use partial matching
164
            version = versionData.find((v) =>
2✔
165
                v.version.startsWith(`v${nodeVersion}`),
11✔
166
            )?.version
167
        }
168

169
        if (version !== undefined) {
8✔
170
            return findVersions(version)[0]
7✔
171
        }
172
        throw new Error(`Could not parse Node version "${nodeVersion}"`)
1✔
173
    }
174

175
    /**
176
     * Return a [version, override] pair where version is the SemVer string
177
     * and the override is a boolean indicating the version must be manually set
178
     * for installs.
179
     *
180
     * If a desired version is specified the function returns this one. If not,
181
     * it will look for a .node-version or a .nvmrc file (in this order) to extract
182
     * the desired node version
183
     *
184
     * It is expected that these files follow the NVM spec: https://github.com/nvm-sh/nvm#nvmrc
185
     * @param [desiredVersion] Desired node version
186
     * @returns {Promise<[string | null, boolean | null]>} Resolved node version
187
     */
188
    async getNodeVersion(desiredVersion) {
189
        // If we're given a version, parse it
190
        if (desiredVersion) {
14✔
191
            const version = await this.parseNvmrcVersion(".node-version", desiredVersion)
7✔
192
            return [version, true]
5✔
193
        }
194

195
        // If .node-version is present, it's the one we want, and it's not
196
        // considered an override
197
        const nodeVersion = await this.parseNvmrcVersion(".node-version")
7✔
198
        if (nodeVersion) {
6✔
199
            return [nodeVersion, false]
4✔
200
        }
201

202
        // If .nvmrc is present, we fall back to it
203
        const nvmrcVersion = await this.parseNvmrcVersion(".nvmrc")
2✔
204
        if (nvmrcVersion) {
2✔
205
            // In this case we want to override the version, as nodenv is not aware of this file
206
            // and we want to use it
207
            return [nvmrcVersion, true]
1✔
208
        }
209

210
        // Otherwise we have no node
211
        return [null, null]
1✔
212
    }
213

214
    /**
215
     * Download and configures nodenv.
216
     *
217
     * @param  {string} root - Directory to install nodenv into (NODENV_ROOT).
218
     * @return {string} The value of NODENV_ROOT.
219
     */
220
    async install(root) {
221
        assert(root, "root is required")
2✔
222
        // Build our URLs
223
        const gh = `https://${process.env.GITHUB_SERVER || "github.com"}/nodenv`
2✔
224
        const url = {}
2✔
225
        url.nodenv = `${gh}/nodenv/archive/refs/heads/master.tar.gz`
2✔
226
        url.nodebulid = `${gh}/node-build/archive/refs/heads/master.tar.gz`
2✔
227
        url.nodedoctor = `${gh}/nodenv-installer/raw/master/bin/nodenv-doctor`
2✔
228

229
        root = await this.downloadTool(url.nodenv, { dest: root, strip: 1 })
2✔
230
        this.info(`Downloaded nodenv to ${root}`)
2✔
231

232
        await this.downloadTool(url.nodebulid, path.join(root, "plugins"))
2✔
233
        this.info(`Downloaded node-build to ${root}/plugins`)
2✔
234

235
        const doctor = await this.downloadTool(url.nodedoctor)
2✔
236
        this.info(`Downloaded node-doctor to ${doctor}`)
2✔
237

238
        // Create environment for running node-doctor
239
        await this.setEnv(root)
2✔
240
        await this.subprocessShell(`bash ${doctor}`)
2✔
241

242
        // Asynchronously clean up the downloaded doctor script
243
        fsPromises.rm(doctor, { recursive: true }).catch(() => {})
2✔
244

245
        return root
2✔
246
    }
247

248
    /**
249
     * Run `npm install -g yarn` and `nodenv rehash` to ensure `yarn` is on the CLI.
250
     */
251
    async installYarn() {
252
        // Check for an existing version
253
        let yarnVersion = await this.version("yarn --version", {
1✔
254
            soft: true,
255
        }).catch(() => {})
256
        if (yarnVersion) {
1!
257
            this.debug(`yarn is already installed (${yarnVersion})`)
1✔
258
            return
1✔
259
        }
260

261
        // Installing yarn with npm, which if this errors means ... things are
262
        // badly broken?
UNCOV
263
        this.info("Installing yarn")
×
UNCOV
264
        await this.subprocessShell("npm install -g yarn")
×
265

266
        // Just run `nodenv rehash` always and ignore errors because we might be
267
        // in a setup-node environment that doesn't have nodenv
UNCOV
268
        this.info("Rehashing node shims")
×
UNCOV
269
        await this.subprocessShell("nodenv rehash").catch(() => {})
×
270
    }
271
}
272

273
Node.register()
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc