From 3c0a1815318477f4f52d4d54432e0f5fe586fbfe Mon Sep 17 00:00:00 2001 From: Jamie Nelson Date: Sun, 11 Oct 2026 11:29:20 +1100 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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