From 714cbb6219ca4a4abdcb94a6710e6d40e2b02ca6 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:46:54 +0200 Subject: [PATCH 1/6] ci: add PR auto-labeler and auto-generated release notes - .github/workflows/pr-labeler.yml: labels PRs from conventional-commit title prefix (feat -> enhancement, fix -> bug, docs -> documentation, chore/refactor/style/test/ci/build/revert -> chore, perf -> enhancement). Runs on pull_request opened/edited/synchronize. No secrets needed. - .github/release.yml: maps those labels to release note section headings (New Features, Bug Fixes, Documentation, Chores, Other Changes). PRs tagged skip-changelog are excluded. - .github/workflows/release.yml: replace manual git-log notes block with --generate-notes so GitHub builds categorized release notes from the label config automatically on every release dispatch. NOTE for repo owner: create a 'chore' label in lidge-jun/opencodex gh label create chore --repo lidge-jun/opencodex --color 'e4e669' --description 'Maintenance, refactors, CI, and non-user-facing changes' --- .github/release.yml | 24 +++++++++ .github/workflows/pr-labeler.yml | 91 ++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 38 ++----------- 3 files changed, 119 insertions(+), 34 deletions(-) create mode 100644 .github/release.yml create mode 100644 .github/workflows/pr-labeler.yml diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000000..26c8cb4039f --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,24 @@ +# GitHub release notes category config. +# Maps PR labels (applied by pr-labeler.yml) to release note section headings. +# Docs: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes + +changelog: + exclude: + labels: + - skip-changelog + categories: + - title: New Features + labels: + - enhancement + - title: Bug Fixes + labels: + - bug + - title: Documentation + labels: + - documentation + - title: Chores + labels: + - chore + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 00000000000..728af0a366d --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,91 @@ +name: PR Labeler + +# Automatically applies a single type label to every PR based on its title +# prefix (conventional-commits style). No secrets or external services needed. + +on: + pull_request: + types: [opened, edited, synchronize] + +concurrency: + group: pr-labeler-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Apply type label from PR title + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const title = context.payload.pull_request.title || ''; + const pr = context.payload.pull_request.number; + + // Map conventional-commit prefix -> GitHub label name. + const PREFIX_TO_LABEL = { + 'feat': 'enhancement', + 'feature': 'enhancement', + 'fix': 'bug', + 'bugfix': 'bug', + 'hotfix': 'bug', + 'docs': 'documentation', + 'doc': 'documentation', + 'chore': 'chore', + 'refactor': 'chore', + 'style': 'chore', + 'test': 'chore', + 'tests': 'chore', + 'ci': 'chore', + 'build': 'chore', + 'perf': 'enhancement', + 'revert': 'chore', + }; + + const TYPE_LABELS = new Set(Object.values(PREFIX_TO_LABEL)); + + // Match "prefix:" or "prefix(scope):" at the start of the title. + const match = title.match(/^([a-zA-Z]+)(?:\([^)]*\))?[!]?\s*:/); + const detected = match ? PREFIX_TO_LABEL[match[1].toLowerCase()] : null; + + if (!detected) { + core.info(`No conventional-commit prefix detected in: "${title}" — skipping`); + return; + } + + // Read current labels on the PR. + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr, + }); + const current = new Set(currentLabels.map(l => l.name)); + + // Remove any stale type label that no longer matches. + for (const label of TYPE_LABELS) { + if (current.has(label) && label !== detected) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr, + name: label, + }); + } + } + + // Add the detected label if not already present. + if (!current.has(detected)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr, + labels: [detected], + }); + core.info(`Applied label "${detected}" to PR #${pr}`); + } else { + core.info(`Label "${detected}" already present on PR #${pr}`); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ee9cf0c7ad..5887e26819a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -265,7 +265,6 @@ jobs: set -euo pipefail release_tag="v${RELEASE_VERSION}" - notes_file="$(mktemp)" git fetch --force --tags origin @@ -275,37 +274,6 @@ jobs: exit 1 fi - previous_tag="$( - git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname | - grep -vx "$release_tag" | - head -n 1 || true - )" - - { - echo "Published to npm as \`@bitkyc08/opencodex@${RELEASE_VERSION}\` with dist-tag \`${NPM_DIST_TAG}\`." - echo - echo "## Commit log" - echo - if [ -n "$previous_tag" ]; then - echo "Changes since \`${previous_tag}\`:" - echo - git log --reverse --no-merges --pretty=format:'- %s (%h)' "${previous_tag}..HEAD" | - grep -vE '^- release: v[0-9]+\.[0-9]+\.[0-9]+' || true - echo - echo - echo "[Full diff](https://github.com/${GITHUB_REPOSITORY}/compare/${previous_tag}...${release_tag})" - else - echo "Initial release commits:" - echo - git log --reverse --no-merges --pretty=format:'- %s (%h)' HEAD | - grep -vE '^- release: v[0-9]+\.[0-9]+\.[0-9]+' || true - fi - } > "$notes_file" - - if ! grep -q '^- ' "$notes_file"; then - printf '\n- Release version bump only.\n' >> "$notes_file" - fi - if [ -z "$existing_tag_sha" ]; then git tag "$release_tag" "$GITHUB_SHA" git push origin "refs/tags/${release_tag}" @@ -318,8 +286,10 @@ jobs: prerelease_flag="--prerelease" fi + # Release notes are generated automatically from merged PR labels using + # .github/release.yml categories (populated by pr-labeler.yml). if gh release view "$release_tag" >/dev/null 2>&1; then - gh release edit "$release_tag" --title "$release_tag" --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} + gh release edit "$release_tag" --title "$release_tag" --generate-notes ${prerelease_flag:+$prerelease_flag} else - gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} + gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" --generate-notes ${prerelease_flag:+$prerelease_flag} fi From 38610866f4fb76ea532b00c791662ce30055a13e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:51:20 +0200 Subject: [PATCH 2/6] ci(pr-labeler): nudge PR creator when title has no conventional prefix When no prefix is detected, post a one-time comment tagging the PR creator with examples and a note that the workflow will re-run once the title is updated. Uses an HTML comment marker to avoid posting the same nudge twice on subsequent edits. --- .github/workflows/pr-labeler.yml | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 728af0a366d..c1487e855c9 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -14,6 +14,7 @@ concurrency: permissions: contents: read pull-requests: write + issues: write jobs: label: @@ -53,7 +54,35 @@ jobs: const detected = match ? PREFIX_TO_LABEL[match[1].toLowerCase()] : null; if (!detected) { - core.info(`No conventional-commit prefix detected in: "${title}" — skipping`); + const creator = context.payload.pull_request.user.login; + const existing = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr, + }); + const alreadyCommented = existing.data.some( + c => c.user.login === 'github-actions[bot]' && + c.body.includes('') + ); + if (!alreadyCommented) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr, + body: [ + '', + `Hey @${creator} — could you add a [conventional-commit](https://www.conventionalcommits.org) prefix to the PR title? This lets the release notes generator categorize it automatically.`, + '', + 'Examples:', + '- `feat: Add Cursor Router optimization levels`', + '- `fix: Resolve auth token refresh loop`', + '- `docs: Update provider setup guide`', + '- `chore: Bump bun to 1.3.15`', + '', + 'Once updated, this workflow will re-run and apply the label automatically.', + ].join('\n'), + }); + } return; } From a7f67a26cdd8b4fb788e4c79ce9c711f6afbe96c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:51:54 +0200 Subject: [PATCH 3/6] fix(pr-labeler): use pull_request_target to fix 403 on fork PRs pull_request runs in the fork context, where GITHUB_TOKEN is read-only even with write permissions declared. pull_request_target always runs in the base repo context so the token can write labels and comments. Safe here because we never check out fork code. --- .github/workflows/pr-labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index c1487e855c9..001e57d7308 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -4,7 +4,7 @@ name: PR Labeler # prefix (conventional-commits style). No secrets or external services needed. on: - pull_request: + pull_request_target: types: [opened, edited, synchronize] concurrency: From 26c2dc245eeba4ee4a4740aa2dc54eb8de7031b2 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:57:35 +0200 Subject: [PATCH 4/6] fix(pr-labeler): clear stale type labels when prefix is removed from title If a PR was labeled 'enhancement' from 'feat: ...' and the author edits the title to remove the prefix, the old label was left behind and would still categorize the PR in release notes. Now clears any managed type label before posting the nudge comment. --- .github/workflows/pr-labeler.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 001e57d7308..c0a6ad77e99 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -54,6 +54,23 @@ jobs: const detected = match ? PREFIX_TO_LABEL[match[1].toLowerCase()] : null; if (!detected) { + // Clear any stale type labels left from a previous prefix. + const { data: staleCheck } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr, + }); + for (const label of staleCheck) { + if (TYPE_LABELS.has(label.name)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr, + name: label.name, + }); + } + } + const creator = context.payload.pull_request.user.login; const existing = await github.rest.issues.listComments({ owner: context.repo.owner, From bd2a0ba9451fa57174cc44af1c1fc6e70e322d47 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:00:13 +0200 Subject: [PATCH 5/6] fix: guard Object.prototype leak in prefix lookup; fix gh release edit notes pr-labeler.yml: use hasOwnProperty guard on PREFIX_TO_LABEL so prefixes like 'constructor' or 'toString' don't resolve to inherited prototype members and blow up the addLabels call. release.yml: gh release edit does not support --generate-notes (only gh release create does). For the edit path, call the GitHub generate-notes API directly and pass the result via --notes-file instead. --- .github/workflows/pr-labeler.yml | 5 ++++- .github/workflows/release.yml | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index c0a6ad77e99..bde2d2408eb 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -51,7 +51,10 @@ jobs: // Match "prefix:" or "prefix(scope):" at the start of the title. const match = title.match(/^([a-zA-Z]+)(?:\([^)]*\))?[!]?\s*:/); - const detected = match ? PREFIX_TO_LABEL[match[1].toLowerCase()] : null; + const key = match ? match[1].toLowerCase() : null; + const detected = key && Object.prototype.hasOwnProperty.call(PREFIX_TO_LABEL, key) + ? PREFIX_TO_LABEL[key] + : null; if (!detected) { // Clear any stale type labels left from a previous prefix. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5887e26819a..a66e7cdc819 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -289,7 +289,13 @@ jobs: # Release notes are generated automatically from merged PR labels using # .github/release.yml categories (populated by pr-labeler.yml). if gh release view "$release_tag" >/dev/null 2>&1; then - gh release edit "$release_tag" --title "$release_tag" --generate-notes ${prerelease_flag:+$prerelease_flag} + # gh release edit does not support --generate-notes; generate the + # notes via the API first, then pass them through --notes-file. + notes_file="$(mktemp)" + gh api repos/"${GITHUB_REPOSITORY}"/releases/generate-notes \ + -f tag_name="$release_tag" \ + --jq '.body' > "$notes_file" + gh release edit "$release_tag" --title "$release_tag" --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} else gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" --generate-notes ${prerelease_flag:+$prerelease_flag} fi From 266d37e5b584f47ebfcfb2c4b853f08d175c2402 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:00:39 +0200 Subject: [PATCH 6/6] fix(pr-labeler): paginate comment lookup to avoid duplicate nudge on busy PRs listComments returns only the first 30 by default. On a PR with 30+ comments the marker could be on a later page, causing a second nudge. Use github.paginate to fetch all comments before checking. --- .github/workflows/pr-labeler.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index bde2d2408eb..ac2cfc7bbaf 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -75,12 +75,12 @@ jobs: } const creator = context.payload.pull_request.user.login; - const existing = await github.rest.issues.listComments({ + const allComments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, issue_number: pr, }); - const alreadyCommented = existing.data.some( + const alreadyCommented = allComments.some( c => c.user.login === 'github-actions[bot]' && c.body.includes('') );