Skip to content

Commit 360fd12

Browse files
authored
Fix actions-gen-readme, with tests now (#145)
1 parent 3be370a commit 360fd12

File tree

7 files changed

+351
-61
lines changed

7 files changed

+351
-61
lines changed

.github/workflows/test.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,31 @@ jobs:
4646

4747
- name: 'npm test'
4848
run: 'npm run test'
49+
50+
actions-gen-readme:
51+
runs-on: 'ubuntu-latest'
52+
steps:
53+
- uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4
54+
55+
- uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
56+
with:
57+
node-version-file: 'package.json'
58+
59+
- name: 'npm build'
60+
run: 'npm ci && npm run build'
61+
62+
- name: 'Install command globally'
63+
run: |-
64+
npm install -g .
65+
66+
- name: 'Copy fixtures and generate README'
67+
run: |-
68+
cp -R "./tests/fixtures/actions-gen-readme" "${RUNNER_TEMP}/actions-gen-readme"
69+
(cd "${RUNNER_TEMP}/actions-gen-readme" && actions-gen-readme)
70+
71+
- name: 'Verify output'
72+
run: |-
73+
grep "Does things." "${RUNNER_TEMP}/actions-gen-readme/README.md"
74+
grep "Does other things." "${RUNNER_TEMP}/actions-gen-readme/README.md"
75+
grep "Has things." "${RUNNER_TEMP}/actions-gen-readme/README.md"
76+
cat "${RUNNER_TEMP}/actions-gen-readme/README.md"

bin/actions-gen-readme.mjs

100644100755
Lines changed: 5 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,9 @@
11
#!/usr/bin/env node
22

3-
/*
4-
* Copyright 2024 Google LLC
5-
*
6-
* Licensed under the Apache License, Version 2.0 (the "License");
7-
* you may not use this file except in compliance with the License.
8-
* You may obtain a copy of the License at
9-
*
10-
* http://www.apache.org/licenses/LICENSE-2.0
11-
*
12-
* Unless required by applicable law or agreed to in writing, software
13-
* distributed under the License is distributed on an "AS IS" BASIS,
14-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15-
* See the License for the specific language governing permissions and
16-
* limitations under the License.
17-
*/
3+
import { actionsGenReadme } from '../dist/index.js';
184

19-
import { readFile, writeFile } from 'fs/promises';
20-
import * as YAML from 'yaml';
21-
22-
async function run() {
23-
const readmeContents = (await readFile('README.md', 'utf8')).split('\n');
24-
25-
const actionContents = await readFile('action.yml', 'utf8');
26-
const action = YAML.parse(actionContents);
27-
28-
const actionInputs = Object.entries(action.inputs || {});
29-
if (actionInputs.length === 0) console.warn(`action.yml inputs are empty`);
30-
const inputs = [];
31-
for (const [input, opts] of Object.entries(actionInputs)) {
32-
const required = opts.required ? 'Required' : 'Optional';
33-
const description = opts.description
34-
.split('\n')
35-
.map((line) => (line.trim() === '' ? '' : ` ${line}`))
36-
.join('\n')
37-
.trim();
38-
const def = opts.default ? `, default: \`${opts.default}\`` : '';
39-
inputs.push(
40-
`- <a name="${input}"></a><a href="#user-content-${input}"><code>${input}</code></a>: _(${required}${def})_ ${description}\n`,
41-
);
42-
}
43-
const startInputs = readmeContents.indexOf('<!-- BEGIN_AUTOGEN_INPUTS -->');
44-
const endInputs = readmeContents.indexOf('<!-- END_AUTOGEN_INPUTS -->');
45-
readmeContents.splice(startInputs + 1, endInputs - startInputs - 1, '', ...inputs, '');
46-
47-
const actionOutputs = Object.entries(action.outputs || {});
48-
if (actionOutputs.length === 0) console.warn(`action.yml outputs are empty`);
49-
const outputs = [];
50-
for (const [output, opts] of Object.entries(actionOutputs)) {
51-
const description = opts.description
52-
.split('\n')
53-
.map((line) => (line.trim() === '' ? '' : ` ${line}`))
54-
.join('\n')
55-
.trim();
56-
outputs.push(`- \`${output}\`: ${description}\n`);
57-
}
58-
const startOutputs = readmeContents.indexOf('<!-- BEGIN_AUTOGEN_OUTPUTS -->');
59-
const endOutputs = readmeContents.indexOf('<!-- END_AUTOGEN_OUTPUTS -->');
60-
readmeContents.splice(startOutputs + 1, endOutputs - startOutputs - 1, '', ...outputs, '');
61-
62-
await writeFile('README.md', readmeContents.join('\n'), 'utf8');
5+
try {
6+
await actionsGenReadme();
7+
} catch (err) {
8+
console.error(err);
639
}
64-
65-
await run();

src/actions-gen-readme.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env node
2+
3+
/*
4+
* Copyright 2024 Google LLC
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
import { readFile, writeFile } from 'fs/promises';
20+
import * as YAML from 'yaml';
21+
22+
type CommonOption = {
23+
required: boolean;
24+
description?: string;
25+
};
26+
27+
type InputOption = CommonOption & {
28+
default: string;
29+
};
30+
31+
type OutputOption = CommonOption & {};
32+
33+
type Branding = {
34+
icon: string;
35+
color: string;
36+
};
37+
38+
type ActionYML = {
39+
name: string;
40+
description: string;
41+
inputs?: Record<string, InputOption>;
42+
outputs?: Record<string, OutputOption | undefined>;
43+
branding?: Branding;
44+
};
45+
46+
/**
47+
* actionsGenReadme parses the action.yml file and auto-generates README.md
48+
* inputs and outputs in a consistent format.
49+
*/
50+
export async function actionsGenReadme(dir = '') {
51+
// For testing
52+
if (dir) {
53+
process.chdir(dir);
54+
}
55+
56+
const readmeContents = (await readFile('README.md', 'utf8')).split('\n');
57+
58+
const actionContents = await readFile('action.yml', 'utf8');
59+
const action = YAML.parse(actionContents) as ActionYML;
60+
61+
const actionInputs = Object.entries(action.inputs || {});
62+
if (actionInputs.length === 0) console.warn(`action.yml inputs are empty`);
63+
const inputs = [];
64+
for (const [input, opts] of actionInputs) {
65+
const required = opts.required ? 'Required' : 'Optional';
66+
const description = (opts.description || '')
67+
.split('\n')
68+
.map((line) => (line.trim() === '' ? '' : ` ${line}`))
69+
.join('\n')
70+
.trim();
71+
if (description === '') {
72+
throw new Error(`Input "${input}" is missing a description`);
73+
}
74+
const def = opts.default ? `, default: \`${opts.default}\`` : '';
75+
inputs.push(
76+
`- <a name="__input_${input}"></a><a href="#user-content-__input_${input}"><code>${input}</code></a>: _(${required}${def})_ ${description}\n`,
77+
);
78+
}
79+
const startInputs = readmeContents.indexOf('<!-- BEGIN_AUTOGEN_INPUTS -->');
80+
const endInputs = readmeContents.indexOf('<!-- END_AUTOGEN_INPUTS -->');
81+
readmeContents.splice(startInputs + 1, endInputs - startInputs - 1, '', ...inputs, '');
82+
83+
const actionOutputs = Object.entries(action.outputs || {});
84+
if (actionOutputs.length === 0) console.warn(`action.yml outputs are empty`);
85+
const outputs = [];
86+
for (const [output, opts] of actionOutputs) {
87+
const description = (opts?.description || '')
88+
.split('\n')
89+
.map((line) => (line.trim() === '' ? '' : ` ${line}`))
90+
.join('\n')
91+
.trim();
92+
if (description === '') {
93+
throw new Error(`Output "${output}" is missing a description`);
94+
}
95+
outputs.push(
96+
`- <a name="__output_${output}"></a><a href="#user-content-__output_${output}"><code>${output}</code></a>: ${description}\n`,
97+
);
98+
}
99+
const startOutputs = readmeContents.indexOf('<!-- BEGIN_AUTOGEN_OUTPUTS -->');
100+
const endOutputs = readmeContents.indexOf('<!-- END_AUTOGEN_OUTPUTS -->');
101+
readmeContents.splice(startOutputs + 1, endOutputs - startOutputs - 1, '', ...outputs, '');
102+
103+
await writeFile('README.md', readmeContents.join('\n'), 'utf8');
104+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17+
export * from './actions-gen-readme';
1718
export * from './auth';
1819
export * from './clone';
1920
export * from './csv';

tests/actions-gen-readme.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { describe, test } from 'node:test';
18+
import assert from 'node:assert/strict';
19+
20+
import { promises as fs } from 'node:fs';
21+
import os from 'node:os';
22+
import path from 'node:path';
23+
24+
import { actionsGenReadme } from '../src/actions-gen-readme';
25+
import { writeSecureFile } from '../src/fs';
26+
27+
describe('actions-gen-readme', { concurrency: true }, async () => {
28+
test('#actionsGenReadme', async (suite) => {
29+
let scratchDir = '';
30+
31+
// Ignore warnings and log messages in test output
32+
suite.mock.method(console, 'log', () => {});
33+
suite.mock.method(console, 'warn', () => {});
34+
35+
suite.beforeEach(async () => {
36+
scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), 'actions-gen-readme-'));
37+
});
38+
39+
suite.afterEach(async () => {
40+
if (scratchDir) {
41+
// For some unknown reason, Windows will sometimes fail these tests. To
42+
// make things less flakey, retry.
43+
for (let i = 0; i < 5; i++) {
44+
try {
45+
await fs.rm(scratchDir, { recursive: true, force: true });
46+
} catch {
47+
await new Promise((r) => setTimeout(r, i * 500));
48+
}
49+
}
50+
}
51+
});
52+
53+
const cases = [
54+
{
55+
name: 'input missing description',
56+
error: 'Input "foo" is missing a description',
57+
actionYAML: `
58+
name: 'my-action'
59+
inputs:
60+
foo:
61+
required: true
62+
`,
63+
},
64+
{
65+
name: 'output missing description',
66+
error: 'Output "foo" is missing a description',
67+
actionYAML: `
68+
name: 'my-action'
69+
outputs:
70+
foo:
71+
`,
72+
},
73+
{
74+
name: 'generates',
75+
actionYAML: `
76+
name: 'my-action'
77+
inputs:
78+
foo:
79+
description: |-
80+
This is the description of foo.
81+
default: '55'
82+
required: true
83+
outputs:
84+
foo:
85+
description: |-
86+
The space between.
87+
`,
88+
expectedReadme: `
89+
## Inputs
90+
<!-- BEGIN_AUTOGEN_INPUTS -->
91+
92+
- <a name="__input_foo"></a><a href="#user-content-__input_foo"><code>foo</code></a>: _(Required, default: \`55\`)_ This is the description of foo.
93+
94+
95+
<!-- END_AUTOGEN_INPUTS -->
96+
97+
## Outputs
98+
<!-- BEGIN_AUTOGEN_OUTPUTS -->
99+
100+
- <a name="__output_foo"></a><a href="#user-content-__output_foo"><code>foo</code></a>: The space between.
101+
102+
103+
<!-- END_AUTOGEN_OUTPUTS -->
104+
`,
105+
},
106+
];
107+
108+
for await (const tc of cases) {
109+
await suite.test(tc.name, async () => {
110+
const readmePath = path.join(scratchDir, 'README.md');
111+
await writeSecureFile(
112+
readmePath,
113+
trimLeft(
114+
`
115+
## Inputs
116+
<!-- BEGIN_AUTOGEN_INPUTS -->
117+
<!-- END_AUTOGEN_INPUTS -->
118+
119+
## Outputs
120+
<!-- BEGIN_AUTOGEN_OUTPUTS -->
121+
<!-- END_AUTOGEN_OUTPUTS -->
122+
`,
123+
10,
124+
),
125+
);
126+
127+
// Create the yaml contents
128+
if (tc.actionYAML) {
129+
await writeSecureFile(path.join(scratchDir, 'action.yml'), trimLeft(tc.actionYAML, 10));
130+
}
131+
132+
if (tc.error) {
133+
await assert.rejects(async () => {
134+
await actionsGenReadme(scratchDir);
135+
}, new RegExp(tc.error));
136+
} else {
137+
// Success output
138+
await actionsGenReadme(scratchDir);
139+
140+
const readmeContents = await fs.readFile(readmePath, { encoding: 'utf8' });
141+
const expectedReadme = trimLeft(tc.expectedReadme || '', 10);
142+
assert.equal(readmeContents, expectedReadme);
143+
}
144+
});
145+
}
146+
});
147+
});
148+
149+
function trimLeft(s: string, align: number): string {
150+
return s
151+
.split('\n')
152+
.map((l) => l.slice(align))
153+
.join('\n')
154+
.trim();
155+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Project
2+
3+
This is a readme.
4+
5+
## Inputs
6+
7+
<!-- BEGIN_AUTOGEN_INPUTS -->
8+
<!-- END_AUTOGEN_INPUTS -->
9+
10+
## Outputs
11+
12+
<!-- BEGIN_AUTOGEN_OUTPUTS -->
13+
<!-- END_AUTOGEN_OUTPUTS -->
14+
15+
It has stuff after.

0 commit comments

Comments
 (0)