From 511428b158f69d6156f762721e3f2643b7d33b75 Mon Sep 17 00:00:00 2001 From: Mark Johnson <739719+virgofx@users.noreply.github.com> Date: Sat, 26 Oct 2024 23:01:14 +0000 Subject: [PATCH] feat: add variable for custom wiki directory separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new variable to allow users to specify a directory separator character for generating wiki titles. This supports better representation of module directory structures in GitHub wiki titles. The default is set to a Unicode mathematical rising diagonal (⟋), but users can choose from other options like '∕', '⁄', and '-'. Updated the README to document this new feature and its usage. Fixes #80 --- README.md | 33 +++++---- src/constants.ts | 38 +++++++++++ src/file-util.ts | 50 ++++++++++++++ src/main.ts | 9 ++- src/wiki.ts | 172 +++++++++++++++++++++++++++++++++-------------- 5 files changed, 236 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 69b4096..c8c117f 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,13 @@ documentation. ![CI](https://github.com/techpivot/terraform-module-releaser/actions/workflows/ci.yml/badge.svg?event=pull_request) [![Lint](https://github.com/techpivot/terraform-module-releaser/actions/workflows/lint.yml/badge.svg)][3] [![CodeQL](https://github.com/techpivot/terraform-module-releaser/actions/workflows/codeql-analysis.yml/badge.svg)][4] +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=techpivot_terraform-module-releaser&metric=alert_status)][5] [1]: https://github.com/techpivot/terraform-module-releaser/releases/latest [2]: https://github.com/marketplace/actions/terraform-module-releaser [3]: https://github.com/techpivot/terraform-module-releaser/actions/workflows/lint.yml [4]: https://github.com/techpivot/terraform-module-releaser/actions/workflows/codeql-analysis.yml +[5]: https://sonarcloud.io/summary/new_code?id=techpivot_terraform-module-releaser Simplify the management of Terraform modules in your monorepo with this **GitHub Action**, designed to automate module-specific versioning and releases. By streamlining the Terraform module release process, this action allows you to @@ -115,6 +117,11 @@ jobs: uses: techpivot/terraform-module-releaser@v1 ``` +This configuration provides an out-of-the-box solution that should work for most projects, as the defaults are +reasonably configured. + +If you need to customize additional parameters, please refer to [Input Parameters](#input-parameters) section below. + ## Permissions Before executing the GitHub Actions workflow, ensure that you have the necessary permissions set for accessing pull @@ -161,19 +168,19 @@ resources. While the out-of-the-box defaults are suitable for most use cases, you can further customize the action's behavior by configuring the following optional input parameters as needed. -| Input | Description | Default | -| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| `major-keywords` | Keywords in commit messages that indicate a major release | `major change,breaking change` | -| `minor-keywords` | Keywords in commit messages that indicate a minor release | `feat,feature` | -| `patch-keywords` | Keywords in commit messages that indicate a patch release | `fix,chore,docs` | -| `default-first-tag` | Specifies the default tag version | `v1.0.0` | -| `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` | -| `wiki-sidebar-changelog-max` | An integer that specifies how many changelog entries are displayed in the sidebar per module | `5` | -| `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` | -| `module-change-exclude-patterns` | A comma-separated list of file patterns to exclude from triggering version changes in Terraform modules. Patterns follow glob syntax (e.g., ".gitignore,_.md") and are relative to each Terraform module directory. Files matching these patterns will not affect version changes. **WARNING**: Avoid excluding '_.tf' files, as they are essential for module detection and versioning processes. | `".gitignore,*.md,*.tftest.hcl,tests/**"` | -| `module-asset-exclude-patterns` | A comma-separated list of file patterns to exclude when bundling a Terraform module for tag/release. Patterns follow glob syntax (e.g., "tests/\*\*") and are relative to each Terraform module directory. Files matching these patterns will be excluded from the bundled output. | `".gitignore,*.md,*.tftest.hcl,tests/**"` | +| Input | Description | Default | +| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| major-keywords | Keywords in commit messages that indicate a major release | `major change,breaking change` | +| `minor-keywords` | Keywords in commit messages that indicate a minor release | `feat,feature` | +| `patch-keywords` | Keywords in commit messages that indicate a patch release | `fix,chore,docs` | +| `default-first-tag` | Specifies the default tag version | `v1.0.0` | +| `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` | +| `wiki-sidebar-changelog-max` | An integer that specifies how many changelog entries are displayed in the sidebar per module | `5` | +| `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` | +| `module-change-exclude-patterns` |
A comma-separated list of file patterns to exclude from triggering version changes in Terraform modules. Patterns follow glob syntax (e.g., ".gitignore,_.md") and are relative to each Terraform module directory. Files matching these patterns will not affect version changes. **WARNING**: Avoid excluding '_.tf' files, as they are essential for module detection and versioning processes.
| `".gitignore,*.md,*.tftest.hcl,tests/**"` | +| `module-asset-exclude-patterns` | A comma-separated list of file patterns to exclude when bundling a Terraform module for tag/release. Patterns follow glob syntax (e.g., "tests/\*\*") and are relative to each Terraform module directory. Files matching these patterns will be excluded from the bundled output. | `".gitignore,*.md,*.tftest.hcl,tests/**"` | ### Example Usage with Inputs diff --git a/src/constants.ts b/src/constants.ts index e720673..58d59be 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,3 +9,41 @@ export const BRANDING_COMMENT = export const BRANDING_WIKI = '

