From 3c0a1815318477f4f52d4d54432e0f5fe586fbfe Mon Sep 17 00:00:00 2001 From: Jamie Nelson Date: Sun, 11 Oct 2026 11:29:20 +1100 Subject: [PATCH 1/6] fix: preserve empty metadata descriptions and cover reviewed errors --- __tests__/action.test.ts | 40 ++++++++--- __tests__/integration-readme-contract.test.ts | 51 ++++++++++++++ __tests__/save.test.ts | 67 +++++++++++++++++++ src/Action.ts | 3 +- src/sections/update-usage.ts | 6 +- 5 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 __tests__/save.test.ts diff --git a/__tests__/action.test.ts b/__tests__/action.test.ts index f81848ca..fe6d94a0 100644 --- a/__tests__/action.test.ts +++ b/__tests__/action.test.ts @@ -137,13 +137,27 @@ describe('Action', () => { }); }); - describe('nested metadata validation', () => { + describe('metadata validation', () => { const metadata = { name: 'Fixture action', description: 'Fixture description', runs: { using: 'node24', main: 'index.js' }, }; + it.each(['node24', 42, true])('rejects scalar runs %j with metadata context', async (runs) => { + vi.mocked(fs.readFileSync).mockReturnValueOnce(YAML.stringify({ ...metadata, runs })); + const { default: Action } = await import('../src/Action.js'); + + expect(() => new Action(actTestYmlPath, mockLogTask)).toThrow( + expect.objectContaining({ + message: expect.stringContaining(`Failed to load ${actTestYmlPath}`), + cause: expect.objectContaining({ + message: `Invalid action metadata syntax in ${actTestYmlPath}.`, + }), + }), + ); + }); + it.each([ { inputs: { option: null } }, { inputs: { option: 42 } }, @@ -154,19 +168,23 @@ describe('Action', () => { ])('rejects malformed nested metadata through YAML loading: %j', async (nested) => { vi.mocked(fs.readFileSync).mockReturnValueOnce(YAML.stringify({ ...metadata, ...nested })); const { default: Action } = await import('../src/Action.js'); - expect(() => new Action(actTestYmlPath, mockLogTask)).toThrow('Invalid action metadata syntax'); + expect(() => new Action(actTestYmlPath, mockLogTask)).toThrow( + 'Invalid action metadata syntax', + ); }); it('accepts scalar defaults and nullish descriptions through YAML loading', async () => { - vi.mocked(fs.readFileSync).mockReturnValueOnce(YAML.stringify({ - ...metadata, - inputs: { - enabled: { description: 'Enabled', default: false, required: false }, - retries: { description: 'Retries', default: 3 }, - optional: { default: null, description: null }, - }, - outputs: { result: { description: null } }, - })); + vi.mocked(fs.readFileSync).mockReturnValueOnce( + YAML.stringify({ + ...metadata, + inputs: { + enabled: { description: 'Enabled', default: false, required: false }, + retries: { description: 'Retries', default: 3 }, + optional: { default: null, description: null }, + }, + outputs: { result: { description: null } }, + }), + ); const { default: Action } = await import('../src/Action.js'); const action = new Action(actTestYmlPath, mockLogTask); expect(action.inputs?.enabled?.default).toBe(false); diff --git a/__tests__/integration-readme-contract.test.ts b/__tests__/integration-readme-contract.test.ts index 54162947..765ef248 100644 --- a/__tests__/integration-readme-contract.test.ts +++ b/__tests__/integration-readme-contract.test.ts @@ -242,6 +242,57 @@ describe('README generation contract', () => { expect(sectionBody(await generate(), 'outputs')).toContain(key); }); + it.each([undefined, '', 'null'])( + 'renders empty descriptions for YAML descriptions written as %j', + async (description) => { + const descriptionLine = + description === undefined ? '' : ` description: ${description}\n`; + fs.writeFileSync( + actionPath, + `name: Nullish description action +description: Metadata with empty descriptions +inputs: + option: +${descriptionLine} default: false +outputs: + result: +${descriptionLine} value: output +runs: + using: node24 + main: index.js +`, + ); + const before = 'Keep this prefix.\n'; + const after = '\nKeep this suffix.\n'; + fs.writeFileSync(readmePath, `${before}${README_WITH_MARKERS}${after}`); + + const readme = await generate(false); + + for (const section of ['inputs', 'outputs']) { + const rows = sectionBody(readme, section) + .split('\n') + .filter((line) => line.startsWith('|')); + const cells = rows[2] + .split('|') + .slice(1, -1) + .map((cell) => cell.trim()); + expect(cells[0]).toBe( + section === 'inputs' ? 'option' : 'result', + ); + expect(cells[1]).toBe(''); + } + expect(sectionBody(readme, 'inputs')).toContain('false'); + expect(sectionBody(readme, 'usage')).toContain("option: ''"); + expect( + sectionBody(readme, 'usage') + .match(/^\s*# Description:.*$/gmu) + ?.map((line) => line.trim()), + ).toEqual(['# Description:']); + expect(readme.startsWith(before)).toBe(true); + expect(readme.endsWith(after)).toBe(true); + }, + ); + it('ignores leading blank lines before selecting input and output table paragraphs', async () => { const action = ACTION_YML.replace( 'description: A plain input with a default', diff --git a/__tests__/save.test.ts b/__tests__/save.test.ts new file mode 100644 index 00000000..6dc494c4 --- /dev/null +++ b/__tests__/save.test.ts @@ -0,0 +1,67 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; + +import { GHActionDocsConfig } from '../src/config.js'; +import type Inputs from '../src/inputs.js'; +import LogTask from '../src/logtask/index.js'; +import save from '../src/save.js'; + +function inputsWithSave(saveEnabled: boolean, configPath: string): Inputs { + const config = { + owner: 'fixture-owner', + repo: 'fixture-repo', + save: saveEnabled, + }; + + return { + config: { get: vi.fn(() => config) }, + configPath, + } as unknown as Inputs; +} + +describe('save', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('persists the resolved config when save is enabled', async () => { + const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'ghadocs-save-')); + const configPath = path.join(tempDirectory, '.ghadocs.json'); + + try { + await save(inputsWithSave(true, configPath), new LogTask('save-test')); + + expect(JSON.parse(fs.readFileSync(configPath, 'utf8'))).toMatchObject({ + owner: 'fixture-owner', + repo: 'fixture-repo', + }); + } finally { + fs.rmSync(tempDirectory, { recursive: true, force: true }); + } + }); + + it('rejects with the original persistence error when save is enabled', async () => { + const failure = new Error('config persistence failed'); + const persist = vi.spyOn(GHActionDocsConfig.prototype, 'save').mockRejectedValue(failure); + const configPath = path.join(os.tmpdir(), 'ghadocs-save-failure', '.ghadocs.json'); + + await expect(save(inputsWithSave(true, configPath), new LogTask('save-test'))).rejects.toBe( + failure, + ); + expect(persist).toHaveBeenCalledWith(configPath); + }); + + it('does not attempt persistence when save is disabled', async () => { + const persist = vi.spyOn(GHActionDocsConfig.prototype, 'save'); + + await save( + inputsWithSave(false, path.join(os.tmpdir(), 'ghadocs-save-disabled', '.ghadocs.json')), + new LogTask('save-test'), + ); + + expect(persist).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Action.ts b/src/Action.ts index c54d3647..31aceb4d 100644 --- a/src/Action.ts +++ b/src/Action.ts @@ -135,8 +135,7 @@ export default class Action implements ActionYaml { (isRecord(value) && Object.values(value).every( (entry) => - isRecord(entry) && - (entry.description == null || typeof entry.description === 'string'), + isRecord(entry) && (entry.description == null || typeof entry.description === 'string'), )); if (!validDescriptions(record.inputs) || !validDescriptions(record.outputs)) { return false; diff --git a/src/sections/update-usage.ts b/src/sections/update-usage.ts index a2130d5e..5ab4fc8a 100644 --- a/src/sections/update-usage.ts +++ b/src/sections/update-usage.ts @@ -43,7 +43,11 @@ export default async function updateUsage( for (const key of Object.keys(inp)) { const input = inp[key]; if (input !== undefined) { - descriptionPromises[key] = wrapDescription(`Description: ${input.description}`, [], indent); + descriptionPromises[key] = wrapDescription( + `Description: ${input.description ?? ''}`, + [], + indent, + ); } } From a5e2d31f836cd779be511973c0138dd1a9030a0b Mon Sep 17 00:00:00 2001 From: Jamie Nelson Date: Sun, 11 Oct 2026 11:44:30 +1100 Subject: [PATCH 2/6] fix: verify empty metadata descriptions through the contract checker --- __tests__/integration-readme-contract.test.ts | 43 ++++++++++++++++++- scripts/verify-readme-contract.mjs | 2 +- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/__tests__/integration-readme-contract.test.ts b/__tests__/integration-readme-contract.test.ts index 765ef248..a9e1f176 100644 --- a/__tests__/integration-readme-contract.test.ts +++ b/__tests__/integration-readme-contract.test.ts @@ -243,7 +243,7 @@ describe('README generation contract', () => { }); it.each([undefined, '', 'null'])( - 'renders empty descriptions for YAML descriptions written as %j', + 'renders and verifies empty descriptions for YAML descriptions written as %j', async (description) => { const descriptionLine = description === undefined ? '' : ` description: ${description}\n`; @@ -264,7 +264,27 @@ runs: ); const before = 'Keep this prefix.\n'; const after = '\nKeep this suffix.\n'; - fs.writeFileSync(readmePath, `${before}${README_WITH_MARKERS}${after}`); + const originalReadme = `${before}${[ + '', + '', + '', + '', + '', + '', + '', + ].join('\n')}${after}`; + fs.writeFileSync(readmePath, originalReadme); + fs.writeFileSync( + path.join(tempDir, '.ghadocs.json'), + JSON.stringify({ + owner: 'contract-owner', + repo: 'contract-repo', + paths: { action: actionPath, readme: readmePath }, + sections: ['usage', 'inputs', 'outputs'], + prettier: false, + versioning: { source: 'explicit', override: '1.2.3', prefix: 'v', badge: false }, + }), + ); const readme = await generate(false); @@ -290,6 +310,25 @@ runs: ).toEqual(['# Description:']); expect(readme.startsWith(before)).toBe(true); expect(readme.endsWith(after)).toBe(true); + + const verify = (candidate: string) => { + fs.writeFileSync(readmePath, candidate); + return spawnSync( + process.execPath, + [VERIFIER_SCRIPT, actionPath, readmePath, path.join(tempDir, 'README.original.md')], + { cwd: tempDir, encoding: 'utf8' }, + ); + }; + fs.writeFileSync(path.join(tempDir, 'README.original.md'), originalReadme); + const valid = verify(readme); + expect(valid.status, `${valid.stdout}\n${valid.stderr}`).toBe(0); + expect(valid.stdout).toContain('All contract checks passed'); + + const staleDescription = readme.replace(' # Description:', ' # Description: stale'); + expect(staleDescription).not.toBe(readme); + const stale = verify(staleDescription); + expect(stale.status).not.toBe(0); + expect(stale.stdout).toContain('usage comments do not exactly project action.yml'); }, ); diff --git a/scripts/verify-readme-contract.mjs b/scripts/verify-readme-contract.mjs index 9b3016f8..477a25e2 100644 --- a/scripts/verify-readme-contract.mjs +++ b/scripts/verify-readme-contract.mjs @@ -599,7 +599,7 @@ if (usage === null) { .join('\n'); const generatedUsageComments = async (declaration) => { - const formatted = await format(`Description: ${declaration?.description}`, { + const formatted = await format(`Description: ${declaration?.description ?? ''}`, { semi: false, parser: 'markdown', proseWrap: 'always', From beb2180abd2d9b225af0b8db0a37201c2616021a Mon Sep 17 00:00:00 2001 From: Jamie Nelson Date: Sun, 11 Oct 2026 12:19:53 +1100 Subject: [PATCH 3/6] fix: verify declared defaults using their rendered projection Preserve absent, empty, null, and multiline default distinctions in the exact README verifier. Exercise real YAML generation under both formatting settings and require corrupted default projections to fail. --- __tests__/integration-readme-contract.test.ts | 129 ++++++++++++++++++ scripts/verify-readme-contract.mjs | 17 ++- 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/__tests__/integration-readme-contract.test.ts b/__tests__/integration-readme-contract.test.ts index a9e1f176..08bf6154 100644 --- a/__tests__/integration-readme-contract.test.ts +++ b/__tests__/integration-readme-contract.test.ts @@ -332,6 +332,135 @@ runs: }, ); + it.each([false, true])( + 'renders and verifies declared default values with pretty %s', + async (pretty) => { + fs.writeFileSync( + actionPath, + `name: Declared defaults action +description: Metadata with supported default values +inputs: + absent_default: + description: No default + empty_default: + description: Empty default + default: '' + null_default: + description: Null default + default: null + false_default: + description: False default + default: false + zero_default: + description: Zero default + default: 0 + multiline_default: + description: Multiline default + default: |- + first + second + blank_line_default: + description: Blank line default + default: |- + before + + after +runs: + using: node24 + main: index.js +`, + ); + const originalReadme = [ + 'Keep this prefix. ', + '', + '', + '', + '', + 'Keep this suffix. ', + '', + ].join('\n'); + const originalReadmePath = path.join(tempDir, 'README.original.md'); + fs.writeFileSync(readmePath, originalReadme); + fs.writeFileSync(originalReadmePath, originalReadme); + fs.writeFileSync( + path.join(tempDir, '.ghadocs.json'), + JSON.stringify({ + owner: 'contract-owner', + repo: 'contract-repo', + paths: { action: actionPath, readme: readmePath }, + sections: ['usage', 'inputs'], + prettier: pretty, + versioning: { source: 'explicit', override: '1.2.3', prefix: 'v', badge: false }, + }), + ); + + const readme = await generate(pretty); + const usage = sectionBody(readme, 'usage'); + expect( + usage + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('# Default:')), + ).toEqual([ + '# Default:', + '# Default: null', + '# Default: false', + '# Default: 0', + '# Default: first', + '# Default: before', + ]); + expect(usage).toContain(' # Default: first\n # second'); + expect(usage).toContain(' # Default: before\n #\n # after'); + const defaultCells = sectionBody(readme, 'inputs') + .split('\n') + .filter((line) => line.startsWith('|')) + .slice(2) + .map((line) => line.split('|')[3].trim()); + expect(defaultCells).toEqual([ + '', + '', + 'null', + 'false', + '0', + 'first
second
', + 'before

after
', + ]); + + const verify = (candidate: string) => { + fs.writeFileSync(readmePath, candidate); + return spawnSync( + process.execPath, + [VERIFIER_SCRIPT, actionPath, readmePath, originalReadmePath], + { cwd: tempDir, encoding: 'utf8' }, + ); + }; + const valid = verify(readme); + expect(valid.status, `${valid.stdout}\n${valid.stderr}`).toBe(0); + expect(valid.stdout).toContain('All contract checks passed'); + + const corruptions = [ + { + readme: readme.replace(' # Default:\n', ' # Default: stale\n'), + diagnostic: 'usage comments do not exactly project action.yml: empty_default', + }, + { + readme: readme.replace('null', ''), + diagnostic: '`null_default` default in the inputs table does not match action.yml', + }, + { + readme: readme.replace(' # second', ' # second'), + diagnostic: 'usage comments do not exactly project action.yml: multiline_default', + }, + ]; + for (const corruption of corruptions) { + expect(corruption.readme).not.toBe(readme); + const invalid = verify(corruption.readme); + expect(invalid.status).not.toBe(0); + expect(invalid.stdout).toContain(corruption.diagnostic); + } + }, + ); + it('ignores leading blank lines before selecting input and output table paragraphs', async () => { const action = ACTION_YML.replace( 'description: A plain input with a default', diff --git a/scripts/verify-readme-contract.mjs b/scripts/verify-readme-contract.mjs index 477a25e2..89e6d49e 100644 --- a/scripts/verify-readme-contract.mjs +++ b/scripts/verify-readme-contract.mjs @@ -607,8 +607,12 @@ if (usage === null) { }); const lines = formatted.split('\n'); if (declaration?.default !== undefined) { - const defaultLine = `Default: ${declaration.default}`; - lines.push(prettierEnabled ? defaultLine.trimEnd() : defaultLine); + const label = 'Default: '; + const [first, ...continuations] = String(declaration.default).split(/\r\n|\n|\r/); + lines.push(`${label}${first}`.trimEnd()); + for (const line of continuations) { + lines.push(`${' '.repeat(label.length)}${line}`.trimEnd()); + } } return lines.join('\n'); }; @@ -737,9 +741,12 @@ await tableSection('inputs', inputs, (declaration) => [ { label: 'description', expected: firstParagraph(declaration?.description), markdown: true }, { label: 'default', - // An empty-string default is indistinguishable from no default once it is - // in a table cell, so it is not asserted. `false` and `0` are. - expected: declaration?.default === undefined || declaration.default === '' ? '' : declaration.default, + // Missing and empty-string defaults have empty cells. Every other declared + // value is rendered as text, including `false`, `0`, and `null`. + expected: + declaration?.default === undefined || declaration.default === '' + ? '' + : String(declaration.default), code: 'html', }, // The generator emits the flag emphasised — `update-inputs.ts` writes From 5f92784434cf65edc441ebf885ada07a134ffbc3 Mon Sep 17 00:00:00 2001 From: Jamie Nelson Date: Sun, 11 Oct 2026 12:20:11 +1100 Subject: [PATCH 4/6] fix: load native ESM configuration and verify fresh package entry points Access nconf through its native default export and preserve configuration precedence. Build before native CLI and library contract tests, retain active coverage output, and run the fresh package suite before external integrations. Capture the complete README when native import resolves so a pending write cannot pass the completion assertion by finishing during process shutdown. --- .github/workflows/integration-test.yml | 4 +- __tests__/integration-bundled-binary.test.ts | 288 ++++++++++++------- package.json | 2 +- src/inputs.ts | 8 +- 4 files changed, 186 insertions(+), 116 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index ef387c86..cdddc0dc 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -67,9 +67,9 @@ jobs: # step against the runner's actual npm version. run-install: false - - name: Build action + - name: Build and verify packaged entry points working-directory: action-under-test - run: npm run build + run: npm run test -- --run __tests__/integration-bundled-binary.test.ts - name: Checkout target repository (${{ matrix.repo }}) uses: actions/checkout@v7.0.1 diff --git a/__tests__/integration-bundled-binary.test.ts b/__tests__/integration-bundled-binary.test.ts index 9009c7e3..d39f65ce 100644 --- a/__tests__/integration-bundled-binary.test.ts +++ b/__tests__/integration-bundled-binary.test.ts @@ -1,139 +1,209 @@ -/** - * Integration test that validates the bundled dist/bin/index.js binary - * is self-contained and does not require external node_modules at runtime. - * - * REGRESSION TEST FOR: ERR_MODULE_NOT_FOUND: Cannot find package 'prettier' - * - * ROOT CAUSE: - * `prettier` was listed in the `external` array in scripts/esbuild.mjs, which - * excluded it from the bundle. When the action runs in a user's workflow, only - * `dist/` is present — there is no `node_modules/prettier` to resolve. - * - * THE FIX: - * Removed `prettier` from the `external` array so it gets bundled into - * dist/bin/index.js. Since prettier is a declared `dependency` (not - * devDependency) in package.json, bundling it is correct. - * - * WHAT THIS TEST VALIDATES: - * - The dist/bin/index.js binary runs successfully in an isolated temporary - * directory that has no node_modules directory - * - No ERR_MODULE_NOT_FOUND error for 'prettier' or any other bundled dep - * - The binary produces valid README output when given a valid action.yml - */ -import { execSync } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test'; +import YAML from 'yaml'; -describe('Integration Test - Bundled Binary Self-Containment', () => { - let tempDir: string; - const binaryPath = path.resolve(import.meta.dirname, '../dist/bin/index.js'); +const repositoryDir = path.resolve(import.meta.dirname, '..'); +const packageManifest = JSON.parse( + fs.readFileSync(path.join(repositoryDir, 'package.json'), 'utf8'), +) as { name: string; dependencies: Record }; - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gha-bundled-binary-test-')); - }); - - afterEach(() => { - if (tempDir && fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }); - - it('dist/bin/index.js exists and is executable', () => { - expect(fs.existsSync(binaryPath), `dist/bin/index.js not found at ${binaryPath}`).toBe(true); - const stat = fs.statSync(binaryPath); - // Check that it's a file with non-zero size (bundled with prettier it should be >20MB) - expect(stat.size).toBeGreaterThan(1024 * 1024); // at least 1MB - }); - - it('binary runs without ERR_MODULE_NOT_FOUND for prettier when no node_modules present', () => { - // Set up a minimal action.yml and README.md in tempDir (no node_modules) - const actionYml = `name: Test Action -description: A test action for bundled binary validation +const actionYml = `name: Test Action +description: A test action for packaged generation +branding: + icon: activity + color: green inputs: my-input: description: A test input required: false default: hello -outputs: - my-output: - description: A test output runs: - using: node20 + using: node24 main: index.js `; - fs.writeFileSync(path.join(tempDir, 'action.yml'), actionYml); - const readmeMd = `# Test Action - - - +const originalReadme = `# Hand-written heading +stale-description +An unformatted paragraph between sections. + +stale-usage - - + +stale-branding + - - +Hand-written footer. `; - const readmePath = path.join(tempDir, 'README.md'); - fs.writeFileSync(readmePath, readmeMd); - - // Copy the binary into tempDir so Node.js resolves modules from tempDir, - // not from the project root (which has node_modules/prettier). This accurately - // simulates the GitHub Actions runner where only dist/ is available without - // an adjacent node_modules directory. - const isolatedBinaryPath = path.join(tempDir, 'index.js'); - fs.copyFileSync(binaryPath, isolatedBinaryPath); - fs.chmodSync(isolatedBinaryPath, 0o755); - - // Confirm no node_modules are present in the isolated directory - expect(fs.existsSync(path.join(tempDir, 'node_modules'))).toBe(false); - - // Run the binary from tempDir — no node_modules anywhere in the hierarchy above tempDir - let stderr = ''; - let exitCode = 0; - - try { - execSync( - `node "${isolatedBinaryPath}" --owner testowner --repo testrepo` + - ` --paths:action "${path.join(tempDir, 'action.yml')}"` + - ` --paths:readme "${readmePath}"`, - { - cwd: tempDir, - encoding: 'utf8', - env: { - ...process.env, - // Clear GitHub Actions env vars to avoid side effects - GITHUB_ACTIONS: undefined, - GITHUB_REPOSITORY: undefined, - INPUT_OWNER: undefined, - INPUT_REPO: undefined, - }, - }, - ); - } catch (error: unknown) { - const execError = error as { stdout?: string; stderr?: string; status?: number }; - stderr = execError.stderr ?? ''; - exitCode = execError.status ?? 1; + +describe('Packaged entry points', () => { + let tempDir: string; + let readmePath: string; + let isolatedBinaryPath: string; + + beforeAll(() => { + const build = spawnSync('npm', ['run', 'build'], { + cwd: repositoryDir, + encoding: 'utf8', + timeout: 60_000, + }); + expect(build.status, build.error?.message ?? `${build.stdout}\n${build.stderr}`).toBe(0); + }, 65_000); + + function writeConfig(overrides: Record = {}) { + fs.writeFileSync( + path.join(tempDir, '.ghadocs.json'), + JSON.stringify({ + owner: 'testowner', + repo: 'testrepo', + paths: { action: 'action.yml', readme: 'README.md' }, + sections: ['description', 'usage'], + pretty: true, + versioning: { enabled: true, source: 'explicit', override: '1.2.3', prefix: 'v' }, + ...overrides, + }), + ); + } + + function runNode(args: string[]) { + return spawnSync(process.execPath, args, { + cwd: tempDir, + encoding: 'utf8', + timeout: 30_000, + env: Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => + !key.startsWith('INPUT_') && key !== 'GITHUB_ACTIONS' && key !== 'GITHUB_REPOSITORY', + ), + ), + }); + } + + function expectGeneratedReadme() { + const generated = fs.readFileSync(readmePath, 'utf8'); + const description = generated.match( + /([\s\S]*?)/u, + )?.[1]; + const usage = generated.match(/([\s\S]*?)/u)?.[1]; + const workflow = usage?.match(/```yaml\n([\s\S]*?)\n```/u)?.[1]; + + expect(description?.trim()).toBe('A test action for packaged generation'); + expect(YAML.parse(workflow ?? '')).toEqual([ + { uses: 'testowner/testrepo@v1.2.3', with: { 'my-input': '' } }, + ]); + expect(generated).toBe( + originalReadme + .replace('\nstale-description\n', description ?? '') + .replace('\nstale-usage\n', usage ?? ''), + ); + } + + function installLibraryFixture() { + const modulesDir = path.join(tempDir, 'node_modules'); + const libraryDir = path.join(modulesDir, packageManifest.name); + fs.mkdirSync(path.join(libraryDir, 'dist'), { recursive: true }); + fs.copyFileSync( + path.join(repositoryDir, 'package.json'), + path.join(libraryDir, 'package.json'), + ); + fs.copyFileSync(path.join(repositoryDir, 'action.yml'), path.join(libraryDir, 'action.yml')); + fs.cpSync(path.join(repositoryDir, 'dist/mjs'), path.join(libraryDir, 'dist/mjs'), { + recursive: true, + }); + + // The library resolves its declared dependencies; the CLI fixture receives only its binary. + for (const dependency of Object.keys(packageManifest.dependencies)) { + const target = path.join(modulesDir, dependency); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.symlinkSync(path.join(repositoryDir, 'node_modules', dependency), target, 'junction'); } + } - // The binary must NOT fail with ERR_MODULE_NOT_FOUND for prettier - expect(stderr).not.toContain('ERR_MODULE_NOT_FOUND'); - expect(stderr).not.toContain("Cannot find package 'prettier'"); + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gha-packaged-entry-test-')); + readmePath = path.join(tempDir, 'README.md'); + isolatedBinaryPath = path.join(tempDir, 'index.js'); + fs.copyFileSync(path.join(repositoryDir, 'dist/bin/index.js'), isolatedBinaryPath); + fs.writeFileSync(path.join(tempDir, 'action.yml'), actionYml); + fs.writeFileSync(readmePath, originalReadme); + writeConfig(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('generates a README from the CLI copied alone without node_modules', () => { + const result = runNode([isolatedBinaryPath]); + + expect(result.status, result.stderr).toBe(0); + expectGeneratedReadme(); + }); + + it('reports startup failures from the isolated CLI with a nonzero exit', () => { + fs.rmSync(readmePath); - // The binary should succeed (exit 0) - expect(exitCode, `stderr: ${stderr}`).toBe(0); + const result = runNode([isolatedBinaryPath]); + + expect(result.status, result.stderr).toBe(1); + expect(result.stderr.trim()).toBe(`ENOENT: no such file or directory, access '${readmePath}'`); + expect(fs.existsSync(readmePath)).toBe(false); + }); + + it('reports SVG write failures before the isolated CLI changes the README', () => { + const svgPath = path.join(tempDir, 'branding.svg'); + fs.mkdirSync(svgPath); + writeConfig({ sections: ['branding'], branding_svg_path: svgPath }); + + const result = runNode([isolatedBinaryPath]); + + expect(result.status, result.stderr).toBe(1); + expect(result.stderr.trim()).toBe( + `EISDIR: illegal operation on a directory, open '${svgPath}'`, + ); + expect(fs.readFileSync(readmePath, 'utf8')).toBe(originalReadme); + }); + + it('completes generation while importing the public ESM library in native Node', () => { + installLibraryFixture(); + const readmeAtImportPath = path.join(tempDir, 'README.at-import.md'); + const result = runNode([ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; + import { readFileSync, writeFileSync } from 'node:fs'; + const { generateReadme } = await import(${JSON.stringify(packageManifest.name)}); + assert.equal(typeof generateReadme, 'function'); + writeFileSync(${JSON.stringify(readmeAtImportPath)}, readFileSync(${JSON.stringify(readmePath)}));`, + ]); + + expect(result.status, result.stderr).toBe(0); + expectGeneratedReadme(); + expect(fs.readFileSync(readmeAtImportPath, 'utf8')).toBe(fs.readFileSync(readmePath, 'utf8')); + }); - // The README should be updated with generated content - const generatedReadme = fs.readFileSync(readmePath, 'utf8'); - expect(generatedReadme).toContain('testowner/testrepo'); + it('rejects a native library import with the original filesystem error', () => { + installLibraryFixture(); + fs.rmSync(readmePath); + const result = runNode([ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; + await assert.rejects(import(${JSON.stringify(packageManifest.name)}), { + code: 'ENOENT', path: ${JSON.stringify(readmePath)}, + });`, + ]); + + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(readmePath)).toBe(false); }); }); diff --git a/package.json b/package.json index 5e0ba46d..d4d0cb90 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "scripts": { "all": "npm run build && npm run format && npm run lint && npm run test", "prebuild": "tsc --project tsconfig.json --noemit", - "build": "rimraf dist out && vp pack && chmod +x dist/bin/index.js", + "build": "rimraf dist && vp pack && chmod +x dist/bin/index.js", "build:docker": "run-script-os", "build:docker:default": "docker run --rm -t -v $(pwd):/work -w /work node:24-alpine sh -c 'apk add --no-cache curl && curl -fsSL https://viteplus.dev/install.sh | VP_NODE_MANAGER=no bash && export PATH=\"$HOME/.vite-plus/bin:$PATH\" && npm run build'", "build:docker:win32": "docker run --rm -t -v \"%cd%\":/work -w /work node:24-alpine sh -c \"apk add --no-cache curl && curl -fsSL https://viteplus.dev/install.sh | VP_NODE_MANAGER=no bash && export PATH=\\\"$HOME/.vite-plus/bin:$PATH\\\" && npm run build\"", diff --git a/src/inputs.ts b/src/inputs.ts index 49fddf02..66c5d072 100644 --- a/src/inputs.ts +++ b/src/inputs.ts @@ -9,7 +9,7 @@ import * as path from 'node:path'; import * as core from '@actions/core'; import { context as githubContext } from '@actions/github'; -import nconf, { Provider } from 'nconf'; +import nconf from 'nconf'; import YAML from 'yaml'; // Get Context type and constructor from the exported context instance @@ -321,7 +321,7 @@ type KVPairType = { /** * Type alias for Provider instance */ -type ProviderInstance = InstanceType; +type ProviderInstance = nconf.Provider; export function transformGitHubInputsToArgv( log: LogTask, @@ -722,7 +722,7 @@ export function loadConfig( configFilePath?: string, ): ProviderInstance { log.debug('Loading config from env and argv'); - const config = providedConfig ?? new Provider(); + const config = providedConfig ?? new nconf.Provider(); if (process.env.GITHUB_ACTION === 'true') { log.info('Running in GitHub action'); } @@ -937,7 +937,7 @@ export default class Inputs { this.log.debug('Initializing Inputs'); const inputContext = providedInputContext ?? {}; this.configPath = inputContext.configPath ?? path.resolve(configFileName); - this.config = inputContext.config ?? new Provider(); + this.config = inputContext.config ?? new nconf.Provider(); loadConfig(log, this.config, this.configPath); // `loadDefaultConfig` calls `repositoryFinder`, which throws "No owner or From 2d451006f5de39e2b9c370647e172e460c2590a4 Mon Sep 17 00:00:00 2001 From: Jamie Nelson Date: Sun, 11 Oct 2026 12:20:29 +1100 Subject: [PATCH 5/6] fix: pair section log groups and honor Actions mode at output boundaries Keep group ownership on the instance that opened it and close every synchronous section scope in finally. Prepare asynchronous usage descriptions before opening the rendering group, preserving concurrent preparation and repeated sections. Apply exact-true Actions detection to existing output boundaries, remove unused group-format state, and cover native SVG failure, completion, error identity, and real caller lifecycle behavior. --- __tests__/logtask/index.test.ts | 49 +++-- __tests__/logtask/lifecycle.test.ts | 272 ++++++++++++++++++++++++++++ __tests__/update-branding.test.ts | 6 +- src/inputs.ts | 4 +- src/logtask/README.md | 8 +- src/logtask/index.ts | 84 +++------ src/readme-editor.ts | 4 +- src/readme-generator.ts | 2 +- src/sections/update-badges.ts | 24 ++- src/sections/update-branding.ts | 36 ++-- src/sections/update-contents.ts | 71 ++++---- src/sections/update-description.ts | 14 +- src/sections/update-inputs.ts | 51 +++--- src/sections/update-outputs.ts | 34 ++-- src/sections/update-title.ts | 30 +-- src/sections/update-usage.ts | 75 ++++---- 16 files changed, 528 insertions(+), 236 deletions(-) create mode 100644 __tests__/logtask/lifecycle.test.ts diff --git a/__tests__/logtask/index.test.ts b/__tests__/logtask/index.test.ts index 3f9c1802..2fd32a47 100644 --- a/__tests__/logtask/index.test.ts +++ b/__tests__/logtask/index.test.ts @@ -12,10 +12,11 @@ describe('LogTask', () => { beforeEach(() => { logTask = new LogTask('TestTask'); vi.stubEnv('GITHUB_ACTIONS', 'false'); + vi.stubEnv('DEBUG', 'false'); }); afterEach(() => { - logTask.ingroup = false; + logTask.end(); vi.resetAllMocks(); vi.unstubAllEnvs(); }); @@ -39,13 +40,7 @@ describe('LogTask', () => { }); describe('getMessageString', () => { - it('should return the formatted message string when ingroup is enabled', () => { - logTask.ingroup = true; - const result = logTask.getMessageString('START', testMessage, '🚀'); - expect(result).toBe(chalk.yellowBright(' 🚀: TestTask > Test message')); - }); - - it('should return the formatted message string when ingroup is disabled', () => { + it('should return the formatted message string', () => { const result = logTask.getMessageString('START', testMessage, '🚀'); expect(result).toBe(chalk.yellowBright('[START][TestTask ] 🚀: Test message')); }); @@ -68,13 +63,13 @@ describe('LogTask', () => { describe('debug', () => { it('should log a debug message if debug mode is enabled', () => { - LogTask.isDebug = vi.fn().mockReturnValue(true); + vi.mocked(core.isDebug).mockReturnValue(true); logTask.debug(testMessage); expect(core.info).toHaveBeenCalledWith('[DEBUG][TestTask ] 🐞: Test message'); }); it('should not log a debug message if debug mode is disabled', () => { - LogTask.isDebug = vi.fn().mockReturnValue(false); + vi.mocked(core.isDebug).mockReturnValue(false); logTask.debug(testMessage); expect(core.info).not.toHaveBeenCalled(); }); @@ -128,11 +123,21 @@ describe('LogTask', () => { ); }); - it('should end the group if ingroup is enabled and GITHUB_ACTIONS is true', () => { - process.env.GITHUB_ACTIONS = 'true'; - logTask.ingroup = true; + it('closes its started group only once on successful completion and cleanup', () => { + vi.stubEnv('GITHUB_ACTIONS', 'true'); + logTask.start(); logTask.success(testMessage); - expect(core.endGroup).toHaveBeenCalled(); + logTask.end(); + expect(core.endGroup).toHaveBeenCalledTimes(1); + }); + + it("does not close another instance's group with the same task name", () => { + vi.stubEnv('GITHUB_ACTIONS', 'true'); + logTask.start(); + + new LogTask('TestTask').success(testMessage); + + expect(core.endGroup).not.toHaveBeenCalled(); }); }); @@ -153,11 +158,21 @@ describe('LogTask', () => { ); }); - it('should end the group if ingroup is enabled and GITHUB_ACTIONS is true', () => { + it('closes its started group only once on failure and cleanup', () => { + vi.stubEnv('GITHUB_ACTIONS', 'true'); + logTask.start(); + logTask.fail(testMessage); + logTask.end(); + expect(core.endGroup).toHaveBeenCalledTimes(1); + expect(core.setFailed).toHaveBeenCalled(); + }); + + it('reports an unstarted failure without emitting an end command', () => { vi.stubEnv('GITHUB_ACTIONS', 'true'); - logTask.ingroup = true; + logTask.fail(testMessage); - expect(core.endGroup).toHaveBeenCalled(); + + expect(core.endGroup).not.toHaveBeenCalled(); expect(core.setFailed).toHaveBeenCalled(); }); }); diff --git a/__tests__/logtask/lifecycle.test.ts b/__tests__/logtask/lifecycle.test.ts new file mode 100644 index 00000000..8959d6f1 --- /dev/null +++ b/__tests__/logtask/lifecycle.test.ts @@ -0,0 +1,272 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import * as core from '@actions/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; + +import { README_SECTIONS, type ReadmeSection } from '../../src/constants.js'; +import Inputs from '../../src/inputs.js'; +import LogTask from '../../src/logtask/index.js'; +import * as prettier from '../../src/prettier.js'; +import ReadmeEditor from '../../src/readme-editor.js'; +import { ReadmeGenerator, type SectionKV } from '../../src/readme-generator.js'; +import updateSection from '../../src/sections/index.js'; +import updateUsage from '../../src/sections/update-usage.js'; +import SVGEditor from '../../src/svg-editor.mjs'; + +vi.mock('@actions/core', { spy: true }); + +const ACTION = `name: Logger lifecycle action +description: Action description +branding: + icon: book-open + color: yellow +inputs: + token: + description: Token description + default: example +outputs: + result: + description: Result description +runs: + using: node24 + main: index.js +`; + +const README = [ + '# Maintained title', + '', + '* Hand-written prose ', + '', + ...README_SECTIONS.flatMap((section) => [ + ``, + 'stale', + ``, + '', + ]), + '## Maintained footer', + '', +].join('\n'); + +describe('section log group lifecycle', () => { + let directory: string; + let readmePath: string; + let svgPath: string; + let configPath: string; + let commands: string[]; + + const loadInputs = (sections: ReadmeSection[] = [...README_SECTIONS]): Inputs => + new Inputs({ configPath, sections }); + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ghadocs-log-groups-')); + readmePath = path.join(directory, 'README.md'); + svgPath = path.join(directory, 'branding.svg'); + configPath = path.join(directory, '.ghadocs.json'); + const actionPath = path.join(directory, 'action.yml'); + fs.writeFileSync(actionPath, ACTION); + fs.writeFileSync(readmePath, README); + fs.writeFileSync( + configPath, + JSON.stringify({ + owner: 'fixture-owner', + repo: 'fixture-action', + paths: { action: actionPath, readme: readmePath }, + title_prefix: '', + branding_svg_path: svgPath, + branding_as_title_prefix: false, + image_generated: 'boxblue', + prettier: false, + versioning: { + enabled: true, + source: 'explicit', + override: '1.2.3', + prefix: 'v', + badge: true, + }, + }), + ); + vi.stubEnv('GITHUB_ACTIONS', 'true'); + vi.stubEnv('DEBUG', 'false'); + commands = []; + vi.spyOn(core, 'startGroup').mockImplementation((name) => { + commands.push(`start:${name}`); + }); + vi.spyOn(core, 'endGroup').mockImplementation(() => { + commands.push('end'); + }); + vi.spyOn(core, 'info').mockImplementation(() => {}); + vi.spyOn(core, 'error').mockImplementation(() => {}); + vi.spyOn(core, 'setOutput').mockImplementation(() => {}); + vi.spyOn(core, 'setFailed').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it('leaves the caller group open when loading a missing README fails', () => { + core.startGroup('caller'); + const missing = path.join(directory, 'missing.md'); + + expect(() => new ReadmeEditor(missing)).toThrow(expect.objectContaining({ code: 'ENOENT' })); + + expect(commands).toEqual(['start:caller']); + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining(missing)); + }); + + it.each([ + 'title', + 'branding', + 'description', + 'inputs', + 'outputs', + 'contents', + 'badges', + ])('closes %s synchronously when its README renderer throws', async (section) => { + const inputs = loadInputs(); + const failure = new Error('README rendering failed'); + vi.spyOn(inputs.readmeEditor, 'updateSection').mockImplementation(() => { + throw failure; + }); + + const update = updateSection(section, inputs); + const commandsAtReturn = [...commands]; + + await expect(update).rejects.toBe(failure); + expect(commandsAtReturn).toEqual([`start:Starting ${section}...`, 'end']); + expect(fs.readFileSync(readmePath, 'utf8')).toBe(README); + }); + + it('closes branding after a native write failure before pending usage finishes, without caching or saving', async () => { + fs.mkdirSync(svgPath); + const inputs = loadInputs(['usage', 'branding']); + const cachedImage = inputs.config.get('image_generated'); + const writeSVGFile = SVGEditor.prototype.writeSVGFile; + let nativeFailure: unknown; + vi.spyOn(SVGEditor.prototype, 'writeSVGFile').mockImplementation(function ( + this: SVGEditor, + target, + content, + ) { + try { + writeSVGFile.call(this, target, content); + } catch (error) { + nativeFailure = error; + throw error; + } + }); + + const generator = new ReadmeGenerator(inputs, new LogTask('generator')); + const updateSections = generator.updateSections.bind(generator); + let updates: Promise[] = []; + vi.spyOn(generator, 'updateSections').mockImplementation((sections) => { + updates = updateSections(sections); + return updates; + }); + const generation = generator.generate(); + + await expect(generation).rejects.toBe(nativeFailure); + // Promise.all rejects before other eagerly started sections necessarily finish. + await Promise.allSettled(updates); + expect(nativeFailure).toMatchObject({ code: 'EISDIR', path: svgPath }); + expect(commands).toEqual([ + 'start:Starting branding...', + 'end', + 'start:Starting usage...', + 'end', + ]); + expect(inputs.config.get('image_generated')).toBe(cachedImage); + expect(fs.readFileSync(readmePath, 'utf8')).toBe(README); + }); + + it('preserves an async formatting rejection without opening or ending a group', async () => { + const inputs = loadInputs(['usage']); + const failure = new Error('Description formatting failed'); + vi.spyOn(prettier, 'wrapDescription').mockRejectedValue(failure); + + await expect(updateUsage('usage', inputs)).rejects.toBe(failure); + + expect(commands).toEqual([]); + expect(fs.readFileSync(readmePath, 'utf8')).toBe(README); + }); + + it('closes usage when its renderer throws after description preparation', async () => { + const inputs = loadInputs(['usage']); + const failure = new Error('Usage rendering failed'); + vi.spyOn(inputs.readmeEditor, 'updateSection').mockImplementation(() => { + throw failure; + }); + + await expect(updateUsage('usage', inputs)).rejects.toBe(failure); + + expect(commands).toEqual(['start:Starting usage...', 'end']); + expect(fs.readFileSync(readmePath, 'utf8')).toBe(README); + }); + + it('prepares duplicate usage sections concurrently and closes each render group before the next', async () => { + const inputs = loadInputs(['usage', 'usage']); + const generator = new ReadmeGenerator(inputs, new LogTask('generator')); + const preparation = Promise.withResolvers(); + const wrapDescription = prettier.wrapDescription; + const wrapping = vi.spyOn(prettier, 'wrapDescription').mockImplementation(async (...args) => { + await preparation.promise; + return wrapDescription(...args); + }); + + const generation = generator.generate(); + const callsBeforeResolution = wrapping.mock.calls.length; + const commandsBeforeResolution = [...commands]; + + preparation.resolve(); + await generation; + + expect(callsBeforeResolution).toBe(2); + expect(commandsBeforeResolution).toEqual([]); + expect(commands).toEqual(['start:Starting usage...', 'end', 'start:Starting usage...', 'end']); + const readme = fs.readFileSync(readmePath, 'utf8'); + expect(readme).toContain('- uses: fixture-owner/fixture-action@v1.2.3'); + expect(readme).toContain(' # Description: Token description'); + expect(readme).toContain(' # Default: example'); + expect(readme).toContain(" token: ''"); + expect(readme.split('')[0]).toBe(README.split('')[0]); + expect(readme.split('')[1]).toBe(README.split('')[1]); + }); + + it('publishes the existing output contract when GITHUB_ACTIONS=true', async () => { + const inputs = loadInputs(['description']); + const expectedReadme = README.replace( + '\nstale\n', + '\n\nAction description\n\n', + ); + + await new ReadmeGenerator(inputs, new LogTask('generator')).generate(); + + const outputs = new Map(vi.mocked(core.setOutput).mock.calls); + expect([...outputs.keys()]).toEqual(['readme_before', 'readme', 'sections', 'readme_after']); + expect(outputs.get('readme_before')).toBe(README); + expect(outputs.get('readme')).toBe(readmePath); + expect(JSON.parse(String(outputs.get('sections')))).toEqual({ + description: 'Action description', + }); + expect(outputs.get('readme_after')).toBe(expectedReadme); + expect(fs.readFileSync(readmePath, 'utf8')).toBe(expectedReadme); + }); + + it.each(['false', undefined])( + 'emits no workflow groups or outputs with GITHUB_ACTIONS=%s', + async (value) => { + vi.stubEnv('GITHUB_ACTIONS', value); + const inputs = loadInputs(['usage', 'branding']); + + await new ReadmeGenerator(inputs, new LogTask('generator')).generate(); + + expect(commands).toEqual([]); + expect(core.setOutput).not.toHaveBeenCalled(); + expect(fs.readFileSync(svgPath, 'utf8').trim()).toMatch(/<\/svg>$/); + }, + ); +}); diff --git a/__tests__/update-branding.test.ts b/__tests__/update-branding.test.ts index d14f809c..d254d13f 100644 --- a/__tests__/update-branding.test.ts +++ b/__tests__/update-branding.test.ts @@ -12,7 +12,7 @@ import updateTitle from '../src/sections/update-title.js'; import SVGEditor from '../src/svg-editor.mjs'; describe('branding generation', () => { - it('generates the branding SVG on the first run', async () => { + it('returns only after the complete branding SVG has been written', () => { const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'ghadocs-branding-')); const svgPath = path.join(tempDirectory, 'branding.svg'); const values = new Map([['branding_svg_path', svgPath]]); @@ -26,7 +26,9 @@ describe('branding generation', () => { try { generateImgMarkup(inputs); - await vi.waitFor(() => expect(fs.readFileSync(svgPath, 'utf8')).toContain(']+>\s*$/); expect(inputs.config.set).toHaveBeenCalledWith('image_generated', 'book-openyellow'); } finally { fs.rmSync(tempDirectory, { recursive: true, force: true }); diff --git a/src/inputs.ts b/src/inputs.ts index 66c5d072..d30d36b1 100644 --- a/src/inputs.ts +++ b/src/inputs.ts @@ -723,7 +723,7 @@ export function loadConfig( ): ProviderInstance { log.debug('Loading config from env and argv'); const config = providedConfig ?? new nconf.Provider(); - if (process.env.GITHUB_ACTION === 'true') { + if (process.env.GITHUB_ACTIONS === 'true') { log.info('Running in GitHub action'); } @@ -977,7 +977,7 @@ export default class Inputs { /** * Output the readme path that is being parsed */ - if (process.env.GITHUB_ACTIONS) { + if (process.env.GITHUB_ACTIONS === 'true') { core.setOutput('readme', this.readmePath); } /** diff --git a/src/logtask/README.md b/src/logtask/README.md index 04b532b3..98eb44c2 100644 --- a/src/logtask/README.md +++ b/src/logtask/README.md @@ -1,7 +1,5 @@ -The file [`index.ts`](./index.ts) defines a class called `LogTask` that is used for logging and displaying different steps and messages in a consistent format. The class has various methods for logging different types of messages such as `debug` , `start` , `info` , `warn` , `success` , `fail` , `error` , and `title` . These methods take a description as an argument and display the message with an appropriate emoji and formatting. +[`LogTask`](./index.ts) formats progress and error messages with a task name, step, and emoji. Chalk supplies local colors; `@actions/core` sends messages and workflow commands to GitHub Actions. -The `LogTask` class keeps track of whether a log message belongs to a group or not using the `ingroup` property. It also keeps track of the maximum width of the step string to ensure consistent indentation. +`start()` opens a group when `GITHUB_ACTIONS=true`. The same instance closes it through `success()`, `fail()`, or `end()`. Completion without a matching start emits only the message, and repeated cleanup does not end another task's group. Outside GitHub Actions, starts and completions remain ordinary log messages. -The class uses the `chalk` library to add colors to the log messages and the `node-emoji` library to add emojis. It also uses the `@actions/core` library for logging messages in GitHub Actions. - -Overall, the `LogTask` class provides a convenient way to log and display messages with consistent formatting and styling. +Workflow groups share an output stream across tasks. Section updaters open groups around synchronous rendering and close them in `finally`, so a thrown renderer or filesystem error still closes its group before propagating. Usage prepares descriptions asynchronously before opening its render group; overlapping preparation therefore does not create overlapping groups. diff --git a/src/logtask/index.ts b/src/logtask/index.ts index 8a8dbbc8..131eb199 100644 --- a/src/logtask/index.ts +++ b/src/logtask/index.ts @@ -96,26 +96,8 @@ function highlightStep(step: string, message: string): string { return msg; } -function handleOutput(startGroup: LogGroup, msg: string, originalString?: string): void { - // Logic to handle different log outputs based on the environment (GitHub Actions or local) - const ci = inGitHubActions(); - +function handleOutput(startGroup: LogGroup, msg: string): void { switch (startGroup) { - case LogGroup.START_GROUP: { - if (ci && originalString) { - core.startGroup(originalString); - } else { - core.info(msg); - } - - break; - } - case LogGroup.END_GROUP: { - if (ci) { - core.endGroup(); - } - break; - } // Logic to handle erroring or failed steps case LogGroup.IS_ERROR: { core.error(msg); @@ -134,10 +116,7 @@ function handleOutput(startGroup: LogGroup, msg: string, originalString?: string * Represents a logging task with various log step methods. */ export default class LogTask { - /** - * Map of ingroup settings per task name. - */ - private static ingroupSettings = new Map(); + private groupOpen = false; /** * The width of the indentation for log messages. @@ -165,29 +144,9 @@ export default class LogTask { this.name = name?.trim(); } - /** - * Gets the ingroup setting for the task. - */ - get ingroup(): boolean { - return LogTask.ingroupSettings.get(this.name) ?? false; - } - - /** - * Sets the ingroup setting for this task. - */ - set ingroup(value: boolean) { - LogTask.ingroupSettings.set(this.name, value); - } - getMessageString(step: string, desc: string, emojiStr: string): string { - let msg: string; - if (this.ingroup) { - const indentStr = ' '.repeat(LogTask.indentWidth); - msg = `${indentStr} ${emojiStr}: ${this.name} > ${desc}`; - } else { - const stepStr = step.padEnd(LogTask.indentWidth, ' '); - msg = `[${stepStr}][${this.name.padEnd(11, ' ')}] ${emojiStr}: ${desc}`; - } + const stepStr = step.padEnd(LogTask.indentWidth, ' '); + const msg = `[${stepStr}][${this.name.padEnd(11, ' ')}] ${emojiStr}: ${desc}`; return highlightStep(step, msg); } @@ -211,7 +170,18 @@ export default class LogTask { const { desc } = highlightMessage(step, message); const msg = this.getMessageString(step, desc, emojiStr); - handleOutput(startGroup, msg, message); + if (startGroup === LogGroup.START_GROUP) { + if (inGitHubActions() && message !== '' && !this.groupOpen) { + core.startGroup(message); + this.groupOpen = true; + } else { + core.info(msg); + } + } else if (startGroup === LogGroup.END_GROUP) { + this.end(); + } else { + handleOutput(startGroup, msg); + } } /** @@ -236,6 +206,14 @@ export default class LogTask { this.logStep('🚀', 'START', desc, LogGroup.START_GROUP); } + /** Closes this task's group once, including when called from a finally block. */ + end(): void { + if (this.groupOpen) { + this.groupOpen = false; + core.endGroup(); + } + } + /** * Logs an info message. * @param message - The message of the info message. @@ -257,16 +235,13 @@ export default class LogTask { /** * Logs a success message. * @param message - The message of the success message. - * @param ingroup - Indicates whether the success message is in a group. + * @param ingroup - Whether to close this task's group before the message. */ success(message: string = '', ingroup: boolean = true): void { // Logic to log a success message const desc = message === '' ? `Completed ${this.name}.` : message; if (ingroup) { - this.ingroup = false; - if (inGitHubActions()) { - core.endGroup(); - } + this.end(); } this.logStep('✅', 'SUCCESS', desc); } @@ -274,16 +249,13 @@ export default class LogTask { /** * Logs a failure message. * @param message - The message of the failure message. - * @param ingroup - Indicates whether the failure message is in a group. + * @param ingroup - Whether to close this task's group before the message. */ fail(message: string = '', ingroup: boolean = true): void { // Logic to log a failure message const desc = message === '' ? `Failed ${this.name}.` : message; if (ingroup) { - this.ingroup = false; - if (inGitHubActions()) { - core.endGroup(); - } + this.end(); } const msgtype = inGitHubActions() ? LogGroup.IS_FAILED : LogGroup.IS_ERROR; this.logStep('❌', 'FAILURE', desc, msgtype); diff --git a/src/readme-editor.ts b/src/readme-editor.ts index 033c085c..a1ca1a0d 100644 --- a/src/readme-editor.ts +++ b/src/readme-editor.ts @@ -72,7 +72,7 @@ export default class ReadmeEditor { try { fs.accessSync(filePath); const raw = fs.readFileSync(filePath, 'utf8'); - if (process.env.GITHUB_ACTIONS) { + if (process.env.GITHUB_ACTIONS === 'true') { core.setOutput('readme_before', raw); } this.crlf = usesCrlf(raw); @@ -216,7 +216,7 @@ export default class ReadmeEditor { await this.formatUpdatedSections(); } const content = this.crlf ? this.fileContent.replaceAll('\n', '\r\n') : this.fileContent; - if (process.env.GITHUB_ACTIONS) { + if (process.env.GITHUB_ACTIONS === 'true') { core.setOutput('readme_after', content); } return fs.promises.writeFile(this.filePath, content, 'utf8'); diff --git a/src/readme-generator.ts b/src/readme-generator.ts index 80db7750..6cf92865 100644 --- a/src/readme-generator.ts +++ b/src/readme-generator.ts @@ -81,7 +81,7 @@ export class ReadmeGenerator { * @param sections - The sections KV */ outputSections(sections: SectionKV): void { - if (process.env.GITHUB_ACTIONS) { + if (process.env.GITHUB_ACTIONS === 'true') { this.log.debug('Outputting sections'); core.setOutput('sections', JSON.stringify(sections, null, 2)); } else { diff --git a/src/sections/update-badges.ts b/src/sections/update-badges.ts index 8f65d9f0..6e51ef0e 100644 --- a/src/sections/update-badges.ts +++ b/src/sections/update-badges.ts @@ -95,15 +95,19 @@ export default function updateBadges( log.info(`Versioning badge: ${enableVersioning}`); log.start(); - let content = ''; - // Add GitHub badges - if (enableVersioning) { - const badges: IBadge[] = githubBadges(inputs.owner, inputs.repo); - content = generateBadges(badges, log).join(''); - inputs.readmeEditor.updateSection(sectionToken, content); + try { + let content = ''; + // Add GitHub badges + if (enableVersioning) { + const badges: IBadge[] = githubBadges(inputs.owner, inputs.repo); + content = generateBadges(badges, log).join(''); + inputs.readmeEditor.updateSection(sectionToken, content); + } + log.success(); + const ret: Record = {}; + ret[sectionToken] = content; + return ret; + } finally { + log.end(); } - log.success(); - const ret: Record = {}; - ret[sectionToken] = content; - return ret; } diff --git a/src/sections/update-branding.ts b/src/sections/update-branding.ts index c36814ed..3b644af0 100644 --- a/src/sections/update-branding.ts +++ b/src/sections/update-branding.ts @@ -163,21 +163,25 @@ export default function updateBranding( log.info(`Brand details: ${JSON.stringify(inputs.action.branding)}`); log.start(); - /** create markup with an image width of 15% */ - const imageWidth = '15%'; - const content = generateImgMarkup( - inputs, - imageWidth, - inputs.readmeEditor.hasSection(sectionToken), - ); - inputs.readmeEditor.updateSection(sectionToken, content); - if (content && content !== '') { - log.success('branding svg successfully created'); - } else { - log.fail('branding svg failed to be created'); - } + try { + /** create markup with an image width of 15% */ + const imageWidth = '15%'; + const content = generateImgMarkup( + inputs, + imageWidth, + inputs.readmeEditor.hasSection(sectionToken), + ); + inputs.readmeEditor.updateSection(sectionToken, content); + if (content && content !== '') { + log.success('branding svg successfully created'); + } else { + log.fail('branding svg failed to be created'); + } - const ret: Record = {}; - ret[sectionToken] = content; - return ret; + const ret: Record = {}; + ret[sectionToken] = content; + return ret; + } finally { + log.end(); + } } diff --git a/src/sections/update-contents.ts b/src/sections/update-contents.ts index c5ab87bb..c8ed879d 100644 --- a/src/sections/update-contents.ts +++ b/src/sections/update-contents.ts @@ -73,51 +73,54 @@ export default function updateContents( ): Record { const log = new LogTask(sectionToken); log.start(); + try { + const content: string[] = []; + const readmeContent = inputs.readmeEditor.getReadmeContent(); - const content: string[] = []; - const readmeContent = inputs.readmeEditor.getReadmeContent(); + // Extract headers from README + const headers = extractHeaders(readmeContent); - // Extract headers from README - const headers = extractHeaders(readmeContent); + // Filter out the title (h1) and contents section itself + const tocHeaders = headers.filter( + (h) => h.level >= 2 && !h.text.toLowerCase().includes('contents'), + ); - // Filter out the title (h1) and contents section itself - const tocHeaders = headers.filter( - (h) => h.level >= 2 && !h.text.toLowerCase().includes('contents'), - ); + if (tocHeaders.length === 0) { + log.info('No headers found for table of contents'); + } else { + log.info(`Generating table of contents with ${tocHeaders.length} entries`); - if (tocHeaders.length === 0) { - log.info('No headers found for table of contents'); - } else { - log.info(`Generating table of contents with ${tocHeaders.length} entries`); + // Find minimum header level for proper indentation + const minLevel = Math.min(...tocHeaders.map((h) => h.level)); - // Find minimum header level for proper indentation - const minLevel = Math.min(...tocHeaders.map((h) => h.level)); + // Track anchor occurrences to disambiguate duplicates like GitHub does + const anchorCounts = new Map(); - // Track anchor occurrences to disambiguate duplicates like GitHub does - const anchorCounts = new Map(); + // Generate TOC entries + content.push('## Table of Contents', ''); - // Generate TOC entries - content.push('## Table of Contents', ''); + for (const header of tocHeaders) { + const indent = ' '.repeat(header.level - minLevel); + const baseAnchor = headerToAnchor(header.text); - for (const header of tocHeaders) { - const indent = ' '.repeat(header.level - minLevel); - const baseAnchor = headerToAnchor(header.text); + // Track occurrences and compute unique anchor + const count = anchorCounts.get(baseAnchor) ?? 0; + anchorCounts.set(baseAnchor, count + 1); - // Track occurrences and compute unique anchor - const count = anchorCounts.get(baseAnchor) ?? 0; - anchorCounts.set(baseAnchor, count + 1); + // First occurrence uses base anchor, subsequent ones get -1, -2, etc. + const anchor = count === 0 ? baseAnchor : `${baseAnchor}-${count}`; - // First occurrence uses base anchor, subsequent ones get -1, -2, etc. - const anchor = count === 0 ? baseAnchor : `${baseAnchor}-${count}`; - - content.push(`${indent}- [${header.text}](#${anchor})`); + content.push(`${indent}- [${header.text}](#${anchor})`); + } } - } - inputs.readmeEditor.updateSection(sectionToken, content); - log.success(); + inputs.readmeEditor.updateSection(sectionToken, content); + log.success(); - const ret: Record = {}; - ret[sectionToken] = content.join('\n'); - return ret; + const ret: Record = {}; + ret[sectionToken] = content.join('\n'); + return ret; + } finally { + log.end(); + } } diff --git a/src/sections/update-description.ts b/src/sections/update-description.ts index 7028ba3c..9d26df2a 100644 --- a/src/sections/update-description.ts +++ b/src/sections/update-description.ts @@ -36,12 +36,16 @@ export default function updateDescription( // Build the new description section if (inputs?.action?.description) { log.start(); - const desc = descriptionMarkdown(inputs.action.description); + try { + const desc = descriptionMarkdown(inputs.action.description); - log.info(`Writing ${desc.length} characters to the description section`); - content.push(desc); - inputs.readmeEditor.updateSection(sectionToken, content); - log.success(); + log.info(`Writing ${desc.length} characters to the description section`); + content.push(desc); + inputs.readmeEditor.updateSection(sectionToken, content); + log.success(); + } finally { + log.end(); + } } const ret: Record = {}; ret[sectionToken] = content.join('\n'); diff --git a/src/sections/update-inputs.ts b/src/sections/update-inputs.ts index 7bd9d4e6..f84f8a1e 100644 --- a/src/sections/update-inputs.ts +++ b/src/sections/update-inputs.ts @@ -34,34 +34,37 @@ export default function updateInputs( if (vars && tI > 0) { log.start(); + try { + for (const key of Object.keys(vars)) { + const values = vars[key]; + let description = values?.description ?? ''; - for (const key of Object.keys(vars)) { - const values = vars[key]; - let description = values?.description ?? ''; + // Keep the complete first paragraph, including soft line breaks. + description = description.trim().split('\n\n')[0] ?? ''; - // Keep the complete first paragraph, including soft line breaks. - description = description.trim().split('\n\n')[0] ?? ''; + // Convert every soft break in the first paragraph to the table's HTML form. + description = description.replaceAll('\n', '
'); - // Convert every soft break in the first paragraph to the table's HTML form. - description = description.replaceAll('\n', '
'); - - const row: string[] = [ - rowHeader(key), - description, - // Declared `false` and `0` defaults reach the column; undefined and an - // empty string render as blank. - values?.default === undefined || values.default === '' - ? '' - : `${values.default}`, - values?.required ? '**true**' : '__false__', - ]; - log.debug(JSON.stringify(row)); - markdownArray.push(row); + const row: string[] = [ + rowHeader(key), + description, + // Declared `false` and `0` defaults reach the column; undefined and an + // empty string render as blank. + values?.default === undefined || values.default === '' + ? '' + : `${values.default}`, + values?.required ? '**true**' : '__false__', + ]; + log.debug(JSON.stringify(row)); + markdownArray.push(row); + } + content.push(markdowner(markdownArray)); + log.info(`Action has ${tI} total ${sectionToken}`); + inputs.readmeEditor.updateSection(sectionToken, content); + log.success(); + } finally { + log.end(); } - content.push(markdowner(markdownArray)); - log.info(`Action has ${tI} total ${sectionToken}`); - inputs.readmeEditor.updateSection(sectionToken, content); - log.success(); } else { log.debug(`Action has no ${sectionToken}`); inputs.readmeEditor.updateSection(sectionToken, content); diff --git a/src/sections/update-outputs.ts b/src/sections/update-outputs.ts index 7fcdcc18..7b78f678 100644 --- a/src/sections/update-outputs.ts +++ b/src/sections/update-outputs.ts @@ -32,26 +32,30 @@ export default function updateOutputs( const tI = vars ? Object.keys(vars).length : 0; if (vars && tI > 0) { log.start(); - for (const key of Object.keys(vars)) { - const values = vars[key]; + try { + for (const key of Object.keys(vars)) { + const values = vars[key]; - let description = values?.description ?? ''; + let description = values?.description ?? ''; - // Keep the complete first paragraph, including soft line breaks. - description = description.trim().split('\n\n')[0] ?? ''; + // Keep the complete first paragraph, including soft line breaks. + description = description.trim().split('\n\n')[0] ?? ''; - // Convert every soft break in the first paragraph to the table's HTML form. - description = description.replaceAll('\n', '
'); - const value = values?.value ? `\`${values.value}\`` : ''; - const row: string[] = [rowHeader(key), description, value]; + // Convert every soft break in the first paragraph to the table's HTML form. + description = description.replaceAll('\n', '
'); + const value = values?.value ? `\`${values.value}\`` : ''; + const row: string[] = [rowHeader(key), description, value]; - log.debug(JSON.stringify(row)); - markdownArray.push(row); + log.debug(JSON.stringify(row)); + markdownArray.push(row); + } + content.push(markdowner(markdownArray)); + log.info(`Action has ${tI} total ${sectionToken}`); + inputs.readmeEditor.updateSection(sectionToken, content); + log.success(); + } finally { + log.end(); } - content.push(markdowner(markdownArray)); - log.info(`Action has ${tI} total ${sectionToken}`); - inputs.readmeEditor.updateSection(sectionToken, content); - log.success(); } else { log.debug(`Action has no ${sectionToken}`); inputs.readmeEditor.updateSection(sectionToken, content); diff --git a/src/sections/update-title.ts b/src/sections/update-title.ts index 5192cda1..a6348ada 100644 --- a/src/sections/update-title.ts +++ b/src/sections/update-title.ts @@ -23,20 +23,24 @@ export default function updateTitle( if (inputs.action.name) { log.start(); - name = inputs.action.name; - if (inputs.config.get('branding_as_title_prefix') as boolean) { - svgInline = `${generateImgMarkup(inputs, '60px', inputs.readmeEditor.hasSection(sectionToken))} `; - } - log.info(`Writing ${name.length} characters to the title`); - const title = `# ${svgInline}${inputs.config.get('title_prefix') as string}${ - inputs.action.name - }`; - log.info(`Title: ${title}`); - // Build the new usage section - content.push(title); - inputs.readmeEditor.updateSection(sectionToken, content, true); + try { + name = inputs.action.name; + if (inputs.config.get('branding_as_title_prefix') as boolean) { + svgInline = `${generateImgMarkup(inputs, '60px', inputs.readmeEditor.hasSection(sectionToken))} `; + } + log.info(`Writing ${name.length} characters to the title`); + const title = `# ${svgInline}${inputs.config.get('title_prefix') as string}${ + inputs.action.name + }`; + log.info(`Title: ${title}`); + // Build the new usage section + content.push(title); + inputs.readmeEditor.updateSection(sectionToken, content, true); - log.success(); + log.success(); + } finally { + log.end(); + } } const ret: Record = {}; ret[sectionToken] = content.join('\n'); diff --git a/src/sections/update-usage.ts b/src/sections/update-usage.ts index 5ab4fc8a..9a11fcf4 100644 --- a/src/sections/update-usage.ts +++ b/src/sections/update-usage.ts @@ -18,14 +18,10 @@ export default async function updateUsage( inputs: Inputs, ): Promise> { const log = new LogTask(sectionToken); - log.start(); const actionName = `${inputs.owner}/${inputs.repo}`; - log.info(`Action name: ${actionName}`); const versionString: string = getCurrentVersionString(inputs); - log.info(`Version string: ${versionString}`); - const actionReference = `${actionName}@${versionString}`; const indent = ' # '; @@ -39,6 +35,7 @@ export default async function updateUsage( const inp = inputs.action.inputs; let firstInput = true; const descriptionPromises: Record> = {}; + const descriptions: DescriptionType = {}; if (inp) { for (const key of Object.keys(inp)) { const input = inp[key]; @@ -51,7 +48,6 @@ export default async function updateUsage( } } - const descriptions: DescriptionType = {}; const kvArray = await Promise.all( Object.keys(descriptionPromises).map(async (key) => { return { key, value: await descriptionPromises[key] }; @@ -59,44 +55,55 @@ export default async function updateUsage( ); for (const e of kvArray) { descriptions[e.key] = e.value; - log.debug(`${e.key}: ${descriptions[e.key].join('\n')}`); } + } - for (const key of Object.keys(inp)) { - const input = inp[key]; - if (input !== undefined) { - // Line break between inputs - if (!firstInput) { - content.push(''); - } + // Workflow groups share one output stream. Await preparation before opening + // this synchronous render group so concurrent usage updates cannot overlap it. + log.start(); + try { + log.info(`Action name: ${actionName}`); + log.info(`Version string: ${versionString}`); + if (inp) { + for (const key of Object.keys(inp)) { + const input = inp[key]; + if (input !== undefined) { + log.debug(`${key}: ${descriptions[key].join('\n')}`); + // Line break between inputs + if (!firstInput) { + content.push(''); + } - // Constrain the width of the description, and append it - content.push(...descriptions[key]); + // Constrain the width of the description, and append it + content.push(...descriptions[key]); - if (input.default !== undefined) { - // Default. Every line stays inside the comment: a bare continuation line - // sits at column 0 and the fence stops being YAML. Continuation lines hang - // under the value, so a multi-line default does not read as more prose. - const [firstLine, ...rest] = `${input.default}`.split(/\r\n|\n|\r/); - content.push(`${indent}${defaultLabel}${firstLine}`.trimEnd()); - for (const line of rest) { - content.push(`${indent}${defaultHang}${line}`.trimEnd()); + if (input.default !== undefined) { + // Default. Every line stays inside the comment: a bare continuation line + // sits at column 0 and the fence stops being YAML. Continuation lines hang + // under the value, so a multi-line default does not read as more prose. + const [firstLine, ...rest] = `${input.default}`.split(/\r\n|\n|\r/); + content.push(`${indent}${defaultLabel}${firstLine}`.trimEnd()); + for (const line of rest) { + content.push(`${indent}${defaultHang}${line}`.trimEnd()); + } } - } - // Input name - content.push(` ${key}: ''`); + // Input name + content.push(` ${key}: ''`); - firstInput = false; + firstInput = false; + } } } - } - content.push('```\n'); + content.push('```\n'); - inputs.readmeEditor.updateSection(sectionToken, content); - log.success(); - const ret: Record = {}; - ret[sectionToken] = content.join('\n'); - return ret; + inputs.readmeEditor.updateSection(sectionToken, content); + log.success(); + const ret: Record = {}; + ret[sectionToken] = content.join('\n'); + return ret; + } finally { + log.end(); + } } From 95a9e6808c2b0ee77369f3ea54193ec2e07c64a7 Mon Sep 17 00:00:00 2001 From: Jamie Nelson Date: Sun, 11 Oct 2026 12:33:05 +1100 Subject: [PATCH 6/6] fix(ci): honor explicit Node versions before automatic detection Give the matrix's requested version precedence over installed or project-detected versions. Preserve automatic selection and the existing fallback for callers that omit the input, and pass explicit input through the step environment. --- .github/actions/setup-node/action.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index 0e2f7de5..a5bbffbd 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -3,8 +3,8 @@ description: Sets up Node.js environment inputs: version: - description: "The version of Node.js to use" - default: "24.x" + description: "The version of Node.js to use. When omitted, detect a compatible version." + default: "" required: false type: string @@ -22,9 +22,13 @@ runs: shell: bash env: npm_config_engine_strict: true + REQUESTED_NODE_VERSION: ${{ inputs.version }} run: | + if [ -n "${REQUESTED_NODE_VERSION}" ]; then + NODE_VERSION="${REQUESTED_NODE_VERSION}" + # check if the installed node version works with the engines field in package.json - if npm ln --dry-run --ignore-scripts &>/dev/null; then + elif npm ln --dry-run --ignore-scripts &>/dev/null; then NODE_VERSION="$(node -v)" # check if .npmrc specifies a node version @@ -43,8 +47,8 @@ runs: fi # check that we now have a node version, if not, use the default - if ! grep -qoP '^v?\d+\.(x|\d+\.)?(\d+|x)?' <<<"${NODE_VERSION}"; then - NODE_VERSION="${{ inputs.version || '24.x' }}" + if [ -z "${REQUESTED_NODE_VERSION}" ] && ! grep -qoP '^v?\d+\.(x|\d+\.)?(\d+|x)?' <<<"${NODE_VERSION}"; then + NODE_VERSION="24.x" echo "::warning::Unable to determine Node.js version from project files, using default version ${NODE_VERSION}" fi