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'