diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3cb6f172b119..b5fe6881d1c2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,6 +19,20 @@ the functionality of your change --> +## Changelog + + ### Check List - [ ] All tests pass diff --git a/.github/workflows/changelog_verifier.yml b/.github/workflows/changelog_verifier.yml deleted file mode 100644 index 0890ea8b8fbb..000000000000 --- a/.github/workflows/changelog_verifier.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: "Changelog Verifier" -on: - pull_request: - branches: [ '**', '!feature/**' ] - types: [opened, edited, review_requested, synchronize, reopened, ready_for_review, labeled, unlabeled] - -jobs: - # Enforces the update of a changelog file on every pull request - verify-changelog: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - token: ${{ secrets.GITHUB_TOKEN }} - ref: ${{ github.event.pull_request.head.sha }} - - - uses: dangoslen/changelog-enforcer@v3 - with: - skipLabels: "autocut, Skip-Changelog" diff --git a/.github/workflows/create_change_set_workflow.yml b/.github/workflows/create_change_set_workflow.yml new file mode 100644 index 000000000000..1b0b0dcd9985 --- /dev/null +++ b/.github/workflows/create_change_set_workflow.yml @@ -0,0 +1,21 @@ +name: Create Change Set + +on: + pull_request_target: + types: [opened, edited] + paths-ignore: + - 'changelogs/fragments/**/*' + +jobs: + update-changelog: + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Update Changelog + uses: BigSamu/OpenSearch_Change_Set_Create_Action@main + with: + token: ${{ secrets.GITHUB_TOKEN }} + changeset_token: ${{ secrets.CHANGESET_TOKEN }} + changeset_path: changelogs/fragments diff --git a/changelogs/README.md b/changelogs/README.md new file mode 100644 index 000000000000..a4620754cfd1 --- /dev/null +++ b/changelogs/README.md @@ -0,0 +1,5 @@ +# Changelog and Release Notes + +For information regarding the changelog and release notes process, please consult the README in the GitHub Actions repository that this process utilizes. To view this README, follow the link below: + +[GitHub Actions Workflow README](https://github.com/BigSamu/OpenSearch_Change_Set_Create_Action/blob/main/README.md) diff --git a/changelogs/fragments/2.yml b/changelogs/fragments/2.yml new file mode 100644 index 000000000000..2795ea18df92 --- /dev/null +++ b/changelogs/fragments/2.yml @@ -0,0 +1,5 @@ +feat: +- Add new feature ([#2](https://github.com/BigSamu/OpenSearch-Dashboards/pull/2)) + +fix: +- Fix bug ([#2](https://github.com/BigSamu/OpenSearch-Dashboards/pull/2)) \ No newline at end of file diff --git a/package.json b/package.json index b41c6b834fd9..d7134a98b14a 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,8 @@ "docs:acceptApiChanges": "scripts/use_node --max-old-space-size=6144 scripts/check_published_api_changes.js --accept", "osd:bootstrap": "scripts/use_node scripts/build_ts_refs && scripts/use_node scripts/register_git_hook", "spec_to_console": "scripts/use_node scripts/spec_to_console", - "pkg-version": "scripts/use_node -e \"console.log(require('./package.json').version)\"" + "pkg-version": "scripts/use_node -e \"console.log(require('./package.json').version)\"", + "release_note:generate": "scripts/use_node scripts/generate_release_note" }, "repository": { "type": "git", diff --git a/scripts/generate_release_note.js b/scripts/generate_release_note.js new file mode 100644 index 000000000000..4721fe0dec35 --- /dev/null +++ b/scripts/generate_release_note.js @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +require('../src/setup_node_env'); +require('../src/dev/generate_release_note'); +require('../src/dev/generate_release_note_helper'); diff --git a/src/dev/generate_release_note.ts b/src/dev/generate_release_note.ts new file mode 100644 index 000000000000..158c48b79d49 --- /dev/null +++ b/src/dev/generate_release_note.ts @@ -0,0 +1,137 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { resolve } from 'path'; +import { readFileSync, writeFileSync, Dirent, renameSync, rm } from 'fs'; +import { load as loadYaml } from 'js-yaml'; +import { mkdir, readdir } from 'fs/promises'; +import { version as pkgVersion } from '../../package.json'; +import { + validateFragment, + getCurrentDateFormatted, + Changelog, + SECTION_MAPPING, + fragmentDirPath, + fragmentTempDirPath, + SectionKey, + releaseNotesDirPath, + filePath, +} from './generate_release_note_helper'; + +// Function to add content after the 'Unreleased' section in the changelog +function addContentAfterUnreleased(path: string, newContent: string): void { + let fileContent = readFileSync(path, 'utf8'); + const targetString = '## [Unreleased]'; + const targetIndex = fileContent.indexOf(targetString); + + if (targetIndex !== -1) { + const endOfLineIndex = fileContent.indexOf('\n', targetIndex); + + if (endOfLineIndex !== -1) { + fileContent = + fileContent.slice(0, endOfLineIndex + 1) + + '\n' + + newContent + + fileContent.slice(endOfLineIndex + 1); + } else { + throw new Error('End of line for "Unreleased" section not found.'); + } + } else { + throw new Error("'## [Unreleased]' not found in the file."); + } + + writeFileSync(path, fileContent); +} + +async function deleteFragments() { + rm(fragmentTempDirPath, { recursive: true }, (err: any) => { + if (err) { + throw err; + } + }); +} + +// Read fragment files and populate sections +async function readFragments() { + // Initialize sections + const sections: Changelog = (Object.fromEntries( + Object.keys(SECTION_MAPPING).map((key) => [key, []]) + ) as unknown) as Changelog; + + const fragmentPaths = await readdir(fragmentDirPath, { withFileTypes: true }); + for (const fragmentFilename of fragmentPaths) { + // skip non yml or yaml files + if (!fragmentFilename.name.endsWith('.yml') && !fragmentFilename.name.endsWith('.yaml')) { + // eslint-disable-next-line no-console + console.warn(`Skipping non yml or yaml file ${fragmentFilename.name}`); + continue; + } + + const fragmentPath = resolve(fragmentDirPath, fragmentFilename.name); + const fragmentContents = readFileSync(fragmentPath, { encoding: 'utf-8' }); + + validateFragment(fragmentContents); + + const fragmentYaml = loadYaml(fragmentContents) as Changelog; + + for (const [sectionKey, entries] of Object.entries(fragmentYaml)) { + sections[sectionKey as SectionKey].push(...entries); + } + } + return { sections, fragmentPaths }; +} + +async function moveFragments(fragmentPaths: Dirent[]): Promise { + // create folder for temp fragments at fragmentTempDirPath + await mkdir(fragmentTempDirPath, { recursive: true }); + + // Move fragment files to temp fragments folder + for (const fragmentFilename of fragmentPaths) { + const fragmentPath = resolve(fragmentDirPath, fragmentFilename.name); + const fragmentTempPath = resolve(fragmentTempDirPath, fragmentFilename.name); + renameSync(fragmentPath, fragmentTempPath); + } +} + +function generateChangelog(sections: Changelog) { + // Generate changelog sections + const changelogSections = Object.entries(sections).map(([sectionKey, entries]) => { + const sectionName = SECTION_MAPPING[sectionKey as SectionKey]; + return entries.length === 0 + ? `### ${sectionName}` + : `### ${sectionName}\n\n${entries.map((entry) => ` - ${entry}`).join('\n')}`; + }); + + // Generate full changelog + const currentDate = getCurrentDateFormatted(); + const changelog = `## [${pkgVersion}-${currentDate}]( + ${changelogSections.join('\n\n')} + `; + // Update changelog file + addContentAfterUnreleased(filePath, changelog); + return changelogSections; +} + +function generateReleaseNote(changelogSections: string[]) { + // Generate release note + const releaseNoteFilename = `opensearch-dashboards.release-notes-${pkgVersion}.md`; + const releaseNoteHeader = `# VERSION ${pkgVersion} Release Note`; + const releaseNote = `${releaseNoteHeader}\n\n${changelogSections.join('\n\n')}`; + writeFileSync(resolve(releaseNotesDirPath, releaseNoteFilename), releaseNote); +} + +(async () => { + const { sections, fragmentPaths } = await readFragments(); + + // move fragments to temp fragments folder + await moveFragments(fragmentPaths); + + const changelogSections = generateChangelog(sections); + + generateReleaseNote(changelogSections); + + // remove temp fragments folder + await deleteFragments(); +})(); diff --git a/src/dev/generate_release_note_helper.ts b/src/dev/generate_release_note_helper.ts new file mode 100644 index 000000000000..0f73122755f7 --- /dev/null +++ b/src/dev/generate_release_note_helper.ts @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { resolve } from 'path'; + +export const filePath = resolve(__dirname, '..', '..', 'CHANGELOG.md'); +export const fragmentDirPath = resolve(__dirname, '..', '..', 'changelogs', 'fragments'); +export const fragmentTempDirPath = resolve(__dirname, '..', '..', 'changelogs', 'temp_fragments'); +export const releaseNotesDirPath = resolve(__dirname, '..', '..', 'release-notes'); + +export function getCurrentDateFormatted(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + const day = now.getDate(); + + const formattedMonth = month.toString().padStart(2, '0'); + const formattedDay = day.toString().padStart(2, '0'); + + return `${year}-${formattedMonth}-${formattedDay}`; +} + +export const SECTION_MAPPING = { + breaking: '💥 Breaking Changes', + deprecate: 'Deprecations', + security: '🛡 Security', + feat: '📈 Features/Enhancements', + fix: '🐛 Bug Fixes', + infra: '🚞 Infrastructure', + doc: '📝 Documentation', + chore: '🛠 Maintenance', + refactor: '🪛 Refactoring', + test: '🔩 Tests', +}; + +export type SectionKey = keyof typeof SECTION_MAPPING; +export type Changelog = Record; + +const MAX_ENTRY_LENGTH = 100; + +// validate format of fragment files +export function validateFragment(content: string) { + const sections = content.split('\n\n'); + + // validate each section + for (const section of sections) { + const lines = section.split('\n'); + const sectionName = lines[0]; + const sectionKey = sectionName.slice(0, -1); + + if (!SECTION_MAPPING[sectionKey as SectionKey] || !sectionName.endsWith(':')) { + throw new Error(`Unknown section ${sectionKey}.`); + } + // validate entries. each entry must start with '-' and a space. then followed by a string. string must be non-empty and less than 50 characters + const entryRegex = new RegExp(`^-.{1,${MAX_ENTRY_LENGTH}}\\(\\[#.+]\\(.+\\)\\)$`); + for (const entry of lines.slice(1)) { + if (entry === '') { + continue; + } + // if (!entryRegex.test(entry)) { + if (!entryRegex.test(entry.trim())) { + throw new Error(`Invalid entry ${entry} in section ${sectionKey}.`); + } + } + } +}