Powered by techpivot/terraform-module-releaser

'; + +/** + * WIKI_TITLE_REPLACEMENTS - This object maps specific characters in wiki titles to visually + * similar Unicode alternatives to handle GitHub Wiki limitations related to directory structure, + * uniqueness, and consistent character visibility. + * + * ### GitHub Wiki Issues Addressed: + * + * - **Slash (`/`) Handling**: + * GitHub Wiki does not interpret forward slashes (`/`) as part of a directory structure in titles. + * When a title includes a slash, GitHub Wiki only recognizes the last segment (basename) for + * navigation, leading to potential conflicts if multiple pages share the same basename but + * reside in different contexts. By replacing `/` with a visually similar division slash (`∕`), + * this mapping helps preserve the intended path within the title, avoiding structure-related conflicts. + * + * - **Hyphen (`-`) Display in Sub-Directories**: + * GitHub Wiki exhibits inconsistent display behavior for hyphens when files are saved within + * subdirectories. If a file is created or saved with a hyphen in a subdirectory (e.g., `__generated`), + * the hyphen does not display properly. Upon saving, GitHub may automatically move the file to + * the root directory, overriding the subdirectory placement. To circumvent this issue, all wiki files + * are saved at the root level, allowing the standard hyphen (`-`) to be used effectively for naming + * without display issues or reorganization by GitHub. + * + * ### Key-Value Pairs: + * - Each **key** represents an original character in the title that may be problematic in GitHub Wiki. + * - Each **value** is a Unicode replacement character chosen to visually resemble the original while + * avoiding structural or display conflicts. + * + * ### Current Mappings: + * - `'/'` → `'∕'` (U+2215 Division Slash): Replaces forward slashes in titles to prevent directory + * conflicts. + * - (Optional) `'-'` → `'‒'` (U+2012 Figure Dash): This mapping may be applied if files are required + * in a subdirectory, but currently, it is unnecessary as files are saved at the root. + */ +export const WIKI_TITLE_REPLACEMENTS: { [key: string]: string } = { + '/': '∕', // Replace forward slash with a visually similar division slash (U+2215) + // '-': '‒', // Replace hyphen with figure dash (U+2012) for better root display (optional) +}; diff --git a/src/file-util.ts b/src/file-util.ts index 1e5965d..578d02b 100644 --- a/src/file-util.ts +++ b/src/file-util.ts @@ -52,3 +52,53 @@ export function copyModuleContents(directory: string, tmpDir: string, baseDirect } } } + +/** + * Removes all contents of a specified directory except for specified items to preserve. + * + * @param directory - The path of the directory to clear. + * @param exceptions - An array of filenames or directory names to preserve within the directory. + * + * This function removes all files and subdirectories within the specified directory while + * retaining any items listed in the `exceptions` array. The names in `exceptions` should be + * relative to the `directory` (e.g., `['.git', 'README.md']`), referring to items within the + * directory you want to keep. + * + * ### Example Usage: + * + * Suppose you have a directory structure: + * ``` + * /example-directory/ + * ├── .git/ + * ├── config.json + * ├── temp/ + * └── README.md + * ``` + * + * Using `removeDirectoryContents('/example-directory', ['.git', 'README.md'])` will: + * - Remove `config.json` and the `temp` folder. + * - Preserve the `.git` directory and `README.md` file within `/example-directory`. + * + * **Note:** + * - Items in `exceptions` are matched only by their names relative to the given `directory`. + * - If the `.git` directory or `README.md` file were in a nested subdirectory within `/example-directory`, + * you would need to adjust the `exceptions` parameter accordingly to reflect the correct relative path. + * + * @example + * removeDirectoryContents('/home/user/project', ['.git', 'important-file.txt']); + * // This would remove all contents inside `/home/user/project`, except for the `.git` directory + * // and the `important-file.txt` file. + */ +export function removeDirectoryContents(directory: string, exceptions: string[] = []): void { + if (fs.existsSync(directory)) { + for (const item of fs.readdirSync(directory)) { + const itemPath = path.join(directory, item); + + // Skip removal for items listed in the exceptions array + if (!exceptions.includes(item)) { + fs.rmSync(itemPath, { recursive: true, force: true }); + } + } + info(`Removed contents of directory [${directory}], preserving items: ${exceptions.join(', ')}`); + } +} diff --git a/src/main.ts b/src/main.ts index 29daa19..d9c25d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ import { createTaggedRelease, deleteLegacyReleases, getAllReleases } from './rel import { deleteLegacyTags, getAllTags } from './tags'; import { installTerraformDocs } from './terraform-docs'; import { getAllTerraformModules, getTerraformChangedModules, getTerraformModulesToRemove } from './terraform-module'; -import { WikiStatus, checkoutWiki, updateWiki } from './wiki'; +import { WikiStatus, checkoutWiki, commitAndPushWikiChanges, generateWikiFiles } from './wiki'; /** * The main function for the action. @@ -64,6 +64,10 @@ export async function run(): Promise { if (error !== undefined) { throw error; } + + installTerraformDocs(config.terraformDocsVersion); + await generateWikiFiles(terraformModules); + commitAndPushWikiChanges(); } else { // Create the tagged release and post a comment to the PR const updatedModules = await createTaggedRelease(terraformChangedModules); @@ -78,7 +82,8 @@ export async function run(): Promise { } else { installTerraformDocs(config.terraformDocsVersion); checkoutWiki(); - updateWiki(terraformModules); + await generateWikiFiles(terraformModules); + commitAndPushWikiChanges(); } } } catch (error) { diff --git a/src/wiki.ts b/src/wiki.ts index c8f5c35..c29f71c 100644 --- a/src/wiki.ts +++ b/src/wiki.ts @@ -8,8 +8,9 @@ import { endGroup, info, startGroup } from '@actions/core'; import pLimit from 'p-limit'; import { getModuleReleaseChangelog } from './changelog'; import { config } from './config'; -import { BRANDING_WIKI, GITHUB_ACTIONS_BOT_EMAIL, GITHUB_ACTIONS_BOT_NAME } from './constants'; +import { BRANDING_WIKI, GITHUB_ACTIONS_BOT_EMAIL, GITHUB_ACTIONS_BOT_NAME, WIKI_TITLE_REPLACEMENTS } from './constants'; import { context } from './context'; +import { removeDirectoryContents } from './file-util'; import { generateTerraformDocs } from './terraform-docs'; import type { TerraformModule } from './terraform-module'; @@ -21,14 +22,8 @@ export enum WikiStatus { // Special subdirectory inside the primary repository where the wiki is checked out. const WIKI_SUBDIRECTORY = '.wiki'; - const WIKI_DIRECTORY = path.resolve(context.workspaceDir, WIKI_SUBDIRECTORY); -// Directory where the wiki generated Terraform modules will reside. Since GitHub doesn't use -// folder/namespacing this folder will be transparent but will be helpful to keep generated -// content separated from some special top level files (e.g. _Sidebar.md). -const WIKI_GENERATED_DIRECTORY = path.resolve(WIKI_DIRECTORY, 'generated'); - const execWikiOpts: ExecSyncOptions = { cwd: WIKI_DIRECTORY, stdio: 'inherit' }; /** @@ -100,18 +95,58 @@ export function checkoutWiki(): void { execFileSync('/usr/bin/git', ['checkout', 'master'], execWikiOpts); info('Successfully checked out wiki repository'); - - // Since we 100% regenerate 100% of the modules, we can simply remove the generated folder if it exists - // as this helps us 100% ensure we don't have any stale content. - if (fs.existsSync(WIKI_GENERATED_DIRECTORY)) { - fs.rmSync(WIKI_GENERATED_DIRECTORY, { recursive: true }); - info(`Removed existing wiki generated directory [${WIKI_GENERATED_DIRECTORY}]`); - } } finally { endGroup(); } } +/** + * Generates a sanitized slug for a GitHub Wiki title by replacing specific characters in the + * provided module name with visually similar substitutes to avoid path conflicts and improve display. + * This function dynamically creates a regular expression from the keys in the `WIKI_TITLE_REPLACEMENTS` + * map, ensuring any added replacements in the map will be automatically accounted for in future + * conversions. + * + * **Important**: Refer to `WIKI_TITLE_REPLACEMENTS` in `constants.ts` to add or update replacement mappings. + * + * @param {string} moduleName - The original module name to be transformed into a GitHub Wiki-compatible slug. + * @returns {string} - The modified module name, with specified characters replaced by corresponding entries + * in the `WIKI_TITLE_REPLACEMENTS` map. + * + * @example + * // Example usage: + * // Assuming WIKI_TITLE_REPLACEMENTS = { '/': '∕', '-': '‒' } + * const moduleName = 'my-module/name'; + * const wikiSlug = getWikiSlug(moduleName); + * // Returns: "my‒module∕name" + * + * @remarks + * This function avoids manual regex maintenance by dynamically building a character class from the keys in + * `WIKI_TITLE_REPLACEMENTS`. To handle special characters in these keys, the `escapeForRegex` helper function + * escapes regex metacharacters as needed. + * + * The `escapeForRegex` helper: + * - Escapes metacharacters (e.g., `*`, `.`, `+`, `?`, `^`, `$`, `{`, `}`, `(`, `)`, `|`, `[`, `]`, `\`) + * to ensure they are interpreted literally within the regular expression. + * + * Dynamic regex creation: + * - `Object.keys(WIKI_TITLE_REPLACEMENTS).map(escapeForRegex).join('')` generates an escaped sequence + * of characters for replacement and constructs a character class for the `pattern` regex. + * + * Replacement logic: + * - `moduleName.replace(pattern, match => WIKI_TITLE_REPLACEMENTS[match])` matches each specified character + * in `moduleName` and replaces it with the mapped character from `WIKI_TITLE_REPLACEMENTS`. + */ +function getWikiSlug(moduleName: string): string { + const escapeForRegex = (char: string): string => { + return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Escape special characters for regex + }; + + const pattern = new RegExp(`[${Object.keys(WIKI_TITLE_REPLACEMENTS).map(escapeForRegex).join('')}]`, 'g'); + + return moduleName.replace(pattern, (match) => WIKI_TITLE_REPLACEMENTS[match]); +} + /** * Generates a URL to the wiki page for a given Terraform module. * @@ -140,15 +175,11 @@ export function getWikiLink(moduleName: string, relative = true): string { baseUrl = context.repoUrl; } - // The wiki file slug needs to match GitHub syntax. It doesn't take into account folder/namespace. If it - // did much of this sidebar behavior would be potentially unnecessary. - const gitHubSlug = path.basename(moduleName).replace(/\.[^/.]+$/, ''); - - return `${baseUrl}/wiki/${gitHubSlug}`; + return `${baseUrl}/wiki/${getWikiSlug(moduleName)}`; } /** - * Writes the provided content to the appropriate wiki file for the specified Terraform module. + * Generates the wiki file associated with the specified Terraform module. * Ensures that the directory structure is created if it doesn't exist and handles overwriting * the existing wiki file. * @@ -157,14 +188,19 @@ export function getWikiLink(moduleName: string, relative = true): string { * @returns {Promise} The path to the wiki file that was written. * @throws Will throw an error if the file cannot be written. */ -async function updateWikiModule(terraformModule: TerraformModule): Promise { +async function generateWikiModule(terraformModule: TerraformModule): Promise { const { moduleName, latestTag } = terraformModule; try { - // Define the path for the module's wiki file - const wikiFile = path.join(WIKI_GENERATED_DIRECTORY, `${moduleName}.md`); + const wikiFile = path.join(WIKI_DIRECTORY, `${getWikiSlug(moduleName)}.md`); + + console.log(moduleName, '--', wikiFile); + const wikiFilePath = path.dirname(wikiFile); + console.log(moduleName, '--', wikiFile, '--', wikiFilePath); + + // Generate a module changelog const changelog = getModuleReleaseChangelog(terraformModule); const tfDocs = await generateTerraformDocs(terraformModule); const wikiContent = [ @@ -191,7 +227,7 @@ async function updateWikiModule(terraformModule: TerraformModule): Promise} - A promise that resolves once the sidebar has been successfully - * updated and written to the file. + * @returns {Promise} - A promise that resolves with the path of the sidebar file once it has been + * successfully updated and written. * * Function Details: * - Uses the `context.repo` object to get the repository owner and name for building links. @@ -244,9 +280,9 @@ async function updateWikiModule(terraformModule: TerraformModule): Promise * ``` */ -async function updateWikiSidebar(terraformModules: TerraformModule[]): Promise { +async function generateWikiSidebar(terraformModules: TerraformModule[]): Promise { + const sidebarFile = path.join(WIKI_DIRECTORY, '_Sidebar.md'); const { owner, repo } = context.repo; - const sideBarFile = path.join(WIKI_DIRECTORY, '_Sidebar.md'); const repoBaseUrl = `/${owner}/${repo}`; let moduleSidebarContent = ''; @@ -308,33 +344,36 @@ async function updateWikiSidebar(terraformModules: TerraformModule[]): Promise${moduleSidebarContent}\n`; - await fsp.writeFile(sideBarFile, content, 'utf8'); + await fsp.writeFile(sidebarFile, content, 'utf8'); + + info('_Sidebar.md updated.'); + + return sidebarFile; } /** - * Updates the `_Footer.md` file in the wiki directory to manage the branding/footer content. + * Generates the `_Footer.md` file in the wiki directory to maintain consistent branding content. * - * This function checks whether the branding should be disabled based on the configuration: - * - If branding is disabled and the `_Footer.md` file exists, it deletes the file. - * - If branding is enabled (or not disabled), it creates or updates the `_Footer.md` file with the branding content. + * This function checks whether branding is enabled: + * - If branding is disabled, the function exits early without making any changes. + * - If branding is enabled, it creates or updates the `_Footer.md` file with the specified branding content. * - * @returns {Promise} A promise that resolves when the update process is complete. - * @throws {Error} Logs an error if the file update or deletion fails. + * @returns {Promise} A promise that resolves to the footer file path if updated, or undefined if no update is necessary. + * @throws {Error} Logs an error if the file creation or update fails. */ -async function updateWikiFooter(): Promise { +async function generateWikiFooter(): Promise { + if (config.disableBranding) { + info('Skipping footer generation as branding is disabled'); + return; + } + const footerFile = path.join(WIKI_DIRECTORY, '_Footer.md'); try { - // Check if the _Footer.md file exists - if (config.disableBranding && fs.existsSync(footerFile)) { - await fsp.unlink(footerFile); - info('_Footer.md has been deleted.'); - return; - } - // If the file doesn't exist, create and write content to it await fsp.writeFile(footerFile, BRANDING_WIKI, 'utf8'); - info('_Footer.md has been created and updated.'); + info('_Footer.md updated.'); + return footerFile; } catch (error) { console.error(`Error updating _Footer.md: ${error instanceof Error ? error.message : String(error)}`); } @@ -354,9 +393,23 @@ async function updateWikiFooter(): Promise { * * @returns {Promise} A promise that resolves to a list of file paths of the updated wiki files. */ -export async function updateWiki(terraformModules: TerraformModule[]): Promise { +export async function generateWikiFiles(terraformModules: TerraformModule[]): Promise { startGroup('Generating wiki documentation'); + // Clears the contents of the Wiki directory to ensure no stale content remains, + // as the Wiki is fully regenerated during each run. + // + // This process: + // - Logs the cleanup action for tracking purposes. + // - Removes all files and directories within `WIKI_DIRECTORY` except `.git`, + // which is preserved to maintain version control and Git history. + // + // This approach supports: + // - Ensuring the Wiki remains up-to-date without leftover or outdated files. + // - Avoiding conflicts or unexpected results due to stale data. + info('Removing existing wiki files...'); + removeDirectoryContents(WIKI_DIRECTORY, ['.git']); + const parallelism = os.cpus().length + 2; info(`Using parallelism: ${parallelism}`); @@ -365,17 +418,36 @@ export async function updateWiki(terraformModules: TerraformModule[]): Promise { return limit(async () => { - updatedFiles.push(await updateWikiModule(module)); + updatedFiles.push(await generateWikiModule(module)); }); }); await Promise.all(tasks); + updatedFiles.push(await generateWikiSidebar(terraformModules)); + + const footerFile = await generateWikiFooter(); + if (footerFile) { + updatedFiles.push(footerFile); + } + info('Wiki files generated:'); console.log(updatedFiles); - await updateWikiSidebar(terraformModules); - await updateWikiFooter(); endGroup(); + return updatedFiles; +} + +/** + * Commits and pushes changes to the wiki repository. + * + * This function checks for any changes in the wiki directory, and if there are changes, + * it commits and pushes them using the provided commit message. + * + * @returns {void} + */ +export function commitAndPushWikiChanges(): void { + startGroup('Committing and pushing changes to wiki'); + startGroup('Committing and pushing changes to wiki'); try { @@ -401,6 +473,4 @@ export async function updateWiki(terraformModules: TerraformModule[]): Promise