From 618eddc459f9cc895e19409a001e15a08916c445 Mon Sep 17 00:00:00 2001 From: Ernest Date: Thu, 2 Jul 2026 13:15:00 +0200 Subject: [PATCH 1/4] chore: add tags for easier release --- .github/release.yml | 31 +++++++++ .github/workflows/label-by-prefix.yml | 91 +++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 .github/release.yml create mode 100644 .github/workflows/label-by-prefix.yml diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..e9d349f9b --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,31 @@ +# Controls how GitHub's "Generate release notes" button groups merged PRs. +# Grouping is by label; the label-by-prefix workflow applies a `type: *` +# label to every PR based on its conventional-commit title prefix, so these +# categories line up with the prefixes (feat/fix/refactor/test/docs/chore). +changelog: + exclude: + labels: + - ignore-for-release + authors: + - dependabot + - github-actions + categories: + - title: New Features + labels: + - 'type: feature' + - title: Fixes & Improvements + labels: + - 'type: fix' + - title: Refactors + labels: + - 'type: refactor' + - title: Tests + labels: + - 'type: test' + - title: Docs & Chores + labels: + - 'type: docs' + - 'type: chore' + - title: Other Changes + labels: + - '*' diff --git a/.github/workflows/label-by-prefix.yml b/.github/workflows/label-by-prefix.yml new file mode 100644 index 000000000..064105c0d --- /dev/null +++ b/.github/workflows/label-by-prefix.yml @@ -0,0 +1,91 @@ +name: Prepare PR + +# Applies a single `type: *` label based on the PR's conventional-commit +# title prefix (feat/fix/refactor/test/docs/chore/...). Those labels are what +# `.github/release.yml` groups on when you click "Generate release notes". +# +# Uses pull_request_target so it can label PRs opened from forks too. It only +# reads the PR title and manages labels (never checks out or runs PR code), so +# there is no untrusted-code execution risk. +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: write + +concurrency: + group: prepare-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + label: + if: github.repository == 'software-mansion/react-native-enriched-markdown' + runs-on: ubuntu-latest + steps: + - name: Apply type label from title prefix + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + // conventional-commit prefix -> label metadata + const MAP = { + feat: { name: 'type: feature', color: 'a2eeef', description: 'New feature' }, + fix: { name: 'type: fix', color: 'd73a4a', description: 'Bug fix or improvement' }, + perf: { name: 'type: fix', color: 'd73a4a', description: 'Bug fix or improvement' }, + refactor: { name: 'type: refactor', color: 'fbca04', description: 'Code refactor' }, + test: { name: 'type: test', color: '0e8a16', description: 'Tests' }, + docs: { name: 'type: docs', color: '0075ca', description: 'Documentation' }, + chore: { name: 'type: chore', color: 'cfd3d7', description: 'Chore / tooling' }, + build: { name: 'type: chore', color: 'cfd3d7', description: 'Chore / tooling' }, + ci: { name: 'type: chore', color: 'cfd3d7', description: 'Chore / tooling' }, + style: { name: 'type: chore', color: 'cfd3d7', description: 'Chore / tooling' }, + }; + + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const title = pr.title || ''; + + // e.g. "feat(android): ..." / "fix!: ..." -> "feat" / "fix" + const m = title.match(/^(\w+)(?:\([^)]*\))?!?:/); + const type = m ? m[1].toLowerCase() : null; + const target = type && MAP[type] ? MAP[type] : null; + + const managed = new Set(Object.values(MAP).map((v) => v.name)); + const current = pr.labels.map((l) => l.name); + + // Remove any managed label that isn't the one we want now + for (const name of current) { + if (managed.has(name) && (!target || name !== target.name)) { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: pr.number, name, + }).catch(() => {}); + } + } + + if (!target) { + core.info(`No known type prefix in title: "${title}" — no label applied.`); + return; + } + + // Ensure the label exists (with a nice color) before adding it + try { + await github.rest.issues.getLabel({ owner, repo, name: target.name }); + } catch (e) { + if (e.status === 404) { + await github.rest.issues.createLabel({ + owner, repo, name: target.name, + color: target.color, description: target.description, + }); + } else { + throw e; + } + } + + if (!current.includes(target.name)) { + await github.rest.issues.addLabels({ + owner, repo, issue_number: pr.number, labels: [target.name], + }); + core.info(`Applied "${target.name}" from prefix "${type}".`); + } else { + core.info(`"${target.name}" already present.`); + } From 78df91f3f94548f15eebb85c616a118efd746ebe Mon Sep 17 00:00:00 2001 From: Ernest Date: Mon, 6 Jul 2026 13:00:47 +0200 Subject: [PATCH 2/4] feat: replace gh scripts with just plane script --- .github/release.yml | 31 ------ .github/workflows/label-by-prefix.yml | 91 --------------- scripts/generate-changelog.mjs | 154 ++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 122 deletions(-) delete mode 100644 .github/release.yml delete mode 100644 .github/workflows/label-by-prefix.yml create mode 100755 scripts/generate-changelog.mjs diff --git a/.github/release.yml b/.github/release.yml deleted file mode 100644 index e9d349f9b..000000000 --- a/.github/release.yml +++ /dev/null @@ -1,31 +0,0 @@ -# Controls how GitHub's "Generate release notes" button groups merged PRs. -# Grouping is by label; the label-by-prefix workflow applies a `type: *` -# label to every PR based on its conventional-commit title prefix, so these -# categories line up with the prefixes (feat/fix/refactor/test/docs/chore). -changelog: - exclude: - labels: - - ignore-for-release - authors: - - dependabot - - github-actions - categories: - - title: New Features - labels: - - 'type: feature' - - title: Fixes & Improvements - labels: - - 'type: fix' - - title: Refactors - labels: - - 'type: refactor' - - title: Tests - labels: - - 'type: test' - - title: Docs & Chores - labels: - - 'type: docs' - - 'type: chore' - - title: Other Changes - labels: - - '*' diff --git a/.github/workflows/label-by-prefix.yml b/.github/workflows/label-by-prefix.yml deleted file mode 100644 index 064105c0d..000000000 --- a/.github/workflows/label-by-prefix.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Prepare PR - -# Applies a single `type: *` label based on the PR's conventional-commit -# title prefix (feat/fix/refactor/test/docs/chore/...). Those labels are what -# `.github/release.yml` groups on when you click "Generate release notes". -# -# Uses pull_request_target so it can label PRs opened from forks too. It only -# reads the PR title and manages labels (never checks out or runs PR code), so -# there is no untrusted-code execution risk. -on: - pull_request_target: - types: [opened, edited, synchronize, reopened] - -permissions: - pull-requests: write - -concurrency: - group: prepare-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - label: - if: github.repository == 'software-mansion/react-native-enriched-markdown' - runs-on: ubuntu-latest - steps: - - name: Apply type label from title prefix - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - // conventional-commit prefix -> label metadata - const MAP = { - feat: { name: 'type: feature', color: 'a2eeef', description: 'New feature' }, - fix: { name: 'type: fix', color: 'd73a4a', description: 'Bug fix or improvement' }, - perf: { name: 'type: fix', color: 'd73a4a', description: 'Bug fix or improvement' }, - refactor: { name: 'type: refactor', color: 'fbca04', description: 'Code refactor' }, - test: { name: 'type: test', color: '0e8a16', description: 'Tests' }, - docs: { name: 'type: docs', color: '0075ca', description: 'Documentation' }, - chore: { name: 'type: chore', color: 'cfd3d7', description: 'Chore / tooling' }, - build: { name: 'type: chore', color: 'cfd3d7', description: 'Chore / tooling' }, - ci: { name: 'type: chore', color: 'cfd3d7', description: 'Chore / tooling' }, - style: { name: 'type: chore', color: 'cfd3d7', description: 'Chore / tooling' }, - }; - - const { owner, repo } = context.repo; - const pr = context.payload.pull_request; - const title = pr.title || ''; - - // e.g. "feat(android): ..." / "fix!: ..." -> "feat" / "fix" - const m = title.match(/^(\w+)(?:\([^)]*\))?!?:/); - const type = m ? m[1].toLowerCase() : null; - const target = type && MAP[type] ? MAP[type] : null; - - const managed = new Set(Object.values(MAP).map((v) => v.name)); - const current = pr.labels.map((l) => l.name); - - // Remove any managed label that isn't the one we want now - for (const name of current) { - if (managed.has(name) && (!target || name !== target.name)) { - await github.rest.issues.removeLabel({ - owner, repo, issue_number: pr.number, name, - }).catch(() => {}); - } - } - - if (!target) { - core.info(`No known type prefix in title: "${title}" — no label applied.`); - return; - } - - // Ensure the label exists (with a nice color) before adding it - try { - await github.rest.issues.getLabel({ owner, repo, name: target.name }); - } catch (e) { - if (e.status === 404) { - await github.rest.issues.createLabel({ - owner, repo, name: target.name, - color: target.color, description: target.description, - }); - } else { - throw e; - } - } - - if (!current.includes(target.name)) { - await github.rest.issues.addLabels({ - owner, repo, issue_number: pr.number, labels: [target.name], - }); - core.info(`Applied "${target.name}" from prefix "${type}".`); - } else { - core.info(`"${target.name}" already present.`); - } diff --git a/scripts/generate-changelog.mjs b/scripts/generate-changelog.mjs new file mode 100755 index 000000000..1234a0528 --- /dev/null +++ b/scripts/generate-changelog.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +// Prints GitHub-release-style markdown for all commits since a tag. +// Usage: ./scripts/generate-changelog.mjs [to-ref] | pbcopy +import { execFileSync } from 'node:child_process'; + +const REPO = 'software-mansion/react-native-enriched-markdown'; +const [OWNER, NAME] = REPO.split('/'); + +const SECTIONS = [ + { title: 'New Features', types: ['feat'] }, + { title: 'Fixes & Improvements', types: ['fix', 'perf'] }, + { title: 'Refactors', types: ['refactor'] }, + { title: 'Tests', types: ['test'] }, + { title: 'Docs & Chores', types: ['docs', 'chore', 'build', 'ci'] }, + { title: 'Other Changes', types: [] }, +]; + +function git(...args) { + return execFileSync('git', args, { encoding: 'utf8' }).trim(); +} + +function ghGraphql(query) { + try { + const out = execFileSync('gh', ['api', 'graphql', '-f', `query=${query}`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return JSON.parse(out); + } catch (e) { + // gh exits non-zero on partial GraphQL errors but still prints the data + if (e.stdout) { + try { + return JSON.parse(e.stdout); + } catch {} + } + throw e; + } +} + +const [from, to = 'HEAD'] = process.argv.slice(2); +if (!from) { + console.error('usage: generate-changelog.mjs [to-ref]'); + process.exit(1); +} +git('rev-parse', '--verify', '--quiet', `${from}^{commit}`); + +const SEP = '\x1f'; +const log = git('log', '--reverse', `--format=%H${SEP}%an${SEP}%ae${SEP}%s`, `${from}..${to}`); +const commits = (log ? log.split('\n') : []) + .map((line) => { + const [hash, name, email, subject] = line.split(SEP); + const pr = subject.match(/\(#(\d+)\)\s*$/)?.[1]; + const title = subject.replace(/\s*\(#\d+\)\s*$/, ''); + const type = title + .match(/^\s*([a-zA-Z]+)\s*(?:\([^)]*\))?\s*!?\s*:/)?.[1] + ?.toLowerCase(); + return { hash, name, email, title, pr, type }; + }) + .filter((c) => !/dependabot|github-actions|\[bot\]/i.test(`${c.name} ${c.email}`)); + +if (!commits.length) { + console.error(`no commits in ${from}..${to}`); + process.exit(1); +} + +const prNumbers = [...new Set(commits.filter((c) => c.pr).map((c) => c.pr))]; +const loginByPr = new Map(); +try { + for (let i = 0; i < prNumbers.length; i += 50) { + const chunk = prNumbers.slice(i, i + 50); + const fields = chunk + .map((n) => `pr${n}: pullRequest(number: ${n}) { author { login } }`) + .join(' '); + const data = ghGraphql( + `query { repository(owner: "${OWNER}", name: "${NAME}") { ${fields} } }` + ); + for (const n of chunk) { + const login = data.data?.repository?.[`pr${n}`]?.author?.login; + if (login) loginByPr.set(n, login); + } + } +} catch { + console.error('warning: gh author lookup failed, falling back to commit author names'); +} + +function authorRef(c) { + const login = + (c.pr && loginByPr.get(c.pr)) || + c.email.match(/^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/)?.[1]; + return login ? `[@${login}](https://github.com/${login})` : c.name; +} + +function itemLine(c) { + const where = c.pr + ? `[#${c.pr}](https://github.com/${REPO}/pull/${c.pr})` + : `[\`${c.hash.slice(0, 7)}\`](https://github.com/${REPO}/commit/${c.hash})`; + return `* ${c.title} by ${authorRef(c)} in ${where}`; +} + +const typeToTitle = new Map( + SECTIONS.flatMap((s) => s.types.map((t) => [t, s.title])) +); +const byTitle = new Map(SECTIONS.map((s) => [s.title, []])); +for (const c of commits) { + byTitle.get(typeToTitle.get(c.type) ?? 'Other Changes').push(c); +} + +const out = ["# What's Changed"]; +for (const s of SECTIONS) { + const items = byTitle.get(s.title); + if (!items.length) continue; + out.push('', `## ${s.title}`, '', ...items.map(itemLine)); +} + +// first in-range PR per login, then keep only logins with no merged PR before the tag +const firstPrByLogin = new Map(); +for (const c of commits) { + const login = c.pr && loginByPr.get(c.pr); + if (login && !firstPrByLogin.has(login)) firstPrByLogin.set(login, c.pr); +} +let newContributors = []; +if (firstPrByLogin.size) { + try { + const cutoff = git('log', '-1', '--format=%cI', from); + const logins = [...firstPrByLogin.keys()]; + const fields = logins + .map( + (l, i) => + `u${i}: search(query: "repo:${REPO} is:pr is:merged author:${l} merged:<${cutoff}", type: ISSUE, first: 1) { issueCount }` + ) + .join(' '); + const data = ghGraphql(`query { ${fields} }`); + newContributors = logins.filter((l, i) => data.data?.[`u${i}`]?.issueCount === 0); + } catch { + console.error('warning: new-contributor lookup failed, skipping that section'); + } +} +if (newContributors.length) { + out.push( + '', + '## New Contributors', + '', + ...newContributors.map((l) => { + const pr = firstPrByLogin.get(l); + return `* [@${l}](https://github.com/${l}) made their first contribution in [#${pr}](https://github.com/${REPO}/pull/${pr})`; + }) + ); +} + +out.push( + '', + `**Full Changelog**: https://github.com/${REPO}/compare/${from}...${to === 'HEAD' ? 'main' : to}` +); +console.log(out.join('\n')); From b5100873162ec241adaeb4b21a258777669f8479 Mon Sep 17 00:00:00 2001 From: Ernest Date: Mon, 6 Jul 2026 13:11:34 +0200 Subject: [PATCH 3/4] docs: added a doc related the script --- scripts/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 scripts/README.md diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 000000000..e529db5f6 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,22 @@ +# Maintainer scripts + +Repo-level tooling, mostly for releases. Not needed for regular contribution work — see [CONTRIBUTING.md](../CONTRIBUTING.md) for that. + +## generate-changelog.mjs + +Prints GitHub-release-style markdown for all commits since a tag, grouped by conventional-commit prefix (feat / fix / refactor / test / docs & chores), with PR links, author handles, and a New Contributors section. + +```sh +./scripts/generate-changelog.mjs v0.7.0 | pbcopy # everything since v0.7.0 +./scripts/generate-changelog.mjs v0.6.0 v0.7.0 # explicit range +``` + +Author handles and the New Contributors section are resolved through an authenticated [GitHub CLI](https://cli.github.com/); without it the script falls back to plain commit author names. + +## prepare-npm-publish.sh + +`prepack`/`postpack` hooks for the library package: swaps the `cpp` symlink for a real copy of `packages/core/cpp` while packing. Run automatically by npm, not by hand. + +## fetch-md4c.sh + +Syncs `packages/core/cpp/md4c` from upstream [mity/md4c](https://github.com/mity/md4c). Run via `yarn workspace react-native-enriched-markdown sync-md4c`. From 486cf2916453e1593a4932de3a756668105faa82 Mon Sep 17 00:00:00 2001 From: Ernest Date: Mon, 6 Jul 2026 13:21:28 +0200 Subject: [PATCH 4/4] fix: address review comments on changelog script - validate from/to refs with a clear error instead of a stack trace - match usage string to the documented invocation - chunk the new-contributor GraphQL search like the PR-author lookup - sync README grouping description with actual prefix mapping Co-Authored-By: Claude Fable 5 --- scripts/README.md | 2 +- scripts/generate-changelog.mjs | 32 ++++++++++++++++++++++---------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index e529db5f6..a5197ce2d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,7 +4,7 @@ Repo-level tooling, mostly for releases. Not needed for regular contribution wor ## generate-changelog.mjs -Prints GitHub-release-style markdown for all commits since a tag, grouped by conventional-commit prefix (feat / fix / refactor / test / docs & chores), with PR links, author handles, and a New Contributors section. +Prints GitHub-release-style markdown for all commits since a tag, with PR links, author handles, and a New Contributors section. Commits are grouped by conventional-commit prefix: `feat` → New Features, `fix`/`perf` → Fixes & Improvements, `refactor` → Refactors, `test` → Tests, `docs`/`chore`/`build`/`ci` → Docs & Chores, and anything else → Other Changes. ```sh ./scripts/generate-changelog.mjs v0.7.0 | pbcopy # everything since v0.7.0 diff --git a/scripts/generate-changelog.mjs b/scripts/generate-changelog.mjs index 1234a0528..8261f4a09 100755 --- a/scripts/generate-changelog.mjs +++ b/scripts/generate-changelog.mjs @@ -39,10 +39,17 @@ function ghGraphql(query) { const [from, to = 'HEAD'] = process.argv.slice(2); if (!from) { - console.error('usage: generate-changelog.mjs [to-ref]'); + console.error('usage: ./scripts/generate-changelog.mjs [to-ref]'); process.exit(1); } -git('rev-parse', '--verify', '--quiet', `${from}^{commit}`); +for (const ref of [from, to]) { + try { + git('rev-parse', '--verify', '--quiet', `${ref}^{commit}`); + } catch { + console.error(`error: '${ref}' is not a known git ref`); + process.exit(1); + } +} const SEP = '\x1f'; const log = git('log', '--reverse', `--format=%H${SEP}%an${SEP}%ae${SEP}%s`, `${from}..${to}`); @@ -123,14 +130,19 @@ if (firstPrByLogin.size) { try { const cutoff = git('log', '-1', '--format=%cI', from); const logins = [...firstPrByLogin.keys()]; - const fields = logins - .map( - (l, i) => - `u${i}: search(query: "repo:${REPO} is:pr is:merged author:${l} merged:<${cutoff}", type: ISSUE, first: 1) { issueCount }` - ) - .join(' '); - const data = ghGraphql(`query { ${fields} }`); - newContributors = logins.filter((l, i) => data.data?.[`u${i}`]?.issueCount === 0); + for (let i = 0; i < logins.length; i += 25) { + const chunk = logins.slice(i, i + 25); + const fields = chunk + .map( + (l, j) => + `u${j}: search(query: "repo:${REPO} is:pr is:merged author:${l} merged:<${cutoff}", type: ISSUE, first: 1) { issueCount }` + ) + .join(' '); + const data = ghGraphql(`query { ${fields} }`); + newContributors.push( + ...chunk.filter((l, j) => data.data?.[`u${j}`]?.issueCount === 0) + ); + } } catch { console.error('warning: new-contributor lookup failed, skipping that section'); }