Skip to content

Commit 941094f

Browse files
Copilotvirgofx
andauthored
feat: Add module reference mode configuration for Terraform module documentation (#283)
- Implemented `module-ref-mode` input to control how module versions are referenced in generated documentation (options: "tag" or "sha"). - Updated `configureGitAuthentication` function to handle GitHub token authentication for HTTPS operations. - Enhanced tests for `configureGitAuthentication` to cover various scenarios including error handling and domain-specific configurations. - Modified `generateWikiFiles` function to utilize the new `moduleRefMode` configuration, adjusting the reference format based on the selected mode. - Refactored `TerraformModule` class to store tags as objects containing both name and commit SHA, allowing for better version management. - Updated metadata and constants to include new module reference mode options and validation. - Adjusted action YAML to include new input for `module-ref-mode` with appropriate descriptions. Co-authored-by: Mark Johnson <739719+virgofx@users.noreply.github.com>
1 parent bacc3fa commit 941094f

26 files changed

+776
-341
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ jobs:
5757
module-asset-exclude-patterns: .gitignore,*.md,*.tftest.hcl,tests/**
5858
use-ssh-source-format: true
5959
tag-directory-separator: "-"
60-
use-version-prefix: false
60+
use-version-prefix: true
61+
module-ref-mode: tag
6162

6263
- name: Test Action Outputs
6364
id: test-outputs

README.md

Lines changed: 29 additions & 19 deletions
Large diffs are not rendered by default.

__tests__/config.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,28 @@ describe('config', () => {
226226
new TypeError("Default first tag must be in format v#.#.# or #.#.# (e.g., v1.0.0 or 1.0.0). Got: 'v1.0'"),
227227
);
228228
});
229+
230+
it('should throw error for invalid module-ref-mode', () => {
231+
setupTestInputs({ 'module-ref-mode': 'invalid' });
232+
expect(() => getConfig()).toThrow(new TypeError("Invalid module_ref_mode 'invalid'. Must be one of: tag, sha"));
233+
234+
clearConfigForTesting();
235+
vi.unstubAllEnvs();
236+
setupTestInputs({ 'module-ref-mode': 'TAG' });
237+
expect(() => getConfig()).toThrow(new TypeError("Invalid module_ref_mode 'TAG'. Must be one of: tag, sha"));
238+
});
239+
240+
it('should allow valid module-ref-mode values', () => {
241+
setupTestInputs({ 'module-ref-mode': 'tag' });
242+
let config = getConfig();
243+
expect(config.moduleRefMode).toBe('tag');
244+
245+
clearConfigForTesting();
246+
vi.unstubAllEnvs();
247+
setupTestInputs({ 'module-ref-mode': 'sha' });
248+
config = getConfig();
249+
expect(config.moduleRefMode).toBe('sha');
250+
});
229251
});
230252

231253
describe('initialization', () => {
@@ -256,6 +278,7 @@ describe('config', () => {
256278
expect(config.useSSHSourceFormat).toBe(false);
257279
expect(config.tagDirectorySeparator).toBe('/');
258280
expect(config.useVersionPrefix).toBe(true);
281+
expect(config.moduleRefMode).toBe('tag');
259282

260283
expect(startGroup).toHaveBeenCalledWith('Initializing Config');
261284
expect(startGroup).toHaveBeenCalledTimes(1);
@@ -275,6 +298,7 @@ describe('config', () => {
275298
['Use SSH Source Format: false'],
276299
['Tag Directory Separator: /'],
277300
['Use Version Prefix: true'],
301+
['Module Ref Mode: tag'],
278302
]);
279303
});
280304
});

__tests__/helpers/terraform-module.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
import { TerraformModule } from '@/terraform-module';
2-
import type { CommitDetails, GitHubRelease } from '@/types';
2+
import type { CommitDetails, GitHubRelease, GitHubTag } from '@/types';
3+
4+
/**
5+
* Helper function to create a GitHubTag from a tag name.
6+
* Generates a fake commit SHA for testing purposes.
7+
*
8+
* @param name - The tag name
9+
* @param commitSHA - Optional commit SHA (defaults to a hash of the tag name)
10+
* @returns A GitHubTag object
11+
*/
12+
export function createMockTag(name: string, commitSHA?: string): GitHubTag {
13+
// Generate a simple hash if no SHA provided
14+
const defaultSHA = commitSHA ?? `sha${name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)}`;
15+
return { name, commitSHA: defaultSHA };
16+
}
17+
18+
/**
19+
* Helper function to create multiple GitHubTags from tag names.
20+
*
21+
* @param names - Array of tag names
22+
* @returns Array of GitHubTag objects
23+
*/
24+
export function createMockTags(names: string[]): GitHubTag[] {
25+
return names.map((name) => createMockTag(name));
26+
}
327

428
/**
529
* Helper function to create a TerraformModule instance for testing.
@@ -9,7 +33,7 @@ import type { CommitDetails, GitHubRelease } from '@/types';
933
* @param options.latestTag - Optional latest tag for the module
1034
* @param options.commits - Optional array of commit details
1135
* @param options.commitMessages - Optional array of commit messages (will create commits)
12-
* @param options.tags - Optional array of tags
36+
* @param options.tags - Optional array of tag names (will be converted to GitHubTag objects)
1337
* @param options.releases - Optional array of releases
1438
* @returns A configured TerraformModule instance for testing
1539
*/
@@ -39,7 +63,8 @@ export function createMockTerraformModule(options: {
3963
module.addCommit(commit);
4064
}
4165

42-
module.setTags(tags);
66+
// Convert string tags to GitHubTag objects
67+
module.setTags(createMockTags(tags));
4368
module.setReleases(releases);
4469

4570
return module;

__tests__/parser.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { join } from 'node:path';
44
import { config } from '@/mocks/config';
55
import { context } from '@/mocks/context';
66
import { parseTerraformModules } from '@/parser';
7+
import { createMockTags } from '@/tests/helpers/terraform-module';
78
import type { CommitDetails, GitHubRelease } from '@/types';
89
import { endGroup, info, startGroup } from '@actions/core';
910
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -271,11 +272,11 @@ describe('parseTerraformModules', () => {
271272
writeFileSync(join(moduleDir, 'main.tf'), 'resource "aws_vpc" "main" {}');
272273

273274
const tags = ['modules/vpc/v1.0.0', 'modules/vpc/v1.1.0', 'modules/sg/v1.0.0'];
274-
const result = parseTerraformModules([], tags, []);
275+
const result = parseTerraformModules([], createMockTags(tags), []);
275276

276277
expect(result).toHaveLength(1);
277278
// Tags are sorted in descending order (newest first) by setTags method
278-
expect(result[0].tags).toEqual(['modules/vpc/v1.1.0', 'modules/vpc/v1.0.0']);
279+
expect(result[0].tags.map((t) => t.name)).toEqual(['modules/vpc/v1.1.0', 'modules/vpc/v1.0.0']);
279280
});
280281

281282
it('should set releases on all modules using static method', () => {
@@ -325,15 +326,15 @@ describe('parseTerraformModules', () => {
325326
{ id: 1, title: 'modules/vpc/v1.0.0', tagName: 'modules/vpc/v1.0.0', body: 'VPC release' },
326327
{ id: 2, title: 'other/v1.0.0', tagName: 'other/v1.0.0', body: 'Other release' },
327328
];
328-
const result = parseTerraformModules([], tags, releases);
329+
const result = parseTerraformModules([], createMockTags(tags), releases);
329330

330331
expect(result).toHaveLength(2);
331332
const rdsModule = result.find((m) => m.name === 'modules/rds');
332333
const vpcModule = result.find((m) => m.name === 'modules/vpc');
333334

334-
expect(rdsModule?.tags).toEqual(['modules/rds/v2.0.0']);
335+
expect(rdsModule?.tags.map((t) => t.name)).toEqual(['modules/rds/v2.0.0']);
335336
expect(rdsModule?.releases).toEqual([]);
336-
expect(vpcModule?.tags).toEqual(['modules/vpc/v1.1.0', 'modules/vpc/v1.0.0']); // Descending order
337+
expect(vpcModule?.tags.map((t) => t.name)).toEqual(['modules/vpc/v1.1.0', 'modules/vpc/v1.0.0']); // Descending order
337338
expect(vpcModule?.releases).toEqual([releases[0]]);
338339
});
339340

@@ -346,7 +347,7 @@ describe('parseTerraformModules', () => {
346347
const releases: GitHubRelease[] = [
347348
{ id: 1, title: 'modules/vpc/v1.0.0', tagName: 'modules/vpc/v1.0.0', body: 'VPC release' },
348349
];
349-
const result = parseTerraformModules([], tags, releases);
350+
const result = parseTerraformModules([], createMockTags(tags), releases);
350351

351352
expect(result).toHaveLength(1);
352353
expect(result[0].name).toBe('modules/networking');

__tests__/releases.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { execFileSync } from 'node:child_process';
12
import { tmpdir } from 'node:os';
23
import { join } from 'node:path';
34
import { context } from '@/mocks/context';
@@ -23,6 +24,8 @@ vi.mock('node:fs', () => ({
2324
readdirSync: vi.fn().mockImplementation(() => []),
2425
}));
2526

27+
const execFileSyncMock = vi.mocked(execFileSync);
28+
2629
describe('releases', () => {
2730
const url = 'https://api.github.com/repos/techpivot/terraform-module-releaser/releases';
2831
const mockListReleasesResponse = {
@@ -294,6 +297,14 @@ describe('releases', () => {
294297
context.set({
295298
workspaceDir: '/workspace',
296299
});
300+
execFileSyncMock.mockReset();
301+
execFileSyncMock.mockImplementation((_file, args) => {
302+
if (Array.isArray(args) && args.includes('rev-parse')) {
303+
return Buffer.from('abc123def456');
304+
}
305+
306+
return Buffer.from('');
307+
});
297308
mockTerraformModule = createMockTerraformModule({
298309
directory: '/workspace/path/to/test-module',
299310
commits: [
@@ -327,6 +338,7 @@ describe('releases', () => {
327338
name: 'path/to/test-module/v1.1.0',
328339
body: 'Mock changelog content',
329340
tag_name: 'path/to/test-module/v1.1.0',
341+
target_commitish: 'abc123def456',
330342
draft: false,
331343
prerelease: false,
332344
},
@@ -352,19 +364,33 @@ describe('releases', () => {
352364
},
353365
...originalReleases,
354366
]);
355-
expect(mockTerraformModule.setTags).toHaveBeenCalledWith(['path/to/test-module/v1.1.0', ...originalTags]);
367+
expect(mockTerraformModule.setTags).toHaveBeenCalledWith([
368+
{
369+
name: 'path/to/test-module/v1.1.0',
370+
commitSHA: 'abc123def456',
371+
},
372+
...originalTags,
373+
]);
356374
expect(mockTerraformModule.needsRelease()).toBe(false);
357375
expect(startGroup).toHaveBeenCalledWith('Creating releases & tags for modules');
358376
expect(endGroup).toHaveBeenCalled();
359377
});
360378

361379
it('should handle null/undefined name and body from GitHub API response', async () => {
380+
execFileSyncMock.mockImplementation((_file, args) => {
381+
if (Array.isArray(args) && args.includes('rev-parse')) {
382+
return Buffer.from('def456abc789');
383+
}
384+
385+
return Buffer.from('');
386+
});
362387
const mockRelease = {
363388
data: {
364389
id: 789012,
365390
name: null, // Simulate GitHub API returning null for name
366391
body: undefined, // Simulate GitHub API returning undefined for body
367392
tag_name: 'path/to/test-module/v1.1.0',
393+
target_commitish: 'def456abc789',
368394
draft: false,
369395
prerelease: false,
370396
},
@@ -390,7 +416,13 @@ describe('releases', () => {
390416
expect(newRelease.body).toContain('v1.1.0'); // Should fall back to generated changelog since body is undefined
391417
expect(newRelease.body).toContain('feat: Add new feature'); // Should contain the commit message
392418

393-
expect(mockTerraformModule.setTags).toHaveBeenCalledWith(['path/to/test-module/v1.1.0', ...originalTags]);
419+
expect(mockTerraformModule.setTags).toHaveBeenCalledWith([
420+
{
421+
name: 'path/to/test-module/v1.1.0',
422+
commitSHA: 'def456abc789',
423+
},
424+
...originalTags,
425+
]);
394426
expect(endGroup).toHaveBeenCalled();
395427
});
396428

__tests__/tags.test.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,14 @@ describe('tags', () => {
2929

3030
expect(Array.isArray(tags)).toBe(true);
3131

32-
// Known tags
32+
// Extract tag names from the tag objects
33+
const tagNames = tags.map((tag) => tag.name);
34+
35+
// Known tags that should exist in the repository
3336
const knownTags = ['v1.3.1', 'v1.3.0', 'v1.2.0', 'v1.1.1', 'v1.1.0', 'v1.0.1', 'v1.0.0', 'v1'];
3437
// Ensure all known tags are present in the fetched tags using a for...of loop
3538
for (const tag of knownTags) {
36-
expect(tags).toContain(tag);
39+
expect(tagNames).toContain(tag);
3740
}
3841

3942
expect(startGroup).toHaveBeenCalledWith('Fetching repository tags');
@@ -74,8 +77,14 @@ describe('tags', () => {
7477
});
7578

7679
it('should fetch all available tags when pagination is required', async () => {
77-
const mockTagData = { data: [{ name: 'v2.0.0' }, { name: 'v2.0.1' }, { name: 'v2.0.2' }] };
78-
const expectedTags = mockTagData.data.map((tag) => tag.name);
80+
const mockTagData = {
81+
data: [
82+
{ name: 'v2.0.0', commit: { sha: 'abc123' } },
83+
{ name: 'v2.0.1', commit: { sha: 'def456' } },
84+
{ name: 'v2.0.2', commit: { sha: 'ghi789' } },
85+
],
86+
};
87+
const expectedTags = mockTagData.data.map((tag) => ({ name: tag.name, commitSHA: tag.commit.sha }));
7988

8089
stubOctokitReturnData('repos.listTags', mockTagData);
8190
const tags = await getAllTags({ per_page: 1 });
@@ -88,15 +97,16 @@ describe('tags', () => {
8897

8998
// Additional assertions to verify pagination calls and debug info
9099
expect(info).toHaveBeenCalledWith('Found 3 tags.');
100+
// Debug logs the tags array with {name, commitSHA} structure
91101
expect(vi.mocked(debug).mock.calls).toEqual([
92102
['Total page requests: 3'],
93103
[JSON.stringify(expectedTags, null, 2)],
94104
]);
95105
});
96106

97107
it('should output singular "tag" when only one', async () => {
98-
const mockTagData = { data: [{ name: 'v4.0.0' }] };
99-
const expectedTags = mockTagData.data.map((tag) => tag.name);
108+
const mockTagData = { data: [{ name: 'v4.0.0', commit: { sha: 'abc123' } }] };
109+
const expectedTags = mockTagData.data.map((tag) => ({ name: tag.name, commitSHA: tag.commit.sha }));
100110

101111
stubOctokitReturnData('repos.listTags', mockTagData);
102112
const tags = await getAllTags({ per_page: 1 });
@@ -116,7 +126,13 @@ describe('tags', () => {
116126
});
117127

118128
it('should fetch all available tags when pagination is not required', async () => {
119-
stubOctokitReturnData('repos.listTags', { data: [{ name: 'v2.0.0' }, { name: 'v2.0.1' }, { name: 'v2.0.2' }] });
129+
stubOctokitReturnData('repos.listTags', {
130+
data: [
131+
{ name: 'v2.0.0', commit: { sha: 'abc123' } },
132+
{ name: 'v2.0.1', commit: { sha: 'def456' } },
133+
{ name: 'v2.0.2', commit: { sha: 'ghi789' } },
134+
],
135+
});
120136

121137
const tags = await getAllTags({ per_page: 20 });
122138

@@ -125,7 +141,7 @@ describe('tags', () => {
125141

126142
// Exact match of known tags to ensure no unexpected tags are included
127143
const expectedTags = ['v2.0.0', 'v2.0.1', 'v2.0.2'];
128-
expect(tags).toEqual(expectedTags);
144+
expect(tags.map((t) => t.name)).toEqual(expectedTags);
129145

130146
// Additional assertions to verify pagination calls and debug info
131147
expect(debug).toHaveBeenCalledWith(expect.stringMatching(/Total page requests: 1/));

0 commit comments

Comments
 (0)