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 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__/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-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/__tests__/integration-readme-contract.test.ts b/__tests__/integration-readme-contract.test.ts index 54162947..08bf6154 100644 --- a/__tests__/integration-readme-contract.test.ts +++ b/__tests__/integration-readme-contract.test.ts @@ -242,6 +242,225 @@ describe('README generation contract', () => { expect(sectionBody(await generate(), 'outputs')).toContain(key); }); + it.each([undefined, '', 'null'])( + 'renders and verifies 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'; + 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); + + 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); + + 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'); + }, + ); + + 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/__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__/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/__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/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/scripts/verify-readme-contract.mjs b/scripts/verify-readme-contract.mjs index 9b3016f8..89e6d49e 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', @@ -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 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/inputs.ts b/src/inputs.ts index 49fddf02..d30d36b1 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,8 +722,8 @@ export function loadConfig( configFilePath?: string, ): ProviderInstance { log.debug('Loading config from env and argv'); - const config = providedConfig ?? new Provider(); - if (process.env.GITHUB_ACTION === 'true') { + const config = providedConfig ?? new nconf.Provider(); + if (process.env.GITHUB_ACTIONS === '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 @@ -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 a2130d5e..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,15 +35,19 @@ 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]; if (input !== undefined) { - descriptionPromises[key] = wrapDescription(`Description: ${input.description}`, [], indent); + descriptionPromises[key] = wrapDescription( + `Description: ${input.description ?? ''}`, + [], + indent, + ); } } - const descriptions: DescriptionType = {}; const kvArray = await Promise.all( Object.keys(descriptionPromises).map(async (key) => { return { key, value: await descriptionPromises[key] }; @@ -55,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(); + } }