Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 108 additions & 108 deletions dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"build:bundle": "esbuild src/index.ts --bundle --platform=node --target=node24 --format=cjs --minify --outfile=dist/index.js",
"build": "pnpm run build:bundle",
"start": "pnpm run build && sh ./run.sh",
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/cache-restore/*.test.mjs src/install-runtime/*.test.mjs src/pnpm-install/*.test.mjs src/pnpm-commands.test.mjs"
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/cache-restore/*.test.mjs src/install-runtime/*.test.mjs src/pnpm-install/*.test.mjs src/*.test.mjs"
},
"dependencies": {
"@actions/cache": "^6.2.0",
Expand Down
85 changes: 85 additions & 0 deletions src/flush-and-exit.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { build } from 'esbuild'

const { outputFiles } = await build({
entryPoints: [fileURLToPath(new URL('./flush-and-exit.ts', import.meta.url))],
bundle: true,
platform: 'node',
format: 'cjs',
write: false,
})

const CHUNK = 'a'.repeat(400_000)

// A pipe holds 64 KiB, so a child writing more than that only keeps its tail
// when the exit waits for the drain.
function runChild(t, source) {
const dir = mkdtempSync(path.join(tmpdir(), 'flush-and-exit-'))
t.after(() => rmSync(dir, { recursive: true, force: true }))
writeFileSync(path.join(dir, 'flush-and-exit.cjs'), outputFiles[0].text)
const entry = path.join(dir, 'child.cjs')
writeFileSync(entry, source)
return new Promise((resolve, reject) => {
const cp = spawn(process.execPath, [entry], { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
cp.stdout.setEncoding('utf8')
cp.stderr.setEncoding('utf8')
cp.stdout.pause()
cp.stderr.pause()
setTimeout(() => {
cp.stdout.resume()
cp.stderr.resume()
}, 300)
cp.stdout.on('data', chunk => {
stdout += chunk
})
cp.stderr.on('data', chunk => {
stderr += chunk
})
cp.on('error', reject)
cp.on('close', (code, signal) => resolve({ stdout, stderr, code, signal }))
})
}

test('writes queued on both pipes reach the parent before the exit', async t => {
const { stdout, stderr, code } = await runChild(t, `
const { flushAndExit } = require('./flush-and-exit.cjs')
process.stdout.write(${JSON.stringify(CHUNK)})
process.stderr.write(${JSON.stringify(CHUNK)})
process.stdout.write('::error::install failed\\n')
flushAndExit()
`)

assert.equal(stdout, `${CHUNK}::error::install failed\n`)
assert.equal(stderr, CHUNK)
assert.equal(code, 0)
})

test('keeps the exit code the failure handler set', async t => {
const { stdout, code } = await runChild(t, `
const { flushAndExit } = require('./flush-and-exit.cjs')
process.exitCode = 1
process.stdout.write(${JSON.stringify(CHUNK)})
flushAndExit()
`)

assert.equal(stdout, CHUNK)
assert.equal(code, 1)
})

test('exits while a handle keeps the loop alive', async t => {
const { code } = await runChild(t, `
const { flushAndExit } = require('./flush-and-exit.cjs')
setInterval(() => {}, 1000)
flushAndExit()
`)

assert.equal(code, 0)
})
17 changes: 17 additions & 0 deletions src/flush-and-exit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Exits the process once stdout and stderr have drained.
Comment on lines +1 to +2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Comment narrates exit behavior The opening sentence restates what flushAndExit does, which its name and implementation already make clear. The repository requires comments not to narrate code; please satisfy that requirement before merging.

Context Used: Comments and docs in code are suspicious. Is test coverage not sufficient the reason why a comment was added? Comments should not replace tests. Comments should also not narrate code. Is the code hard to understand? Then it should be refactored to ma... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

*
* Both streams are pipes under the Actions runner, where writes are
* asynchronous, so a bare `process.exit()` discards whatever is still queued:
* the workflow command `setFailed` wrote and the error `console.error` printed
* reach the runner only after the drain.
*/
export function flushAndExit() {
let pending = 2
const exitWhenDrained = () => {
pending -= 1
if (pending === 0) process.exit()
}
process.stdout.write('', exitWhenDrained)
process.stderr.write('', exitWhenDrained)
}
13 changes: 9 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { setFailed, saveState, getState } from '@actions/core'
import restoreCache, { finalizeCache } from './cache-restore'
import saveCache from './cache-save'
import { flushAndExit } from './flush-and-exit'
import getInputs, { Inputs } from './inputs'
import installPnpm from './install-pnpm'
import {
Expand Down Expand Up @@ -90,7 +91,11 @@ async function runPost() {
await saveCache(inputs)
}

main().catch(error => {
console.error(error)
setFailed(error)
})
// A failed cache download leaves its remaining block requests open, and they
// keep node alive after `main` settles.
main()
.catch(error => {
console.error(error)
setFailed(error)
})
.finally(flushAndExit)
5 changes: 3 additions & 2 deletions src/install-runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,18 +213,19 @@ function runPnpmForOutput(binDest: string, args: string[]): Promise<string> {
return new Promise<string>((resolve, reject) => {
const cp = spawn(pnpmBin, args, {
stdio: ['ignore', 'pipe', 'inherit'],
timeout: 60_000,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Timeout path lacks coverage The runtime tests only exercise resolveRuntimeRequests, so they do not check whether this new timeout falls back or produces the expected warning. A regression in the hang-prevention path could pass the test suite unnoticed. Add a child-process test for the timeout and signal path.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

covered in 6ab309b by src/install-runtime/installed-versions.test.mjs, which drives getInstalledRuntimeVersions against a fake pnpm on binDest: one that signals itself, one that exits non-zero, and one that prints a listing. the first pins the signal in the warning and fails without this change, since the old message always read code null.

a timeout surfaces through that same path, because node's spawn timeout kills the child with killSignal and the close handler then reports the signal. testing the 60 second wait itself would mean either a 60 second test or a timeout seam that exists only for the test, so i left it at the observable it produces.

})
let stdout = ''
cp.stdout.setEncoding('utf8')
cp.stdout.on('data', chunk => {
stdout += chunk
})
cp.on('error', reject)
cp.on('close', code => {
cp.on('close', (code, signal) => {
if (code === 0) {
resolve(stdout)
} else {
reject(new Error(`pnpm ${args.join(' ')} exited with code ${code}`))
reject(new Error(`pnpm ${args.join(' ')} exited with ${signal ?? `code ${code}`}`))
}
})
})
Expand Down
75 changes: 75 additions & 0 deletions src/install-runtime/installed-versions.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { build } from 'esbuild'

const { outputFiles } = await build({
entryPoints: [fileURLToPath(new URL('./index.ts', import.meta.url))],
bundle: true,
platform: 'node',
format: 'cjs',
write: false,
})
const bundledModule = { exports: {} }
new Function('require', 'module', 'exports', outputFiles[0].text)(
createRequire(import.meta.url), bundledModule, bundledModule.exports,
)
const { getInstalledRuntimeVersions } = bundledModule.exports

function fakePnpm(t, body) {
const binDest = mkdtempSync(path.join(tmpdir(), 'pnpm-installed-versions-'))
t.after(() => rmSync(binDest, { recursive: true, force: true }))
const bin = path.join(binDest, process.platform === 'win32' ? 'pnpm.exe' : 'pnpm')
writeFileSync(bin, `#!/bin/sh\n${body}\n`)
chmodSync(bin, 0o755)
return binDest
}

// The runner reports through stdout too, so the capture has to pass every
// write along to it.
function captureStdout(t) {
const written = []
const original = process.stdout.write
process.stdout.write = function (chunk, ...rest) {
written.push(String(chunk))
return original.call(this, chunk, ...rest)
}
t.after(() => {
process.stdout.write = original
})
return written
}

test('reports the signal when the listing is terminated', { skip: process.platform === 'win32' }, async t => {
const binDest = fakePnpm(t, 'kill -TERM $$\nsleep 5')
const logged = captureStdout(t)

const versions = await getInstalledRuntimeVersions(['node'], binDest)

assert.deepEqual([...versions], [])
assert.match(logged.join(''), /::warning::Unable to determine the installed runtime versions: pnpm list --global --json --depth 0 exited with SIGTERM/)
})

test('reports the exit code when the listing fails', { skip: process.platform === 'win32' }, async t => {
const binDest = fakePnpm(t, 'exit 3')
const logged = captureStdout(t)

const versions = await getInstalledRuntimeVersions(['node'], binDest)

assert.deepEqual([...versions], [])
assert.match(logged.join(''), /exited with code 3/)
})

test('reads the version out of a successful listing', { skip: process.platform === 'win32' }, async t => {
const listing = JSON.stringify([{ dependencies: { node: { version: '24.9.0' } } }])
const binDest = fakePnpm(t, `cat <<'JSON'\n${listing}\nJSON`)
captureStdout(t)

const versions = await getInstalledRuntimeVersions(['node'], binDest)

assert.deepEqual([...versions], [['node', '24.9.0']])
})