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/**
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'),