diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f8f6ff..909e762 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,3 +46,31 @@ jobs: - name: 'npm test' run: 'npm run test' + + actions-gen-readme: + runs-on: 'ubuntu-latest' + steps: + - uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4 + + - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: 'package.json' + + - name: 'npm build' + run: 'npm ci && npm run build' + + - name: 'Install command globally' + run: |- + npm install -g . + + - name: 'Copy fixtures and generate README' + run: |- + cp -R "./tests/fixtures/actions-gen-readme" "${RUNNER_TEMP}/actions-gen-readme" + (cd "${RUNNER_TEMP}/actions-gen-readme" && actions-gen-readme) + + - name: 'Verify output' + run: |- + grep "Does things." "${RUNNER_TEMP}/actions-gen-readme/README.md" + grep "Does other things." "${RUNNER_TEMP}/actions-gen-readme/README.md" + grep "Has things." "${RUNNER_TEMP}/actions-gen-readme/README.md" + cat "${RUNNER_TEMP}/actions-gen-readme/README.md" diff --git a/bin/actions-gen-readme.mjs b/bin/actions-gen-readme.mjs old mode 100644 new mode 100755 index b124235..37003ff --- a/bin/actions-gen-readme.mjs +++ b/bin/actions-gen-readme.mjs @@ -1,65 +1,9 @@ #!/usr/bin/env node -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +import { actionsGenReadme } from '../dist/index.js'; -import { readFile, writeFile } from 'fs/promises'; -import * as YAML from 'yaml'; - -async function run() { - const readmeContents = (await readFile('README.md', 'utf8')).split('\n'); - - const actionContents = await readFile('action.yml', 'utf8'); - const action = YAML.parse(actionContents); - - const actionInputs = Object.entries(action.inputs || {}); - if (actionInputs.length === 0) console.warn(`action.yml inputs are empty`); - const inputs = []; - for (const [input, opts] of Object.entries(actionInputs)) { - const required = opts.required ? 'Required' : 'Optional'; - const description = opts.description - .split('\n') - .map((line) => (line.trim() === '' ? '' : ` ${line}`)) - .join('\n') - .trim(); - const def = opts.default ? `, default: \`${opts.default}\`` : ''; - inputs.push( - `- ${input}: _(${required}${def})_ ${description}\n`, - ); - } - const startInputs = readmeContents.indexOf(''); - const endInputs = readmeContents.indexOf(''); - readmeContents.splice(startInputs + 1, endInputs - startInputs - 1, '', ...inputs, ''); - - const actionOutputs = Object.entries(action.outputs || {}); - if (actionOutputs.length === 0) console.warn(`action.yml outputs are empty`); - const outputs = []; - for (const [output, opts] of Object.entries(actionOutputs)) { - const description = opts.description - .split('\n') - .map((line) => (line.trim() === '' ? '' : ` ${line}`)) - .join('\n') - .trim(); - outputs.push(`- \`${output}\`: ${description}\n`); - } - const startOutputs = readmeContents.indexOf(''); - const endOutputs = readmeContents.indexOf(''); - readmeContents.splice(startOutputs + 1, endOutputs - startOutputs - 1, '', ...outputs, ''); - - await writeFile('README.md', readmeContents.join('\n'), 'utf8'); +try { + await actionsGenReadme(); +} catch (err) { + console.error(err); } - -await run(); diff --git a/src/actions-gen-readme.ts b/src/actions-gen-readme.ts new file mode 100644 index 0000000..7c0dc9e --- /dev/null +++ b/src/actions-gen-readme.ts @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { readFile, writeFile } from 'fs/promises'; +import * as YAML from 'yaml'; + +type CommonOption = { + required: boolean; + description?: string; +}; + +type InputOption = CommonOption & { + default: string; +}; + +type OutputOption = CommonOption & {}; + +type Branding = { + icon: string; + color: string; +}; + +type ActionYML = { + name: string; + description: string; + inputs?: Record; + outputs?: Record; + branding?: Branding; +}; + +/** + * actionsGenReadme parses the action.yml file and auto-generates README.md + * inputs and outputs in a consistent format. + */ +export async function actionsGenReadme(dir = '') { + // For testing + if (dir) { + process.chdir(dir); + } + + const readmeContents = (await readFile('README.md', 'utf8')).split('\n'); + + const actionContents = await readFile('action.yml', 'utf8'); + const action = YAML.parse(actionContents) as ActionYML; + + const actionInputs = Object.entries(action.inputs || {}); + if (actionInputs.length === 0) console.warn(`action.yml inputs are empty`); + const inputs = []; + for (const [input, opts] of actionInputs) { + const required = opts.required ? 'Required' : 'Optional'; + const description = (opts.description || '') + .split('\n') + .map((line) => (line.trim() === '' ? '' : ` ${line}`)) + .join('\n') + .trim(); + if (description === '') { + throw new Error(`Input "${input}" is missing a description`); + } + const def = opts.default ? `, default: \`${opts.default}\`` : ''; + inputs.push( + `- ${input}: _(${required}${def})_ ${description}\n`, + ); + } + const startInputs = readmeContents.indexOf(''); + const endInputs = readmeContents.indexOf(''); + readmeContents.splice(startInputs + 1, endInputs - startInputs - 1, '', ...inputs, ''); + + const actionOutputs = Object.entries(action.outputs || {}); + if (actionOutputs.length === 0) console.warn(`action.yml outputs are empty`); + const outputs = []; + for (const [output, opts] of actionOutputs) { + const description = (opts?.description || '') + .split('\n') + .map((line) => (line.trim() === '' ? '' : ` ${line}`)) + .join('\n') + .trim(); + if (description === '') { + throw new Error(`Output "${output}" is missing a description`); + } + outputs.push( + `- ${output}: ${description}\n`, + ); + } + const startOutputs = readmeContents.indexOf(''); + const endOutputs = readmeContents.indexOf(''); + readmeContents.splice(startOutputs + 1, endOutputs - startOutputs - 1, '', ...outputs, ''); + + await writeFile('README.md', readmeContents.join('\n'), 'utf8'); +} diff --git a/src/index.ts b/src/index.ts index f4fa52a..e983db5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export * from './actions-gen-readme'; export * from './auth'; export * from './clone'; export * from './csv'; diff --git a/tests/actions-gen-readme.test.ts b/tests/actions-gen-readme.test.ts new file mode 100644 index 0000000..464db90 --- /dev/null +++ b/tests/actions-gen-readme.test.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { actionsGenReadme } from '../src/actions-gen-readme'; +import { writeSecureFile } from '../src/fs'; + +describe('actions-gen-readme', { concurrency: true }, async () => { + test('#actionsGenReadme', async (suite) => { + let scratchDir = ''; + + // Ignore warnings and log messages in test output + suite.mock.method(console, 'log', () => {}); + suite.mock.method(console, 'warn', () => {}); + + suite.beforeEach(async () => { + scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), 'actions-gen-readme-')); + }); + + suite.afterEach(async () => { + if (scratchDir) { + // For some unknown reason, Windows will sometimes fail these tests. To + // make things less flakey, retry. + for (let i = 0; i < 5; i++) { + try { + await fs.rm(scratchDir, { recursive: true, force: true }); + } catch { + await new Promise((r) => setTimeout(r, i * 500)); + } + } + } + }); + + const cases = [ + { + name: 'input missing description', + error: 'Input "foo" is missing a description', + actionYAML: ` + name: 'my-action' + inputs: + foo: + required: true + `, + }, + { + name: 'output missing description', + error: 'Output "foo" is missing a description', + actionYAML: ` + name: 'my-action' + outputs: + foo: + `, + }, + { + name: 'generates', + actionYAML: ` + name: 'my-action' + inputs: + foo: + description: |- + This is the description of foo. + default: '55' + required: true + outputs: + foo: + description: |- + The space between. + `, + expectedReadme: ` + ## Inputs + + + - foo: _(Required, default: \`55\`)_ This is the description of foo. + + + + + ## Outputs + + + - foo: The space between. + + + + `, + }, + ]; + + for await (const tc of cases) { + await suite.test(tc.name, async () => { + const readmePath = path.join(scratchDir, 'README.md'); + await writeSecureFile( + readmePath, + trimLeft( + ` + ## Inputs + + + + ## Outputs + + + `, + 10, + ), + ); + + // Create the yaml contents + if (tc.actionYAML) { + await writeSecureFile(path.join(scratchDir, 'action.yml'), trimLeft(tc.actionYAML, 10)); + } + + if (tc.error) { + await assert.rejects(async () => { + await actionsGenReadme(scratchDir); + }, new RegExp(tc.error)); + } else { + // Success output + await actionsGenReadme(scratchDir); + + const readmeContents = await fs.readFile(readmePath, { encoding: 'utf8' }); + const expectedReadme = trimLeft(tc.expectedReadme || '', 10); + assert.equal(readmeContents, expectedReadme); + } + }); + } + }); +}); + +function trimLeft(s: string, align: number): string { + return s + .split('\n') + .map((l) => l.slice(align)) + .join('\n') + .trim(); +} diff --git a/tests/fixtures/actions-gen-readme/README.md b/tests/fixtures/actions-gen-readme/README.md new file mode 100644 index 0000000..7c696a5 --- /dev/null +++ b/tests/fixtures/actions-gen-readme/README.md @@ -0,0 +1,15 @@ +# Project + +This is a readme. + +## Inputs + + + + +## Outputs + + + + +It has stuff after. diff --git a/tests/fixtures/actions-gen-readme/action.yml b/tests/fixtures/actions-gen-readme/action.yml new file mode 100644 index 0000000..049fddb --- /dev/null +++ b/tests/fixtures/actions-gen-readme/action.yml @@ -0,0 +1,43 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: 'A fixture action' +author: 'Google LLC' +description: |- + Does fixture things. + +inputs: + one: + description: |- + Does things. + default: 'latest' + required: false + + two: + description: |- + Does other things. + required: false + +outputs: + one: + description: |- + Has things. + +branding: + icon: 'terminal' + color: 'blue' + +runs: + using: 'node24' + main: 'dist/index.js'