Skip to content

Commit

Permalink
Add option to use hash in changelog filenames (#996)
Browse files Browse the repository at this point in the history
  • Loading branch information
ecraig12345 authored Nov 6, 2024
1 parent a4f19d4 commit e349314
Show file tree
Hide file tree
Showing 15 changed files with 475 additions and 61 deletions.
5 changes: 4 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
"args": ["--", "--runInBand", "--watch", "--testTimeout=1000000", "${fileBasenameNoExtension}"],
"sourceMaps": true,
"outputCapture": "std",
"console": "integratedTerminal"
"console": "integratedTerminal",
// Debug with Node 14 via nvm.
// On Windows, you might have to change this to a specific version.
"runtimeVersion": "14"
},
{
"type": "node",
Expand Down
7 changes: 7 additions & 0 deletions change/beachball-ba7bb90f-ba01-4c9b-84ac-e054aa4db235.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Replace usage of uuid library with built-in crypto.randomUUID()",
"packageName": "beachball",
"email": "[email protected]",
"dependentChangeType": "patch"
}
7 changes: 7 additions & 0 deletions change/beachball-c1b55758-27ac-4dc8-b073-8380c991c183.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add option `changelog.uniqueFilenames` for adding a hash to the changelog filename based on the package name",
"packageName": "beachball",
"email": "[email protected]",
"dependentChangeType": "patch"
}
6 changes: 5 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ const commonOptions = {
roots: ['<rootDir>/src'],
setupFilesAfterEnv: ['<rootDir>/scripts/jestSetup.js'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
'^.+\\.tsx?$': [
'ts-jest',
// in ts-jest, this means skip type checking (we already type check in the build step)
{ isolatedModules: true },
],
},
testEnvironment: 'node',
};
Expand Down
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@
"semver": "^7.0.0",
"toposort": "^2.0.2",
"p-graph": "^1.1.2",
"uuid": "^9.0.0",
"workspace-tools": "^0.38.0",
"yargs-parser": "^21.0.0"
},
Expand All @@ -65,7 +64,6 @@
"@types/semver": "^7.3.13",
"@types/tmp": "^0.2.3",
"@types/toposort": "^2.0.3",
"@types/uuid": "^9.0.0",
"@types/yargs-parser": "^21.0.0",
"find-free-port": "^2.0.0",
"gh-pages": "^5.0.0",
Expand Down
31 changes: 13 additions & 18 deletions src/__fixtures__/changelog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import fs from 'fs-extra';
import path from 'path';
import _ from 'lodash';
import { ChangelogJson } from '../types/ChangeLog';
import { markerComment } from '../changelog/renderChangelog';

