From a97248639d32ce03f1cc7a586ea30a13d7a806b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 04:37:39 +0000 Subject: [PATCH 1/3] Initial plan From 63afce67f146e445717ce90f97f709415f196a28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 04:49:54 +0000 Subject: [PATCH 2/3] Add export-changelog-files feature implementation Co-authored-by: virgofx <739719+virgofx@users.noreply.github.com> --- __tests__/changelog.test.ts | 127 +++++++++++++++++++++++++++++++ __tests__/config.test.ts | 2 + __tests__/utils/metadata.test.ts | 2 + action.yml | 8 ++ src/changelog.ts | 83 ++++++++++++++++++++ src/config.ts | 1 + src/main.ts | 72 +++++++++++++++++- src/types/config.types.ts | 8 ++ src/utils/metadata.ts | 1 + 9 files changed, 303 insertions(+), 1 deletion(-) diff --git a/__tests__/changelog.test.ts b/__tests__/changelog.test.ts index b743664..1c9d805 100644 --- a/__tests__/changelog.test.ts +++ b/__tests__/changelog.test.ts @@ -1,11 +1,14 @@ import { createTerraformModuleChangelog, + generateChangelogFiles, getPullRequestChangelog, getTerraformModuleFullReleaseChangelog, } from '@/changelog'; import { context } from '@/mocks/context'; import type { TerraformModule } from '@/terraform-module'; import { createMockTerraformModule } from '@/tests/helpers/terraform-module'; +import { existsSync, promises as fsp } from 'node:fs'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('changelog', () => { @@ -215,4 +218,128 @@ describe('changelog', () => { expect(getTerraformModuleFullReleaseChangelog(terraformModule)).toBe('Single release content'); }); }); + + describe('generateChangelogFiles()', () => { + const tmpDir = '/tmp/changelog-test'; + + beforeEach(async () => { + // Create temp directory for test files + await fsp.mkdir(tmpDir, { recursive: true }); + }); + + afterEach(async () => { + // Cleanup test files + if (existsSync(tmpDir)) { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should generate CHANGELOG.md files for modules needing release', async () => { + const terraformModules: TerraformModule[] = [ + createMockTerraformModule({ + directory: 'test/module1', + commitMessages: ['feat: Add new feature', 'fix: Fix bug'], + }), + createMockTerraformModule({ + directory: 'test/module2', + commitMessages: ['feat: Another feature'], + }), + ]; + + // Create module directories using actual absolute paths + for (const module of terraformModules) { + const fullPath = join(tmpDir, module.name); + await fsp.mkdir(fullPath, { recursive: true }); + // Override the directory with absolute path for file operations + Object.defineProperty(module, 'directory', { value: fullPath, writable: true }); + } + + const changelogFiles = await generateChangelogFiles(terraformModules); + + expect(changelogFiles).toHaveLength(2); + expect(changelogFiles[0]).toContain('CHANGELOG.md'); + expect(changelogFiles[1]).toContain('CHANGELOG.md'); + + // Verify file contents + const changelog1 = await fsp.readFile(changelogFiles[0], 'utf8'); + expect(changelog1).toContain('# Changelog - test/module1'); + expect(changelog1).toContain('All notable changes to this module will be documented in this file.'); + expect(changelog1).toContain('## `v1.0.0` (2024-11-05)'); + expect(changelog1).toContain('feat: Add new feature'); + expect(changelog1).toContain('fix: Fix bug'); + + const changelog2 = await fsp.readFile(changelogFiles[1], 'utf8'); + expect(changelog2).toContain('# Changelog - test/module2'); + expect(changelog2).toContain('feat: Another feature'); + }); + + it('should include historical releases in changelog', async () => { + const terraformModule = createMockTerraformModule({ + directory: 'test/module-with-history', + commitMessages: ['feat: New feature'], + tags: ['test/module-with-history/v1.0.0'], // Existing tag + releases: [ + { + id: 1, + title: 'test/module-with-history/v1.0.0', + body: '## `v1.0.0` (2024-01-01)\n\n- Initial release', + tagName: 'test/module-with-history/v1.0.0', + }, + ], + }); + + const fullPath = join(tmpDir, terraformModule.name); + await fsp.mkdir(fullPath, { recursive: true }); + Object.defineProperty(terraformModule, 'directory', { value: fullPath, writable: true }); + + const changelogFiles = await generateChangelogFiles([terraformModule]); + + expect(changelogFiles).toHaveLength(1); + + const changelog = await fsp.readFile(changelogFiles[0], 'utf8'); + expect(changelog).toContain('# Changelog - test/module-with-history'); + expect(changelog).toContain('## `v1.1.0` (2024-11-05)'); // New release + expect(changelog).toContain('feat: New feature'); + expect(changelog).toContain('## `v1.0.0` (2024-01-01)'); // Historical release + expect(changelog).toContain('Initial release'); + }); + + it('should return empty array when no modules need release', async () => { + const terraformModules: TerraformModule[] = [ + createMockTerraformModule({ + directory: 'test/no-release-module', + commitMessages: [], // No commits - no release needed + tags: ['test/no-release-module/v1.0.0'], // Already has a tag + }), + ]; + + const changelogFiles = await generateChangelogFiles(terraformModules); + + expect(changelogFiles).toHaveLength(0); + }); + + it('should handle empty module array', async () => { + const changelogFiles = await generateChangelogFiles([]); + + expect(changelogFiles).toHaveLength(0); + }); + + it('should skip modules where changelog generation returns empty', async () => { + const terraformModule = createMockTerraformModule({ + directory: 'test/edge-case-module', + commitMessages: ['feat: some change'], + }); + + // Mock getReleaseTagVersion to return null (edge case) + vi.spyOn(terraformModule, 'getReleaseTagVersion').mockReturnValue(null); + + const fullPath = join(tmpDir, terraformModule.name); + await fsp.mkdir(fullPath, { recursive: true }); + Object.defineProperty(terraformModule, 'directory', { value: fullPath, writable: true }); + + const changelogFiles = await generateChangelogFiles([terraformModule]); + + expect(changelogFiles).toHaveLength(0); + }); + }); }); diff --git a/__tests__/config.test.ts b/__tests__/config.test.ts index 1cc95cf..d26fe23 100644 --- a/__tests__/config.test.ts +++ b/__tests__/config.test.ts @@ -247,6 +247,7 @@ describe('config', () => { expect(config.terraformDocsVersion).toBe('v0.20.0'); expect(config.deleteLegacyTags).toBe(true); expect(config.disableWiki).toBe(false); + expect(config.exportChangelogFiles).toBe(false); expect(config.wikiSidebarChangelogMax).toBe(5); expect(config.disableBranding).toBe(false); expect(config.githubToken).toBe('ghp_test_token_2c6912E7710c838347Ae178B4'); @@ -268,6 +269,7 @@ describe('config', () => { ['Terraform Docs Version: v0.20.0'], ['Delete Legacy Tags: true'], ['Disable Wiki: false'], + ['Export Changelog Files: false'], ['Wiki Sidebar Changelog Max: 5'], ['Module Paths to Ignore: '], ['Module Change Exclude Patterns: .gitignore, *.md, *.tftest.hcl, tests/**'], diff --git a/__tests__/utils/metadata.test.ts b/__tests__/utils/metadata.test.ts index e4847a9..db51ce9 100644 --- a/__tests__/utils/metadata.test.ts +++ b/__tests__/utils/metadata.test.ts @@ -14,6 +14,7 @@ describe('utils/metadata', () => { 'terraform-docs-version', 'delete-legacy-tags', 'disable-wiki', + 'export-changelog-files', 'wiki-sidebar-changelog-max', 'wiki-usage-template', 'disable-branding', @@ -47,6 +48,7 @@ describe('utils/metadata', () => { const booleanInputs = [ 'delete-legacy-tags', 'disable-wiki', + 'export-changelog-files', 'disable-branding', 'use-ssh-source-format', 'use-version-prefix', diff --git a/action.yml b/action.yml index 0f5f0d0..0738382 100644 --- a/action.yml +++ b/action.yml @@ -45,6 +45,14 @@ inputs: By default, this is set to false. Set to true to prevent wiki documentation from being generated. required: true default: "false" + export-changelog-files: + description: > + Whether to export CHANGELOG.md files for each Terraform module in the repository. + When enabled, a CHANGELOG.md file will be generated and committed to each module's directory + containing the full release history. This complements the wiki functionality and provides + an alternative changelog format that lives within the repository itself. + required: true + default: "false" wiki-sidebar-changelog-max: description: > An integer that specifies how many changelog entries are displayed in the sidebar per module. diff --git a/src/changelog.ts b/src/changelog.ts index 329497e..f266dc6 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -108,3 +108,86 @@ export function getTerraformModuleFullReleaseChangelog(terraformModule: Terrafor .filter((body): body is string => Boolean(body)) .join('\n\n'); } + +/** + * Generates and writes CHANGELOG.md files for Terraform modules that need a release. + * + * This function creates a CHANGELOG.md file in each module's directory containing the complete + * release history. The changelog includes: + * - A header with the module name + * - All release entries in reverse chronological order (newest first) + * - Properly formatted markdown sections for each release + * + * The function only processes modules that need a release and have commit messages. + * It creates the changelog content by combining the new release entry with historical + * release notes from the module's previous releases. + * + * @param {TerraformModule[]} terraformModules - Array of Terraform modules to process + * @returns {Promise} Array of file paths to the generated CHANGELOG.md files + * + * @example + * ```typescript + * const changelogFiles = await generateChangelogFiles(terraformModules); + * console.log(changelogFiles); + * // Output: ['/path/to/module1/CHANGELOG.md', '/path/to/module2/CHANGELOG.md'] + * ``` + */ +export async function generateChangelogFiles(terraformModules: TerraformModule[]): Promise { + const { writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const { endGroup, info, startGroup } = await import('@actions/core'); + + console.time('Elapsed time generating changelog files'); + startGroup('Generating CHANGELOG.md files'); + + const changelogFiles: string[] = []; + const modulesToRelease = terraformModules.filter((module) => module.needsRelease()); + + if (modulesToRelease.length === 0) { + info('No modules need release. Skipping changelog file generation.'); + endGroup(); + console.timeEnd('Elapsed time generating changelog files'); + return changelogFiles; + } + + for (const terraformModule of modulesToRelease) { + const changelogPath = join(terraformModule.directory, 'CHANGELOG.md'); + + // Get the new release entry + const newReleaseEntry = createTerraformModuleChangelog(terraformModule); + + if (!newReleaseEntry) { + continue; + } + + // Get historical changelog (existing releases) + const historicalChangelog = getTerraformModuleFullReleaseChangelog(terraformModule); + + // Combine new entry with historical data + const changelogContent = [ + `# Changelog - ${terraformModule.name}`, + '', + 'All notable changes to this module will be documented in this file.', + '', + newReleaseEntry, + ]; + + // Add historical changelog if it exists + if (historicalChangelog) { + changelogContent.push(''); + changelogContent.push(historicalChangelog); + } + + const fullChangelog = changelogContent.join('\n'); + + await writeFile(changelogPath, fullChangelog, 'utf8'); + info(`Generated CHANGELOG.md for module: ${terraformModule.name}`); + changelogFiles.push(changelogPath); + } + + info(`Generated ${changelogFiles.length} CHANGELOG.md file${changelogFiles.length !== 1 ? 's' : ''}`); + endGroup(); + console.timeEnd('Elapsed time generating changelog files'); + + return changelogFiles; +} diff --git a/src/config.ts b/src/config.ts index f5e21e8..120c090 100644 --- a/src/config.ts +++ b/src/config.ts @@ -86,6 +86,7 @@ function initializeConfig(): Config { info(`Terraform Docs Version: ${configInstance.terraformDocsVersion}`); info(`Delete Legacy Tags: ${configInstance.deleteLegacyTags}`); info(`Disable Wiki: ${configInstance.disableWiki}`); + info(`Export Changelog Files: ${configInstance.exportChangelogFiles}`); info(`Wiki Sidebar Changelog Max: ${configInstance.wikiSidebarChangelogMax}`); info(`Module Paths to Ignore: ${configInstance.modulePathIgnore.join(', ')}`); info(`Module Change Exclude Patterns: ${configInstance.moduleChangeExcludePatterns.join(', ')}`); diff --git a/src/main.ts b/src/main.ts index 16464aa..cb0791c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,4 @@ +import { generateChangelogFiles } from '@/changelog'; import { getConfig } from '@/config'; import { getContext } from '@/context'; import { parseTerraformModules } from '@/parser'; @@ -45,9 +46,71 @@ async function handlePullRequestEvent( } } +/** + * Commits and pushes CHANGELOG.md files to the repository. + * + * This function stages and commits all CHANGELOG.md files that were generated, + * then pushes them back to the repository. + * + * @param {string[]} changelogFiles - Array of file paths to the generated CHANGELOG.md files + * @returns {Promise} Resolves when changes are committed and pushed + */ +async function commitAndPushChangelogFiles(changelogFiles: string[]): Promise { + if (changelogFiles.length === 0) { + info('No changelog files to commit.'); + return; + } + + const { execFileSync } = await import('node:child_process'); + const which = (await import('which')).default; + const { getGitHubActionsBotEmail } = await import('@/utils/github'); + const { GITHUB_ACTIONS_BOT_NAME } = await import('@/utils/constants'); + const { context } = await import('@/context'); + + startGroup('Committing and pushing CHANGELOG.md files'); + console.time('Elapsed time committing changelog files'); + + try { + const gitPath = which.sync('git'); + const botEmail = await getGitHubActionsBotEmail(); + const execGitOpts = { + cwd: context.workspaceDir, + stdio: 'inherit' as const, + }; + + // Configure git user + info('Configuring git user'); + execFileSync(gitPath, ['config', 'user.name', GITHUB_ACTIONS_BOT_NAME], execGitOpts); + execFileSync(gitPath, ['config', 'user.email', botEmail], execGitOpts); + + // Stage all CHANGELOG.md files + info('Staging CHANGELOG.md files'); + for (const file of changelogFiles) { + execFileSync(gitPath, ['add', file], execGitOpts); + } + + // Commit the changes + const commitMessage = `chore: update CHANGELOG.md files for ${changelogFiles.length} module${changelogFiles.length !== 1 ? 's' : ''}`; + info(`Committing changes: ${commitMessage}`); + execFileSync(gitPath, ['commit', '-m', commitMessage], execGitOpts); + + // Push the changes + info('Pushing changes to repository'); + execFileSync(gitPath, ['push'], execGitOpts); + + info('Successfully pushed CHANGELOG.md files'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to commit and push CHANGELOG.md files: ${errorMessage}`); + } finally { + console.timeEnd('Elapsed time committing changelog files'); + endGroup(); + } +} + /** * Handles merge-event-specific operations, including tagging new releases, deleting legacy resources, - * and optionally generating Terraform Docs-based wiki documentation. + * and optionally generating Terraform Docs-based wiki documentation and CHANGELOG.md files. * * @param {Config} config - The configuration object. * @param {TerraformModule[]} terraformModules - List of Terraform modules associated with this workspace. @@ -80,6 +143,13 @@ async function handlePullRequestMergedEvent( await generateWikiFiles(terraformModules); await commitAndPushWikiChanges(); } + + if (config.exportChangelogFiles) { + const changelogFiles = await generateChangelogFiles(terraformModules); + await commitAndPushChangelogFiles(changelogFiles); + } else { + info('CHANGELOG.md file export is disabled.'); + } } /** diff --git a/src/types/config.types.ts b/src/types/config.types.ts index 0633207..f4fbd8d 100644 --- a/src/types/config.types.ts +++ b/src/types/config.types.ts @@ -48,6 +48,14 @@ export interface Config { */ disableWiki: boolean; + /** + * Whether to export CHANGELOG.md files for each Terraform module in the repository. + * When enabled, a CHANGELOG.md file will be generated and committed to each module's directory + * containing the full release history. This complements the wiki functionality and provides + * an alternative changelog format that lives within the repository itself. + */ + exportChangelogFiles: boolean; + /** * An integer that specifies how many changelog entries are displayed in the sidebar per module. */ diff --git a/src/utils/metadata.ts b/src/utils/metadata.ts index 07c3253..d0c344f 100644 --- a/src/utils/metadata.ts +++ b/src/utils/metadata.ts @@ -48,6 +48,7 @@ export const ACTION_INPUTS: Record = { 'terraform-docs-version': requiredString('terraformDocsVersion'), 'delete-legacy-tags': requiredBoolean('deleteLegacyTags'), 'disable-wiki': requiredBoolean('disableWiki'), + 'export-changelog-files': requiredBoolean('exportChangelogFiles'), 'wiki-sidebar-changelog-max': requiredNumber('wikiSidebarChangelogMax'), 'wiki-usage-template': requiredString('wikiUsageTemplate'), 'disable-branding': requiredBoolean('disableBranding'), From c992a7cb90bb06fbe712bc8af2b9f1f1c5364f40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 04:52:19 +0000 Subject: [PATCH 3/3] Add documentation for export-changelog-files feature Co-authored-by: virgofx <739719+virgofx@users.noreply.github.com> --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index e5ac538..81da16f 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ configuring the following optional input parameters as needed. | `terraform-docs-version` | Specifies the terraform-docs version used to generate documentation for the wiki | `v0.19.0` | | `delete-legacy-tags` | Specifies a boolean that determines whether tags and releases from Terraform modules that have been deleted should be automatically removed | `true` | | `disable-wiki` | Whether to disable wiki generation for Terraform modules | `false` | +| `export-changelog-files` | Whether to export CHANGELOG.md files for each Terraform module. When enabled, generates and commits a CHANGELOG.md file in each module's directory with the full release history.
[Read more here](#exporting-changelog-files) | `false` | | `wiki-sidebar-changelog-max` | An integer that specifies how many changelog entries are displayed in the sidebar per module | `5` | | `wiki-usage-template` | A raw, multi-line string to override the default 'Usage' section in the generated wiki. Allows using variables like {{module_name}}, {{latest_tag}}, {{latest_tag_version_number}} and more.
[Read more here](#configuring-the-wiki-usage-template) | [See action.yml](https://github.com/techpivot/terraform-module-releaser/blob/main/action.yml#L54-L65) | | `disable-branding` | Controls whether a small branding link to the action's repository is added to PR comments. Recommended to leave enabled to support OSS. | `false` | @@ -291,6 +292,40 @@ You can use the following dynamic variables in your template: | `{{module_source}}` | The Git source URL for the module with `git::` prefix, respecting the `use-ssh-source-format` input. | `git::ssh://github.com/techpivot/terraform-module-releaser.git` | | `{{module_name_terraform}}` | A Terraform-safe version of the module name (e.g., special characters replaced with underscores). | `aws_s3_bucket` | +### Exporting CHANGELOG Files + +The `export-changelog-files` option allows you to generate and maintain `CHANGELOG.md` files for each Terraform module +directly in the repository. When enabled, the action will: + +- Generate a `CHANGELOG.md` file in each module's directory containing the full release history +- Include both the new release entry and all historical releases +- Automatically commit and push the changes to the repository after creating releases + +This feature complements the existing wiki functionality and provides an alternative format for tracking module changes +that lives within the repository itself. This is particularly useful for: + +- Organizations that prefer in-repository documentation over wikis +- Scenarios where offline access to changelogs is required +- CI/CD pipelines that need to programmatically access changelog information +- Projects that want to maintain changelogs as part of their version control history + +**Example usage:** + +```yaml +- name: Terraform Module Releaser + uses: techpivot/terraform-module-releaser@v1 + with: + export-changelog-files: true + disable-wiki: false # Both can be enabled simultaneously +``` + +**Important notes:** + +- The changelog files are generated during the merge event (when PR is merged) +- Changes are automatically committed and pushed using the GitHub Actions bot credentials +- Each `CHANGELOG.md` file contains the module name, description, and all release entries in reverse chronological order +- The feature works independently of the wiki generation - you can enable one, both, or neither + ### Example Usage with Inputs ````yml @@ -322,6 +357,7 @@ jobs: terraform-docs-version: v0.20.0 delete-legacy-tags: true disable-wiki: false + export-changelog-files: false wiki-sidebar-changelog-max: 10 module-path-ignore: path/to/ignore1,path/to/ignore2 module-change-exclude-patterns: .gitignore,*.md,docs/**,examples/**,*.tftest.hcl,tests/**