diff --git a/.github/scripts/fixtures/release-5.4.2.json b/.github/scripts/fixtures/release-5.4.2.json new file mode 100644 index 000000000..fb7fd83e2 --- /dev/null +++ b/.github/scripts/fixtures/release-5.4.2.json @@ -0,0 +1,32 @@ +[ + { + "name": "@callstack/repack", + "version": "5.4.2", + "changelog": "# @callstack/repack\n\n## 5.4.2\n\n### Patch Changes\n\n- [#1480](https://github.com/callstack/repack/pull/1480) [`166388b`](https://github.com/callstack/repack/commit/166388b76fc4641f15cd63d3e96aec9d7e582c84) Thanks [@CristiCeban](https://github.com/CristiCeban)! - Fix `.jpeg` images not showing in Android release builds. They were emitted to `raw` instead of `drawable-*`, where React Native looks for them.\n\n If you have a `.jpeg` and a `.png`/`.jpg`/`.gif`/`.webp` image with the same name in the same directory (e.g. `logo.png` and `logo.jpeg`), rename one of them. Both now resolve to the same Android drawable resource name, and the build fails with a duplicate resources error.\n\n- Updated dependencies []:\n - @callstack/repack-dev-server@5.4.2\n\n## 5.4.1\n\n### Patch Changes\n\n- [#1479](https://github.com/callstack/repack/pull/1479) [`811a6d8`](https://github.com/callstack/repack/commit/811a6d84ed09ad83f4395c85218e055e7e9abf91) Thanks [@CristiCeban](https://github.com/CristiCeban)! - Ship consumer ProGuard rules for the Android library so release builds with R8 (`minifyEnabled true`) no longer fail with missing `com.google.errorprone.annotations` classes referenced by `nimbus-jose-jwt`.\n\n- [#1476](https://github.com/callstack/repack/pull/1476) [`5bb41f0`](https://github.com/callstack/repack/commit/5bb41f045f43d935fc79b153f889a1195a0f19ad) Thanks [@whydidoo](https://github.com/whydidoo)! - Support React Native 0.88's Flow parser in Babel loaders, native component codegen, and Reanimated transforms while retaining Hermes parser compatibility with older React Native presets.\n\n- [#1473](https://github.com/callstack/repack/pull/1473) [`f9326a0`](https://github.com/callstack/repack/commit/f9326a0a5fcc7de753190fc7576b91320498dfa8) Thanks [@whydidoo](https://github.com/whydidoo)! - Emit an additional `res/raw` copy of bundled Android font binaries so Metro-compatible consumers such as Skia can load them by name. Preserve the existing `res/font` output for native `@font/...`, `R.font`, and XML font-family references. Generate `res/raw/keep.xml` for bundled Android resources so resource shrinking preserves both font copies and other assets loaded by name at runtime.\n\n XML font-family resources continue to use `res/font`. Asset metadata and iOS, development-server, inline, and remote-loader output paths are unchanged.\n\n- Updated dependencies []:\n - @callstack/repack-dev-server@5.4.1\n\n" + }, + { + "name": "@callstack/repack-dev-server", + "version": "5.4.2", + "changelog": "# @callstack/repack-dev-server\n\n## 5.4.2\n\n## 5.4.1\n\n" + }, + { + "name": "@callstack/repack-init", + "version": "5.4.2", + "changelog": "# @callstack/repack-init\n\n## 5.4.2\n\n### Patch Changes\n\n- [#1482](https://github.com/callstack/repack/pull/1482) [`4139cf3`](https://github.com/callstack/repack/commit/4139cf3b9fa5f0f80260bfd0b783621543bf3949) Thanks [@jacekAtCK](https://github.com/jacekAtCK)! - Bootstrap new projects with React Native 0.87.1 and refresh the default bundler dependencies to `@rspack/core@^1.7.12`, `@swc/helpers@^0.5.23`, `webpack@^5.111.1` and `terser-webpack-plugin@^5.6.1`. Generated Rspack and webpack configs now enable package exports (`getResolveOptions({ enablePackageExports: true })`).\n\n## 5.4.1\n\n" + }, + { + "name": "@callstack/repack-plugin-reanimated", + "version": "5.4.2", + "changelog": "# @callstack/repack-plugin-reanimated\n\n## 5.4.2\n\n## 5.4.1\n\n### Patch Changes\n\n- [#1476](https://github.com/callstack/repack/pull/1476) [`5bb41f0`](https://github.com/callstack/repack/commit/5bb41f045f43d935fc79b153f889a1195a0f19ad) Thanks [@whydidoo](https://github.com/whydidoo)! - Support React Native 0.88's Flow parser in Babel loaders, native component codegen, and Reanimated transforms while retaining Hermes parser compatibility with older React Native presets.\n\n" + }, + { + "name": "@callstack/repack-plugin-nativewind", + "version": "5.4.2", + "changelog": "# @callstack/repack-plugin-nativewind\n\n## 5.4.2\n\n## 5.4.1\n\n" + }, + { + "name": "@callstack/repack-plugin-expo-modules", + "version": "5.4.2", + "changelog": "# @callstack/repack-plugin-expo-modules\n\n## 5.4.2\n\n## 5.4.1\n\n" + } +] diff --git a/.github/scripts/release-notes.mjs b/.github/scripts/release-notes.mjs new file mode 100644 index 000000000..9f81b8be5 --- /dev/null +++ b/.github/scripts/release-notes.mjs @@ -0,0 +1,120 @@ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const mainPackage = '@callstack/repack'; + +function changelogEntry(changelog, version) { + const content = []; + let found = false; + let fence; + + for (const line of changelog.split(/\r?\n/)) { + const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]; + if (fence) { + if (new RegExp(`^ {0,3}${fence[0]}{${fence.length},}\\s*$`).test(line)) { + fence = undefined; + } + } else if (marker) { + fence = marker; + } else { + const heading = /^ {0,3}(#{1,2})\s+(.+?)\s*#*\s*$/.exec(line); + if (heading) { + if (found) break; + found = heading[1] === '##' && heading[2] === version; + continue; + } + } + if (found) content.push(line); + } + + if (!found) throw new Error(`Missing changelog entry for ${version}`); + return content.join('\n').trim(); +} + +export function generateReleaseNotes(packages) { + const main = packages.find((pkg) => pkg.name === mainPackage); + if (!main) throw new Error(`Missing ${mainPackage} in the release group`); + + const changes = { Major: new Set(), Minor: new Set(), Patch: new Set() }; + for (const pkg of packages) { + if (pkg.version !== main.version) { + throw new Error( + `Release group version mismatch: ${pkg.name}@${pkg.version}` + ); + } + let entry; + try { + entry = changelogEntry(pkg.changelog, pkg.version); + } catch (error) { + throw new Error(`${pkg.name}: ${error.message}`, { cause: error }); + } + const sections = entry.split(/^### (Major|Minor|Patch) Changes\s*$/m); + for (let index = 1; index < sections.length; index += 2) { + for (const item of sections[index + 1].split(/(?=^- )/m)) { + const note = item.trim(); + if (note && !note.startsWith('- Updated dependencies')) { + changes[sections[index]].add(note); + } + } + } + } + + return [ + '## Packages', + packages.map((pkg) => `- \`${pkg.name}@${pkg.version}\``).join('\n'), + ...Object.entries(changes) + .filter(([, notes]) => notes.size) + .map( + ([type, notes]) => `## ${type} Changes\n\n${[...notes].join('\n\n')}` + ), + ].join('\n\n'); +} + +export async function readRelease(root) { + const config = JSON.parse( + await readFile(path.join(root, '.changeset/config.json'), 'utf8') + ); + const group = config.fixed.find((names) => names.includes(mainPackage)); + if (!group) throw new Error(`Missing fixed release group for ${mainPackage}`); + + const manifests = await Promise.all( + (await readdir(path.join(root, 'packages'))).map(async (directory) => { + const dir = path.join(root, 'packages', directory); + const manifest = JSON.parse( + await readFile(path.join(dir, 'package.json'), 'utf8') + ); + return { dir, ...manifest }; + }) + ); + const packages = await Promise.all( + group.map(async (name) => { + const pkg = manifests.find((manifest) => manifest.name === name); + if (!pkg || pkg.private || config.ignore.includes(name)) { + throw new Error( + `Release group package is missing or excluded: ${name}` + ); + } + return { + name, + version: pkg.version, + changelog: await readFile(path.join(pkg.dir, 'CHANGELOG.md'), 'utf8'), + }; + }) + ); + return generateReleaseNotes(packages); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + console.log( + await readRelease(fileURLToPath(new URL('../../', import.meta.url))) + ); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/.github/scripts/release-notes.test.mjs b/.github/scripts/release-notes.test.mjs new file mode 100644 index 000000000..d69ef57b3 --- /dev/null +++ b/.github/scripts/release-notes.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { generateReleaseNotes } from './release-notes.mjs'; + +const fixture = JSON.parse( + await readFile( + new URL('./fixtures/release-5.4.2.json', import.meta.url), + 'utf8' + ) +); + +test('merges the actual 5.4.2 notes without dependency updates or empty sections', () => { + const notes = generateReleaseNotes(fixture); + assert.match(notes, /Fix `\.jpeg` images/); + assert.match(notes, /Bootstrap new projects with React Native 0\.87\.1/); + assert.match(notes, /\n\n {2}If you have a `\.jpeg`/); + assert.equal((notes.match(/^## Patch Changes$/gm) ?? []).length, 1); + for (const pkg of fixture) { + assert.ok(notes.includes(`- \`${pkg.name}@${pkg.version}\``)); + } + assert.doesNotMatch( + notes, + /Updated dependencies|^## @callstack\/|5\.4\.1|#1479|#1476|consumer ProGuard/m + ); +}); + +test('includes the shared 5.4.1 changeset only once across packages', () => { + const notes = generateReleaseNotes( + fixture.map((pkg) => ({ ...pkg, version: '5.4.1' })) + ); + assert.equal((notes.match(/\[#1476\]/g) ?? []).length, 1); + assert.equal((notes.match(/^## Patch Changes$/gm) ?? []).length, 1); + assert.match(notes, /consumer ProGuard/); + assert.match(notes, /XML font-family resources continue to use `res\/font`/); + assert.doesNotMatch(notes, /Updated dependencies|#1480|#1482|5\.4\.2/); +}); + +test('groups changes by type and preserves multiline notes including fenced headings', () => { + const example = + '- Example\n\n ```md\n ## 0.1.0\n ### Patch Changes\n - Nested item\n ```'; + const notes = generateReleaseNotes([ + { + name: '@callstack/repack', + version: '6.0.0', + changelog: `## 6.0.0\n\n### Patch Changes\n\n${example}\n\n### Major Changes\n\n- Breaking change\n\n## 5.4.2\n\nOld notes`, + }, + { + name: '@callstack/repack-init', + version: '6.0.0', + changelog: + '## 6.0.0\n\n### Minor Changes\n\n- New option\n\n### Patch Changes\n\n- Another fix', + }, + ]); + assert.ok(notes.includes(example)); + assert.ok( + notes.indexOf('## Major Changes') < notes.indexOf('## Minor Changes') + ); + assert.ok( + notes.indexOf('## Minor Changes') < notes.indexOf('## Patch Changes') + ); + assert.match(notes, /- Another fix/); + assert.doesNotMatch(notes, /Old notes/); +}); + +test('fails instead of including another version when the requested entry is missing', () => { + const packages = structuredClone(fixture); + packages[0].changelog = '# Package\n\n## 5.4.20\n\nWrong release'; + assert.throws( + () => generateReleaseNotes(packages), + /repack: Missing changelog entry for 5\.4\.2/ + ); +}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c99e9599b..770c7a09a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,12 +52,31 @@ jobs: publish-script: pnpm release commit-message: 'chore: changeset version' pr-title: 'chore: changeset version' - create-github-releases: true + create-github-releases: false + push-git-tags: true github-token: ${{ secrets.GITHUB_TOKEN }} env: # @changesets/changelog-github reads the token from the environment. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Create combined GitHub release + if: steps.changesets.outputs.published == 'true' + run: | + version=$(node -p "require('./packages/repack/package.json').version") + tag="@callstack/repack@${version}" + if gh release view "$tag" >/dev/null 2>&1; then + exit 0 + fi + node .github/scripts/release-notes.mjs > "$RUNNER_TEMP/release-notes.md" + args=() + if [[ "$version" == *-* ]]; then + args+=(--prerelease) + fi + gh release create "$tag" --verify-tag --title "$version" \ + --notes-file "$RUNNER_TEMP/release-notes.md" "${args[@]}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + canary-release: name: Canary Release runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 296a8ff62..a2cbc7fd7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,3 +56,6 @@ jobs: - name: Run tests run: pnpm test + + - name: Test release notes + run: pnpm test:release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d5e12c6c..aa48925f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,8 +91,39 @@ package version changes and generated changelog entries for the next release. Review the version numbers and changelog entries in that PR before merging it. When the version PR is merged to `main`, the same workflow runs `pnpm release`, -publishes the packages to npm, pushes the release tags, and creates GitHub -releases from the generated changelog entries. +publishes the packages to npm and pushes all package release tags. A separate +step creates one GitHub release on the main package's tag (for example, +`@callstack/repack@5.4.2`), named after the shared version. Its notes list all +packages in the fixed release group and merge their changelog entries by change +type, without duplicate entries or dependency update bullets. Package changelogs +and npm publishing are still managed by Changesets. Existing GitHub releases +are left untouched. + +Preview the combined notes without network access or credentials: + +```sh +node .github/scripts/release-notes.mjs +pnpm test:release +``` + +The GitHub release step runs only when Changesets publishes packages. If it +fails after npm publication, rerunning the workflow may not retry that step, +because there are no new packages to publish. To create the missing GitHub +release manually, check out the original release commit, confirm the packages +are published and their release tags exist, then run: + +```sh +version=$(node -p "require('./packages/repack/package.json').version") +tag="@callstack/repack@${version}" +node .github/scripts/release-notes.mjs > /tmp/repack-release-notes.md +gh release create "$tag" --verify-tag --title "$version" \ + --notes-file /tmp/repack-release-notes.md +``` + +For a prerelease version, add `--prerelease` to the `gh release create` command. +This requires an authenticated GitHub CLI with permission to create releases. +It does not recover npm publications or recreate package tags. The combined +release covers the fixed group containing `@callstack/repack`. #### Canary releases diff --git a/package.json b/package.json index c20ae6901..69154b079 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "lint:ci": "biome check", "typecheck": "turbo run typecheck", "test": "turbo run test", + "test:release": "node --test .github/scripts/*.test.mjs", "version-packages": "pnpm changeset version && pnpm lint", "version-packages:canary": "pnpm changeset version --snapshot canary && pnpm lint", "release": "pnpm changeset publish",