Expand All @@ -11,8 +10,8 @@ export const fakeCommit = '(sha1)';
* Read the CHANGELOG.md under the given package path, sanitizing any dates for snapshots.
* Returns null if it doesn't exist.
*/
export function readChangelogMd(packagePath: string): string | null {
const changelogFile = path.join(packagePath, 'CHANGELOG.md');
export function readChangelogMd(packagePath: string, filename?: string): string | null {
const changelogFile = path.join(packagePath, filename || 'CHANGELOG.md');
if (!fs.existsSync(changelogFile)) {
return null;
}
Expand All @@ -26,27 +25,23 @@ export function trimChangelogMd(changelogMd: string): string {
}

/**
* Read the CHANGELOG.json under the given package path.
* Returns null if it doesn't exist.
* Read the CHANGELOG.json, and clean it for a snapshot (unless `noClean` is true):
* replace dates and SHAs with placeholders.
* @param packagePath The path to the package directory.
* @param filename The name of the changelog file. Defaults to 'CHANGELOG.json'.
* @param noClean If true, don't clean the changelog for snapshots.
* @returns The parsed changelog JSON, or null if it doesn't exist.
*/
export function readChangelogJson(packagePath: string, cleanForSnapshot: boolean = false): ChangelogJson | null {
const changelogJsonFile = path.join(packagePath, 'CHANGELOG.json');
export function readChangelogJson(packagePath: string, filename?: string, noClean?: boolean): ChangelogJson | null {
const changelogJsonFile = path.join(packagePath, filename || 'CHANGELOG.json');
if (!fs.existsSync(changelogJsonFile)) {
return null;
}
const json = fs.readJSONSync(changelogJsonFile, { encoding: 'utf-8' });
return cleanForSnapshot ? cleanChangelogJson(json) : json;
}

/**
* Clean changelog json for a snapshot: replace dates and SHAs with placeholders.
* Note: this clones the changelog object rather than modifying the original.
*/
export function cleanChangelogJson(changelog: ChangelogJson | null): ChangelogJson | null {
if (!changelog) {
return null;
const changelog: ChangelogJson = fs.readJSONSync(changelogJsonFile, { encoding: 'utf-8' });
if (noClean) {
return changelog;
}
changelog = _.cloneDeep(changelog);

for (const entry of changelog.entries) {
// Only replace properties if they existed, to help catch bugs if things are no longer written
Expand Down
20 changes: 20 additions & 0 deletions src/__fixtures__/createTestFileStructure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import fs from 'fs-extra';
import { tmpdir } from './tmpdir';
import path from 'path';

/**
* For each key in `files`, create a test folder and write a file of that filename, where the
* content is the value (and create any intermediate folders).
* @returns path to the test folder with **forward slashes**
*/
export function createTestFileStructure(files: Record<string, string | object>): string {
const testFolderPath = tmpdir();

for (const [filename, content] of Object.entries(files)) {
const filePath = path.join(testFolderPath, filename);
fs.ensureDirSync(path.dirname(filePath));
fs.writeFileSync(filePath, typeof content === 'string' ? content : JSON.stringify(content));
}

return testFolderPath.replace(/\\/g, '/');
}
10 changes: 10 additions & 0 deletions src/__fixtures__/tmpdir.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as fs from 'fs-extra';
import * as tmp from 'tmp';
import { normalizedTmpdir } from 'normalized-tmpdir';
// import console to ensure that warnings are always logged if needed (no mocking)
Expand Down Expand Up @@ -25,3 +26,12 @@ export function tmpdir(options?: tmp.DirOptions): string {
tmpdir: normalizedTmpdir({ console: realConsole }),
}).name;
}

/** Remove the temp directory, ignoring errors */
export function removeTempDir(dir: string) {
try {
fs.rmSync(dir, { force: true });
} catch {
// ignore
}
}
217 changes: 217 additions & 0 deletions src/__functional__/changelog/prepareChangelogPaths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { afterEach, beforeAll, describe, expect, it, jest } from '@jest/globals';
import fs from 'fs';
import path from 'path';
import { removeTempDir } from '../../__fixtures__/tmpdir';
import { prepareChangelogPaths } from '../../changelog/prepareChangelogPaths';
import { createTestFileStructure } from '../../__fixtures__/createTestFileStructure';

describe('prepareChangelogPaths', () => {
let consoleLogMock: jest.SpiedFunction<typeof console.log>;
let tempDir: string | undefined;

const fakeDir = slash(path.resolve('/faketmpdir'));
const packageName = 'test';
/** This is the beginning of the md5 hash digest of "test" */
const testHash = '098f6bcd';

/** Wrapper that calls `prepareChangelogPaths` and converts the result to forward slashes */
function prepareChangelogPathsWrapper(options: Parameters<typeof prepareChangelogPaths>[0]) {
const paths = prepareChangelogPaths(options);
if (paths.md) paths.md = slash(paths.md);
if (paths.json) paths.json = slash(paths.json);
return paths;
}

function slash(str: string) {
return str.replace(/\\/g, '/');
}

beforeAll(() => {
consoleLogMock = jest.spyOn(console, 'log').mockImplementation(() => {});
// there will be a bunch of ignorable warnings because /faketmpdir doesn't exist
jest.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
consoleLogMock.mockClear();
tempDir && removeTempDir(tempDir);
tempDir = undefined;
});

it('returns empty paths if generateChangelog is false', () => {
const paths = prepareChangelogPathsWrapper({
options: { generateChangelog: false, changelog: {} },
changelogAbsDir: fakeDir,
packageName,
});

expect(paths).toEqual({});
});

it('returns default paths if generateChangelog is true (uniqueFilenames unset)', () => {
const paths = prepareChangelogPathsWrapper({
options: { generateChangelog: true },
changelogAbsDir: fakeDir,
packageName,
});

expect(paths).toEqual({ md: `${fakeDir}/CHANGELOG.md`, json: `${fakeDir}/CHANGELOG.json` });
});

it('returns only default md path if generateChangelog is "md" (uniqueFilenames unset)', () => {
const paths = prepareChangelogPathsWrapper({
options: { generateChangelog: 'md' },
changelogAbsDir: fakeDir,
packageName,
});

expect(paths).toEqual({ md: `${fakeDir}/CHANGELOG.md` });
});

it('returns only default json path if generateChangelog is "json" (uniqueFilenames unset)', () => {
const paths = prepareChangelogPathsWrapper({
options: { generateChangelog: 'json' },
changelogAbsDir: fakeDir,
packageName,
});

expect(paths).toEqual({ json: `${fakeDir}/CHANGELOG.json` });
});

it('returns new paths with hashes if uniqueFilenames is true and no files exist', () => {
const options = {
options: { generateChangelog: true, changelog: { uniqueFilenames: true } },
changelogAbsDir: fakeDir,
};

const paths = prepareChangelogPathsWrapper({ ...options, packageName: 'test' });

expect(paths).toEqual({
md: `${fakeDir}/CHANGELOG-${testHash}.md`,
json: `${fakeDir}/CHANGELOG-${testHash}.json`,
});

// hash is based on package name, not path or anything else
const otherPaths = prepareChangelogPathsWrapper({ ...options, packageName: 'other' });

expect(otherPaths).toEqual({
md: `${fakeDir}/CHANGELOG-795f3202.md`,
json: `${fakeDir}/CHANGELOG-795f3202.json`,
});
});

it('returns new md path with hash if uniqueFilenames is true and generateChangelog is "md"', () => {
const paths = prepareChangelogPathsWrapper({
options: { generateChangelog: 'md', changelog: { uniqueFilenames: true } },
changelogAbsDir: fakeDir,
packageName,
});

expect(paths).toEqual({
md: `${fakeDir}/CHANGELOG-${testHash}.md`,
});
});

it('returns new json path with hash if uniqueFilenames is true and generateChangelog is "json"', () => {
const paths = prepareChangelogPathsWrapper({
options: { generateChangelog: 'json', changelog: { uniqueFilenames: true } },
changelogAbsDir: fakeDir,
packageName,
});

expect(paths).toEqual({
json: `${fakeDir}/CHANGELOG-${testHash}.json`,
});
});

it('migrates existing non-hash file to path with hash (uniqueFilenames false to true)', () => {
tempDir = createTestFileStructure({
'CHANGELOG.md': 'existing md',
'CHANGELOG.json': {},
});

const paths = prepareChangelogPathsWrapper({
options: { generateChangelog: true, changelog: { uniqueFilenames: true } },
changelogAbsDir: tempDir,
packageName,
});

expect(paths).toEqual({
md: `${tempDir}/CHANGELOG-${testHash}.md`,
json: `${tempDir}/CHANGELOG-${testHash}.json`,
});

expect(fs.existsSync(`${tempDir}/CHANGELOG.md`)).toBe(false);
expect(fs.readFileSync(paths.md!, 'utf8')).toBe('existing md');

expect(fs.existsSync(`${tempDir}/CHANGELOG.json`)).toBe(false);
expect(fs.readFileSync(paths.json!, 'utf8')).toBe('{}');

expect(consoleLogMock).toHaveBeenCalledWith(expect.stringContaining('Renamed existing changelog file'));
expect(consoleLogMock).toHaveBeenCalledTimes(2);
});

it('migrates existing path with hash to non-hash path (uniqueFilenames true to false)', () => {
const oldName = 'CHANGELOG-abcdef08';
tempDir = createTestFileStructure({
[`${oldName}.md`]: 'existing md',
[`${oldName}.json`]: {},
});

const paths = prepareChangelogPathsWrapper({
options: { generateChangelog: true },
changelogAbsDir: tempDir,
packageName,
});

expect(paths).toEqual({
md: `${tempDir}/CHANGELOG.md`,
json: `${tempDir}/CHANGELOG.json`,
});

expect(fs.existsSync(`${tempDir}/${oldName}.md`)).toBe(false);
expect(fs.readFileSync(paths.md!, 'utf8')).toBe('existing md');

expect(fs.existsSync(`${tempDir}/${oldName}.json`)).toBe(false);
expect(fs.readFileSync(paths.json!, 'utf8')).toBe('{}');

expect(consoleLogMock).toHaveBeenCalledWith(expect.stringContaining('Renamed existing changelog file'));
expect(consoleLogMock).toHaveBeenCalledTimes(2);
});

it('renames newest file if uniqueFilenames is true and there are multiple files with hashes', async () => {
const file1 = 'CHANGELOG-12345678.md';
const file2 = 'CHANGELOG-fbcd40ef.md';
const lastHash = 'abcdef12';
tempDir = createTestFileStructure({
[file1]: 'md 1',
[file2]: 'md 2',
});
// ensure different timestamps by waiting 1ms
await new Promise(resolve => setTimeout(resolve, 1));
fs.writeFileSync(path.join(tempDir, `CHANGELOG-${lastHash}.md`), 'last md');

const paths = prepareChangelogPathsWrapper({
options: { generateChangelog: true, changelog: { uniqueFilenames: true } },
changelogAbsDir: tempDir,
packageName,
});

// Paths use the actual hash of "test"
expect(paths).toEqual({
md: `${tempDir}/CHANGELOG-${testHash}.md`,
json: `${tempDir}/CHANGELOG-${testHash}.json`,
});

// The most recently modified file is renamed
expect(fs.existsSync(`${tempDir}/CHANGELOG-${lastHash}.md`)).toBe(false);
expect(fs.readFileSync(paths.md!, 'utf8')).toBe('last md');

expect(consoleLogMock).toHaveBeenCalledWith(expect.stringContaining('Renamed existing changelog file'));
expect(consoleLogMock).toHaveBeenCalledTimes(1);

// The other files are untouched
expect(fs.readFileSync(`${tempDir}/${file1}`, 'utf8')).toBe('md 1');
expect(fs.readFileSync(`${tempDir}/${file2}`, 'utf8')).toBe('md 2');
});
});
Loading

0 comments on commit e349314

Please sign in to comment.