[READ-ONLY] Mirror of https://github.com/danielroe/uppt. A composite GitHub Action that turns conventional commits into a draft release PR, tags the PR on merge, and stages publishing to npm via OIDC
github-action npm npmjs oidc staged-publish trusted-publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add composite action for release PRs and OIDC npm publish

+922 -3
+1
.gitignore
··· 1 1 node_modules 2 + .pnpm-store
+111 -3
README.md
··· 1 1 # up-action 2 2 3 - This is a GitHub action to test your project against the latest (nightly) release of Nuxt. 3 + A composite GitHub Action that turns conventional commits into a draft release PR, tags the PR on merge, and stages publishing to npm via OIDC trusted publishing. 4 + 5 + ## Usage 6 + 7 + ```yaml 8 + name: release 9 + 10 + on: 11 + push: 12 + branches: [main] 13 + pull_request_target: 14 + types: [closed] 15 + branches: [main] 16 + # Required: `release` mode chains into `publish` via `gh workflow run`, which 17 + # fires `workflow_dispatch`. Also serves as the manual publish-recovery entry point. 18 + workflow_dispatch: 19 + 20 + permissions: {} 21 + 22 + jobs: 23 + # `pr` mode: parse commits since the last tag, push a `release/vX.Y.Z` 24 + # branch, open or update a draft release PR, and close any superseded 25 + # release PRs (e.g. `release/v1.0.1` when the bump is now `release/v1.1.0`). 26 + pr: 27 + if: github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) 28 + runs-on: ubuntu-latest 29 + permissions: 30 + contents: write # push the `release/vX.Y.Z` branch and delete superseded ones 31 + pull-requests: write # create a release PR, update its body, close superseded PRs 32 + steps: 33 + - uses: danielroe/up-action@v1 34 + with: 35 + token: ${{ secrets.GITHUB_TOKEN }} 4 36 5 - > [!WARNING] 6 - > This action is currently under development and may not behave as expected. 37 + # `release` mode: the release PR was merged. Tag the squash commit, cut a 38 + # GitHub release from the PR body, dispatch the publish workflow. The 39 + # `release/v` head-ref guard is what keeps regular feature-PR merges from 40 + # triggering a tag attempt. 41 + release: 42 + if: | 43 + github.event_name == 'pull_request_target' 44 + && github.event.pull_request.merged == true 45 + && startsWith(github.event.pull_request.head.ref, 'release/v') 46 + runs-on: ubuntu-latest 47 + permissions: 48 + contents: write # push the `vX.Y.Z` tag and create the GitHub release 49 + actions: write # `gh workflow run release.yml --ref vX.Y.Z` chained dispatch 50 + steps: 51 + - uses: danielroe/up-action@v1 52 + with: 53 + token: ${{ secrets.GITHUB_TOKEN }} 54 + 55 + # `publish` mode: the chained dispatch from `release` lands here as a 56 + # `workflow_dispatch` event on a `vX.Y.Z` tag ref. Manual recovery uses 57 + # the same path (Run workflow -> pick a `v*` tag). 58 + publish: 59 + if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v') 60 + runs-on: ubuntu-latest 61 + permissions: 62 + contents: read # checkout the tag 63 + id-token: write # OIDC claim for npm trusted publisher 64 + environment: npm # matches the trusted-publisher entry on npmjs.com 65 + steps: 66 + - uses: danielroe/up-action@v1 67 + with: 68 + mode: publish 69 + ``` 70 + 71 + ### Is `pull_request_target` safe here? 72 + 73 + `pull_request_target` is the well-known footgun: it runs in the target branch's context, with write permissions and access to secrets, and the classic exploit is checking out the PR head and running build or test scripts from attacker-controlled code. 74 + 75 + The `release` job avoids that pattern. Concretely: 76 + 77 + - It checks out `github.event.pull_request.merge_commit_sha` (the squash commit on the default branch, created after the maintainer approved and clicked merge). It never checks out `head.sha`. 78 + - It does not run `npm install`, `pnpm install`, `postinstall`, or any build / test scripts from the merged code. The only thing it executes is `node scripts/tag-and-release.ts` from `${{ github.action_path }}`, which is this action's pinned checkout, not the consumer repo's. 79 + - The single value it reads from the merged code is `package.json#version`, and it is validated against a strict semver regex before flowing into `git tag` / `gh` argv. Flag-injection (`--upload-pack=...`) and ref-confusion attacks are blocked at that gate. 80 + - All subprocess calls use `execFileSync` with argv arrays, never `execSync` or shell interpolation. `PR_BODY` is passed as an env var and forwarded to `gh release create --notes` as a single argv, so backtick / `$()` content in a PR body is inert. 81 + 82 + In short: the only attacker-controlled input that reaches a subprocess is the semver-validated package version, passed argv-not-shell. 83 + 84 + ## What it does 85 + 86 + - **`pr`** (push to default branch): parses conventional commits since the latest semver tag, decides the next bump (`major` / `minor` / `patch`), pushes a `release/vX.Y.Z` branch with the version bump as `github-actions[bot]`, and opens or updates a draft PR against the base branch. The PR body uses `## 👉 Changelog` as a marker; text above the marker is preserved across updates. If a subsequent commit shifts the target version (e.g. a `feat:` lands after a patch PR was opened), the stale PR is closed, its branch deleted, and its preamble carried into the new PR. Superseded-PR cleanup is scoped to the same base branch, so a repo with maintenance branches (e.g. `main`, `4.x`, `3.x`) can have a release PR open against each one without them clobbering each other. 87 + - **`release`** (`pull_request_target: closed` from a merged PR): reads the version from `package.json` at the merge commit, tags that commit, creates a GitHub Release using the PR body as notes, then dispatches the publish workflow on the new tag. 88 + - **`publish`** (`push: tags: ['v*']`, `workflow_dispatch` on a tag): if `pnpm-lock.yaml` is present, runs `pnpm pack` (so `catalog:` specifiers resolve) then `npm stage publish ./<tarball>.tgz --provenance --access <access>`. Otherwise runs `npm stage publish --provenance --access <access>` from source. Always `npm stage publish`, never `npm publish`. OIDC trusted publishing, no `NPM_TOKEN`. The maintainer approves the staged version with 2FA on npmjs.com afterwards. 89 + 90 + Mode is auto-detected from `github.event_name` by default; set `mode:` explicitly to override. 91 + 92 + ## Inputs 93 + 94 + | Input | Default | Description | 95 + | --- | --- | --- | 96 + | `mode` | `auto` | `auto`, `pr`, `release`, or `publish`. | 97 + | `token` | `${{ github.token }}` | Required for `release`; recommended for `pr`. Not used by `publish`. | 98 + | `base-branch` | default branch | Base branch for the release PR. | 99 + | `node-version` | `24` | Node version used for the scripts and for `publish`. Needs to support `--experimental-strip-types` (Node 22.6+, 24+ recommended). | 100 + | `npm-access` | `public` | npm access level (`public` or `restricted`). | 101 + | `publish-workflow` | `release.yml` | Workflow filename to dispatch after tagging. Must declare `workflow_dispatch`. | 102 + | `checkout` | `true` | Set to `false` if the caller has already checked out the right ref. | 103 + 104 + ## Prerequisites 105 + 106 + For `publish` to work end to end you need: 107 + 108 + - An npmjs.com trusted-publisher entry per package, pointing at the caller's `release.yml` and the `npm` environment, with the `npm stage publish` permission chip. 109 + - A GitHub environment named `npm` (or whichever name you put on the publish job). 110 + - The package must already exist on npmjs.com; `npm stage publish` cannot stage a brand-new package. 111 + 112 + ## License 113 + 114 + [MIT](./LICENSE)
+165
action.yml
··· 1 + name: 'up-action' 2 + description: 'Open release PRs from conventional commits, tag on merge, publish to npm via OIDC.' 3 + author: 'Daniel Roe' 4 + 5 + branding: 6 + icon: 'package' 7 + color: 'black' 8 + 9 + inputs: 10 + mode: 11 + description: | 12 + `auto` (default), `pr`, `release`, or `publish`. 13 + 14 + - `pr`: parse commits since the last tag, open or update a draft release PR. 15 + - `release`: tag the merged release PR, create a GitHub release, dispatch publish. 16 + - `publish`: pack + publish to npm using OIDC trusted publishing. 17 + - `auto`: pick one of the above from `github.event_name` and PR state. 18 + required: false 19 + default: auto 20 + token: 21 + description: | 22 + GitHub token. Required for `release` (tag push, release create, workflow dispatch). 23 + Recommended for `pr` (rate limit + private repo PR write). Not used by `publish`. 24 + required: false 25 + default: ${{ github.token }} 26 + base-branch: 27 + description: 'Branch the release PR is opened against.' 28 + required: false 29 + default: ${{ github.event.repository.default_branch }} 30 + node-version: 31 + description: 'Node version for the scripts and for `publish`.' 32 + required: false 33 + default: '24' 34 + npm-access: 35 + description: 'npm access level for publish (`public` or `restricted`).' 36 + required: false 37 + default: 'public' 38 + publish-workflow: 39 + description: 'Workflow filename to dispatch after tagging (must accept `workflow_dispatch`).' 40 + required: false 41 + default: 'release.yml' 42 + checkout: 43 + description: | 44 + Whether the action should run `actions/checkout` itself. Set to `false` if the 45 + caller has already checked out the right ref (with `fetch-depth: 0` for `pr`, 46 + and `ref: github.event.pull_request.merge_commit_sha` for `release`). 47 + required: false 48 + default: 'true' 49 + 50 + runs: 51 + using: composite 52 + steps: 53 + - name: Resolve mode 54 + id: mode 55 + shell: bash 56 + env: 57 + MODE_INPUT: ${{ inputs.mode }} 58 + EVENT_NAME: ${{ github.event_name }} 59 + PR_MERGED: ${{ github.event.pull_request.merged }} 60 + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} 61 + run: | 62 + set -euo pipefail 63 + mode="$MODE_INPUT" 64 + if [ "$mode" = "auto" ]; then 65 + case "$EVENT_NAME" in 66 + push) 67 + # `push: tags: ['v*']` for publish; default-branch push for pr. 68 + if [ "${GITHUB_REF:-}" != "${GITHUB_REF#refs/tags/v}" ]; then 69 + mode=publish 70 + else 71 + mode=pr 72 + fi 73 + ;; 74 + workflow_dispatch) 75 + # workflow_dispatch has two reasons to fire: the chained dispatch 76 + # from `release` mode (always on a `vX.Y.Z` tag ref, lands as 77 + # `publish`), and manual publish recovery (also on a tag ref). 78 + # Dispatch on a non-tag ref is unsupported here: `pr` mode is 79 + # intentionally not exposed via manual dispatch, because routing 80 + # by ref-shape is too implicit. Use a `push` to the default 81 + # branch (or re-run the last `pr` job) to refresh the release PR. 82 + if [ "${GITHUB_REF:-}" != "${GITHUB_REF#refs/tags/v}" ]; then 83 + mode=publish 84 + else 85 + mode=skip 86 + fi 87 + ;; 88 + pull_request_target) 89 + if [ "$PR_MERGED" = "true" ] && [ "${PR_HEAD_REF#release/v}" != "$PR_HEAD_REF" ]; then 90 + mode=release 91 + else 92 + mode=skip 93 + fi 94 + ;; 95 + *) 96 + mode=skip 97 + ;; 98 + esac 99 + fi 100 + echo "mode=$mode" >> "$GITHUB_OUTPUT" 101 + echo "Resolved mode: $mode (event: $EVENT_NAME)" 102 + 103 + - name: Checkout (pr) 104 + if: steps.mode.outputs.mode == 'pr' && inputs.checkout == 'true' 105 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 106 + with: 107 + fetch-depth: 0 108 + token: ${{ inputs.token }} 109 + 110 + - name: Checkout (release) 111 + if: steps.mode.outputs.mode == 'release' && inputs.checkout == 'true' 112 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 113 + with: 114 + fetch-depth: 0 115 + ref: ${{ github.event.pull_request.merge_commit_sha }} 116 + token: ${{ inputs.token }} 117 + 118 + - name: Checkout (publish) 119 + if: steps.mode.outputs.mode == 'publish' && inputs.checkout == 'true' 120 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 121 + 122 + - name: Enable corepack 123 + if: steps.mode.outputs.mode == 'publish' 124 + shell: bash 125 + run: corepack enable 126 + 127 + - name: Setup Node 128 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 129 + with: 130 + node-version: ${{ inputs.node-version }} 131 + registry-url: ${{ steps.mode.outputs.mode == 'publish' && 'https://registry.npmjs.org' || '' }} 132 + 133 + - name: Update npm 134 + if: steps.mode.outputs.mode == 'publish' 135 + shell: bash 136 + run: npm install -g npm@latest 137 + 138 + - name: Update changelog and open/update release PR 139 + if: steps.mode.outputs.mode == 'pr' 140 + shell: bash 141 + env: 142 + GITHUB_TOKEN: ${{ inputs.token }} 143 + RELEASE_BASE: ${{ inputs.base-branch }} 144 + run: node --experimental-strip-types ${{ github.action_path }}/scripts/update-changelog.ts 145 + 146 + - name: Tag merged release PR and dispatch publish 147 + if: steps.mode.outputs.mode == 'release' 148 + shell: bash 149 + env: 150 + GITHUB_TOKEN: ${{ inputs.token }} 151 + PR_BODY: ${{ github.event.pull_request.body }} 152 + PUBLISH_WORKFLOW: ${{ inputs.publish-workflow }} 153 + run: node --experimental-strip-types ${{ github.action_path }}/scripts/tag-and-release.ts 154 + 155 + - name: Publish to npm 156 + if: steps.mode.outputs.mode == 'publish' 157 + shell: bash 158 + env: 159 + NPM_ACCESS: ${{ inputs.npm-access }} 160 + run: node --experimental-strip-types ${{ github.action_path }}/scripts/publish.ts 161 + 162 + - name: Skip 163 + if: steps.mode.outputs.mode == 'skip' 164 + shell: bash 165 + run: echo "No-op for this event."
+16
package.json
··· 1 + { 2 + "name": "up-action", 3 + "version": "0.0.1", 4 + "private": true, 5 + "description": "Composite GitHub Action to release PRs from conventional commits, tag on merge, publish to npm via OIDC.", 6 + "author": "Daniel Roe <daniel@roe.dev>", 7 + "license": "MIT", 8 + "scripts": { 9 + "typecheck": "tsc --noEmit" 10 + }, 11 + "devDependencies": { 12 + "@types/node": "^24.0.0", 13 + "typescript": "^5.9.2" 14 + }, 15 + "packageManager": "pnpm@10.15.0" 16 + }
+39
pnpm-lock.yaml
··· 1 + lockfileVersion: '9.0' 2 + 3 + settings: 4 + autoInstallPeers: true 5 + excludeLinksFromLockfile: false 6 + 7 + importers: 8 + 9 + .: 10 + devDependencies: 11 + '@types/node': 12 + specifier: ^24.0.0 13 + version: 24.12.4 14 + typescript: 15 + specifier: ^5.9.2 16 + version: 5.9.3 17 + 18 + packages: 19 + 20 + '@types/node@24.12.4': 21 + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} 22 + 23 + typescript@5.9.3: 24 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 25 + engines: {node: '>=14.17'} 26 + hasBin: true 27 + 28 + undici-types@7.16.0: 29 + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} 30 + 31 + snapshots: 32 + 33 + '@types/node@24.12.4': 34 + dependencies: 35 + undici-types: 7.16.0 36 + 37 + typescript@5.9.3: {} 38 + 39 + undici-types@7.16.0: {}
+64
scripts/publish.ts
··· 1 + // Stage-publish to npm using OIDC trusted publishing. The maintainer 2 + // approves the staged version with 2FA on npmjs.com afterwards. 3 + // 4 + // Two paths, differing only in what gets staged: 5 + // - pnpm-lock.yaml present: `pnpm pack` first so `catalog:` specifiers 6 + // resolve in the tarball, then `npm stage publish ./<tarball>.tgz`. 7 + // - otherwise: `npm stage publish` from source (no tarball arg). 8 + // 9 + // Env: 10 + // NPM_ACCESS `public` (default) or `restricted` 11 + 12 + import process from 'node:process' 13 + import { execFileSync } from 'node:child_process' 14 + import { existsSync, readFileSync, readdirSync } from 'node:fs' 15 + import { resolve } from 'node:path' 16 + 17 + function run (cmd: string, args: string[]) { 18 + console.log('$', cmd, ...args) 19 + execFileSync(cmd, args, { stdio: 'inherit' }) 20 + } 21 + 22 + function tarballGlobPrefix (pkgName: string): string { 23 + // npm pack names tarballs `<name>-<version>.tgz` for unscoped packages 24 + // and `<scope>-<name>-<version>.tgz` for scoped ones (the leading `@` 25 + // is stripped and the `/` becomes `-`). 26 + return pkgName.replace(/^@/, '').replace(/\//g, '-') 27 + } 28 + 29 + function findTarballs (prefix: string): string[] { 30 + return readdirSync(process.cwd()) 31 + .filter(f => f.startsWith(`${prefix}-`) && f.endsWith('.tgz')) 32 + .sort() 33 + } 34 + 35 + function main () { 36 + const access = process.env.NPM_ACCESS === 'restricted' ? 'restricted' : 'public' 37 + const pkgPath = resolve(process.cwd(), 'package.json') 38 + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name: string } 39 + const hasPnpmLock = existsSync(resolve(process.cwd(), 'pnpm-lock.yaml')) 40 + 41 + if (!hasPnpmLock) { 42 + run('npm', ['stage', 'publish', '--provenance', `--access=${access}`]) 43 + return 44 + } 45 + 46 + run('pnpm', ['pack', '--pack-destination', '.']) 47 + 48 + const prefix = tarballGlobPrefix(pkg.name) 49 + const tarballs = findTarballs(prefix) 50 + if (!tarballs.length) { 51 + throw new Error(`No tarball matching ${prefix}-*.tgz found in ${process.cwd()}`) 52 + } 53 + for (const tarball of tarballs) { 54 + run('npm', ['stage', 'publish', `./${tarball}`, '--provenance', `--access=${access}`]) 55 + } 56 + } 57 + 58 + try { 59 + main() 60 + } 61 + catch (err) { 62 + console.error(err) 63 + process.exit(1) 64 + }
+61
scripts/tag-and-release.ts
··· 1 + // Tag the squash-merge commit, create a GitHub release from the PR body, 2 + // and dispatch the publish workflow. 3 + // 4 + // Runs on `pull_request_target: closed` after the caller's workflow has 5 + // checked out the merge commit (`ref: github.event.pull_request.merge_commit_sha`). 6 + // The version is read from `package.json` at that ref, not from the branch name. 7 + // 8 + // Env: 9 + // GITHUB_TOKEN required (tag push, release create, workflow dispatch) 10 + // GITHUB_REPOSITORY "owner/repo" (set automatically inside Actions) 11 + // PR_BODY PR body, used verbatim as release notes 12 + // PUBLISH_WORKFLOW workflow filename to dispatch (default: release.yml) 13 + 14 + import process from 'node:process' 15 + import { execFileSync } from 'node:child_process' 16 + import { readFileSync } from 'node:fs' 17 + import { resolve } from 'node:path' 18 + 19 + function run (cmd: string, args: string[], opts: { env?: NodeJS.ProcessEnv } = {}) { 20 + execFileSync(cmd, args, { stdio: 'inherit', env: { ...process.env, ...opts.env } }) 21 + } 22 + 23 + function main () { 24 + const token = process.env.GITHUB_TOKEN 25 + if (!token) throw new Error('GITHUB_TOKEN is required') 26 + 27 + const pkgPath = resolve(process.cwd(), 'package.json') 28 + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string } 29 + // Runs under `pull_request_target`, so `pkg.version` is attacker-influenced. 30 + // Pin to strict semver before letting it flow into `git tag` / `gh` argv to 31 + // rule out flag-injection (`--upload-pack=...`) and ref-confusion attacks. 32 + if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(pkg.version)) { 33 + throw new Error(`Refusing to tag: package.json version "${pkg.version}" is not strict semver`) 34 + } 35 + const tag = `v${pkg.version}` 36 + 37 + run('git', ['config', 'user.email', 'github-actions[bot]@users.noreply.github.com']) 38 + run('git', ['config', 'user.name', 'github-actions[bot]']) 39 + run('git', ['tag', tag]) 40 + run('git', ['push', 'origin', tag]) 41 + 42 + const body = process.env.PR_BODY ?? '' 43 + run('gh', ['release', 'create', tag, '--title', tag, '--notes', body], { 44 + env: { GH_TOKEN: token }, 45 + }) 46 + 47 + const workflow = process.env.PUBLISH_WORKFLOW || 'release.yml' 48 + run('gh', ['workflow', 'run', workflow, '--ref', tag], { 49 + env: { GH_TOKEN: token }, 50 + }) 51 + 52 + console.log(`Tagged ${tag}, created release, dispatched ${workflow}.`) 53 + } 54 + 55 + try { 56 + main() 57 + } 58 + catch (err) { 59 + console.error(err) 60 + process.exit(1) 61 + }
+446
scripts/update-changelog.ts
··· 1 + // Zero-dependency release-PR updater. 2 + // 3 + // Reads the conventional commits since the latest tag, decides the next 4 + // semver bump, and creates or updates a draft "release PR" against the 5 + // default branch. The PR body is a generated changelog plus a contributor 6 + // list pulled from the GitHub API. 7 + // 8 + // Env: 9 + // GITHUB_TOKEN required for PR create/update; optional for read-only 10 + // contributor + PR lookups (public endpoints work 11 + // unauthenticated against public repos, just with a 12 + // 60 req/hr ceiling). 13 + // GITHUB_REPOSITORY "owner/repo" (set automatically inside Actions) 14 + // DRY_RUN if set, skip git push and GitHub writes 15 + // RELEASE_BASE override base branch (default: current branch) 16 + 17 + import process from 'node:process' 18 + import { execFileSync } from 'node:child_process' 19 + import { readFileSync, writeFileSync } from 'node:fs' 20 + import { resolve } from 'node:path' 21 + 22 + interface Commit { 23 + shortHash: string 24 + message: string 25 + type: string 26 + scope: string 27 + description: string 28 + isBreaking: boolean 29 + author: { name: string, email: string } 30 + references: string[] 31 + } 32 + 33 + interface Contributor { 34 + name: string 35 + username: string 36 + isFirstTime: boolean 37 + } 38 + 39 + const TYPE_TITLES: Record<string, string> = { 40 + feat: '🚀 Enhancements', 41 + perf: '🔥 Performance', 42 + fix: '🩹 Fixes', 43 + refactor: '💅 Refactors', 44 + docs: '📖 Documentation', 45 + build: '📦 Build', 46 + types: '🌊 Types', 47 + chore: '🏡 Chore', 48 + examples: '🏀 Examples', 49 + test: '✅ Tests', 50 + style: '🎨 Styles', 51 + ci: '🤖 CI', 52 + } 53 + 54 + const KNOWN_TYPES = new Set(Object.keys(TYPE_TITLES)) 55 + 56 + const git = (...args: string[]) => 57 + execFileSync('git', args, { encoding: 'utf8' }).trim() 58 + 59 + function getRepo (): { owner: string, repo: string } { 60 + const env = process.env.GITHUB_REPOSITORY 61 + if (env && env.includes('/')) { 62 + const [owner, repo] = env.split('/') 63 + return { owner: owner!, repo: repo! } 64 + } 65 + const url = git('remote', 'get-url', 'origin') 66 + const match = url.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?$/) 67 + if (!match) throw new Error(`Cannot parse repo from remote url: ${url}`) 68 + return { owner: match[1]!, repo: match[2]! } 69 + } 70 + 71 + function getCurrentBranch (): string { 72 + return process.env.RELEASE_BASE || git('rev-parse', '--abbrev-ref', 'HEAD') 73 + } 74 + 75 + interface Tag { name: string, ref: string } 76 + 77 + function getLatestTag (): Tag | null { 78 + // Pick the most recent semver-shaped tag by creation date. We deliberately 79 + // don't use `git describe`, which only finds tags reachable from HEAD; the 80 + // previous release tag isn't always an ancestor of HEAD (e.g. release 81 + // branches that were never merged back). 82 + // 83 + // We return both the short name (for display / URLs) and the fully 84 + // qualified ref (`refs/tags/...`) so subsequent git calls aren't confused 85 + // by branches sharing the tag name. 86 + try { 87 + const stdout = execFileSync( 88 + 'git', 89 + ['for-each-ref', '--sort=-creatordate', '--format=%(refname:strip=2)', 'refs/tags'], 90 + { encoding: 'utf8' }, 91 + ) 92 + const name = stdout.split('\n').map(s => s.trim()).find(t => /^v?\d+\.\d+\.\d+/.test(t)) 93 + return name ? { name, ref: `refs/tags/${name}` } : null 94 + } catch { 95 + return null 96 + } 97 + } 98 + 99 + function parseCommit (raw: string): Commit | null { 100 + const [shortHash, authorName, authorEmail, subject, body] = raw.split('\x1f') 101 + if (!shortHash || !subject) return null 102 + 103 + const header = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/) 104 + if (!header) { 105 + return { 106 + shortHash, 107 + message: subject, 108 + type: '', 109 + scope: '', 110 + description: subject, 111 + isBreaking: false, 112 + author: { name: authorName || '', email: authorEmail || '' }, 113 + references: [], 114 + } 115 + } 116 + const [, type, scope = '', bang, rawDescription] = header 117 + const isBreaking = Boolean(bang) || /BREAKING[ -]CHANGE/.test(body || '') 118 + 119 + const references: string[] = [] 120 + for (const m of (body || '').matchAll(/(?:closes?|fixes?|resolves?)\s+#(\d+)/gi)) { 121 + references.push(`#${m[1]}`) 122 + } 123 + for (const m of subject.matchAll(/\(#(\d+)\)/g)) { 124 + references.push(`#${m[1]}`) 125 + } 126 + // Drop trailing `(#nnn)` PR refs from the description: we'll re-attach them 127 + // in the rendered changelog from the deduped `references` list. 128 + const description = rawDescription!.replace(/\s*\(#\d+\)\s*$/, '').trim() 129 + 130 + return { 131 + shortHash, 132 + message: subject, 133 + type: type!.toLowerCase(), 134 + scope, 135 + description, 136 + isBreaking, 137 + author: { name: authorName || '', email: authorEmail || '' }, 138 + references: [...new Set(references)], 139 + } 140 + } 141 + 142 + function getCommitsSince (tag: Tag | null): Commit[] { 143 + const range = tag ? `${tag.ref}..HEAD` : 'HEAD' 144 + const stdout = execFileSync( 145 + 'git', 146 + ['log', range, `--pretty=format:%h%x1f%an%x1f%ae%x1f%s%x1f%b%x1e`], 147 + { encoding: 'utf8' }, 148 + ) 149 + return stdout 150 + .split('\x1e') 151 + .map(s => s.replace(/^\n/, '')) 152 + .filter(Boolean) 153 + .map(parseCommit) 154 + .filter((c): c is Commit => c !== null) 155 + } 156 + 157 + function determineBump (commits: Commit[]): 'major' | 'minor' | 'patch' { 158 + if (commits.some(c => c.isBreaking)) return 'major' 159 + if (commits.some(c => c.type === 'feat')) return 'minor' 160 + return 'patch' 161 + } 162 + 163 + function incVersion (version: string, bump: 'major' | 'minor' | 'patch'): string { 164 + const match = version.match(/^(\d+)\.(\d+)\.(\d+)/) 165 + if (!match) throw new Error(`Cannot parse version: ${version}`) 166 + let [, major, minor, patch] = match.map(Number) as [number, number, number, number, number] 167 + if (bump === 'major') { major += 1; minor = 0; patch = 0 } 168 + else if (bump === 'minor') { minor += 1; patch = 0 } 169 + else { patch += 1 } 170 + return `${major}.${minor}.${patch}` 171 + } 172 + 173 + function formatChangelog ( 174 + commits: Commit[], 175 + opts: { owner: string, repo: string, fromRef: Tag | null, toRef: string }, 176 + ): string { 177 + const grouped = new Map<string, Commit[]>() 178 + for (const c of commits) { 179 + if (!KNOWN_TYPES.has(c.type)) continue 180 + if (c.type === 'chore' && c.scope === 'deps') continue 181 + const list = grouped.get(c.type) || [] 182 + list.push(c) 183 + grouped.set(c.type, list) 184 + } 185 + 186 + const lines: string[] = [] 187 + if (opts.fromRef) { 188 + const compareUrl = `https://github.com/${opts.owner}/${opts.repo}/compare/${opts.fromRef.name}...${opts.toRef}` 189 + lines.push(`[compare changes](${compareUrl})`, '') 190 + } 191 + 192 + const commitUrl = (sha: string) => 193 + `https://github.com/${opts.owner}/${opts.repo}/commit/${sha}` 194 + 195 + for (const type of Object.keys(TYPE_TITLES)) { 196 + const items = grouped.get(type) 197 + if (!items?.length) continue 198 + lines.push(`### ${TYPE_TITLES[type]}`, '') 199 + for (const c of items) { 200 + const scope = c.scope ? `**${c.scope}:** ` : '' 201 + const breaking = c.isBreaking ? '⚠️ ' : '' 202 + // Prefer PR references; fall back to a link to the commit itself so 203 + // every line is traceable to something on GitHub. 204 + const trailer = c.references.length 205 + ? ` (${c.references.join(', ')})` 206 + : ` ([\`${c.shortHash}\`](${commitUrl(c.shortHash)}))` 207 + lines.push(`- ${breaking}${scope}${c.description}${trailer}`) 208 + } 209 + lines.push('') 210 + } 211 + return lines.join('\n').trim() 212 + } 213 + 214 + async function gh<T> (path: string, init: RequestInit & { requireAuth?: boolean } = {}): Promise<T> { 215 + const { requireAuth, ...rest } = init 216 + const token = process.env.GITHUB_TOKEN 217 + if (requireAuth && !token) throw new Error('GITHUB_TOKEN is required for this call') 218 + const res = await fetch(`https://api.github.com${path}`, { 219 + ...rest, 220 + headers: { 221 + 'Accept': 'application/vnd.github+json', 222 + 'X-GitHub-Api-Version': '2022-11-28', 223 + ...(token ? { Authorization: `Bearer ${token}` } : {}), 224 + 'User-Agent': 'release-pr-updater', 225 + ...(rest.body ? { 'Content-Type': 'application/json' } : {}), 226 + ...(rest.headers as Record<string, string> | undefined), 227 + }, 228 + }) 229 + if (!res.ok) { 230 + throw new Error(`GitHub ${init.method || 'GET'} ${path} -> ${res.status} ${res.statusText}: ${await res.text()}`) 231 + } 232 + return res.json() as Promise<T> 233 + } 234 + 235 + async function getContributors ( 236 + commits: Commit[], 237 + repo: { owner: string, repo: string }, 238 + cutoff: string | null, 239 + ): Promise<Contributor[]> { 240 + const out: Contributor[] = [] 241 + const seenEmails = new Set<string>() 242 + const seenUsers = new Set<string>() 243 + 244 + for (const commit of commits) { 245 + if (commit.author.name === 'renovate[bot]') continue 246 + if (seenEmails.has(commit.author.email)) continue 247 + seenEmails.add(commit.author.email) 248 + 249 + let login: string | undefined 250 + try { 251 + const data = await gh<{ author: { login: string } | null }>( 252 + `/repos/${repo.owner}/${repo.repo}/commits/${commit.shortHash}`, 253 + ) 254 + login = data.author?.login 255 + } catch { 256 + continue 257 + } 258 + if (!login || seenUsers.has(login)) continue 259 + seenUsers.add(login) 260 + 261 + // First-time contributor = no commits authored by them before the cutoff 262 + // (the previous release tag's commit date). If we have no previous tag 263 + // every contributor is, by definition, first-time. 264 + let isFirstTime = true 265 + if (cutoff) { 266 + try { 267 + const prior = await gh<unknown[]>( 268 + `/repos/${repo.owner}/${repo.repo}/commits?author=${encodeURIComponent(login)}&until=${encodeURIComponent(cutoff)}&per_page=1`, 269 + ) 270 + isFirstTime = prior.length === 0 271 + } catch { 272 + isFirstTime = false 273 + } 274 + } 275 + 276 + out.push({ name: commit.author.name, username: login, isFirstTime }) 277 + } 278 + return out 279 + } 280 + 281 + async function main () { 282 + const dryRun = Boolean(process.env.DRY_RUN) 283 + const repo = getRepo() 284 + const baseBranch = getCurrentBranch() 285 + const latestTag = getLatestTag() 286 + 287 + const commits = getCommitsSince(latestTag).filter( 288 + c => KNOWN_TYPES.has(c.type) && !(c.type === 'chore' && c.scope === 'deps'), 289 + ) 290 + 291 + if (!commits.length) { 292 + console.log('No release-worthy commits since', latestTag?.name ?? 'repo root') 293 + return 294 + } 295 + 296 + const pkgPath = resolve(process.cwd(), 'package.json') 297 + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) 298 + const bump = determineBump(commits) 299 + const newVersion = incVersion(pkg.version, bump) 300 + const releaseBranch = `release/v${newVersion}` 301 + 302 + const changelog = formatChangelog(commits, { 303 + owner: repo.owner, 304 + repo: repo.repo, 305 + fromRef: latestTag, 306 + toRef: releaseBranch, 307 + }) 308 + 309 + console.log(`Current: ${pkg.version} -> ${newVersion} (${bump})`) 310 + console.log(`Base branch: ${baseBranch}`) 311 + console.log(`Release branch: ${releaseBranch}`) 312 + console.log(`Commits: ${commits.length}`) 313 + 314 + // Close any open release PRs that don't match the new target version, 315 + // scoped to PRs targeting the *same* base branch. Repos with maintenance 316 + // branches (e.g. nuxt's `main`, `4.x`, `3.x`) have a release PR per base; 317 + // a `feat:` landing on `main` must not close the `4.x`-base PR. The common 318 + // single-branch case: a patch PR (`release/v1.0.1`) gets superseded by a 319 + // `feat:` that bumps the target to `release/v1.1.0`. We close the stale PR, 320 + // lift its preamble (so the maintainer's intro text isn't lost), and 321 + // delete its branch. 322 + let seedPreamble: string | null = null 323 + if (!dryRun && process.env.GITHUB_TOKEN) { 324 + const openReleasePRs = await gh<Array<{ number: number, body: string | null, head: { ref: string }, base: { ref: string }, updated_at: string }>>( 325 + `/repos/${repo.owner}/${repo.repo}/pulls?state=open&per_page=100&base=${encodeURIComponent(baseBranch)}`, 326 + { requireAuth: true }, 327 + ) 328 + const stale = openReleasePRs 329 + .filter(pr => 330 + pr.head.ref.startsWith('release/v') 331 + && pr.head.ref !== releaseBranch 332 + && pr.base.ref === baseBranch, 333 + ) 334 + .sort((a, b) => b.updated_at.localeCompare(a.updated_at)) 335 + for (const pr of stale) { 336 + console.log(`Closing superseded release PR #${pr.number} (${pr.head.ref})`) 337 + const preamble = pr.body?.replace(/## 👉 Changelog[\s\S]*$/, '').trim() 338 + if (preamble && !seedPreamble) seedPreamble = preamble 339 + await gh(`/repos/${repo.owner}/${repo.repo}/pulls/${pr.number}`, { 340 + method: 'PATCH', 341 + body: JSON.stringify({ state: 'closed' }), 342 + requireAuth: true, 343 + }) 344 + try { 345 + await gh(`/repos/${repo.owner}/${repo.repo}/git/refs/heads/${pr.head.ref}`, { 346 + method: 'DELETE', 347 + requireAuth: true, 348 + }) 349 + } 350 + catch (err) { 351 + console.warn(` could not delete branch ${pr.head.ref}:`, err) 352 + } 353 + } 354 + } 355 + 356 + const remoteBranchExists = git('ls-remote', '--heads', 'origin', releaseBranch).length > 0 357 + if (!remoteBranchExists && !dryRun) { 358 + git('checkout', '-B', releaseBranch) 359 + pkg.version = newVersion 360 + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n') 361 + git('add', 'package.json') 362 + git('-c', 'user.name=github-actions[bot]', '-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com', 363 + 'commit', '-m', `v${newVersion}`) 364 + git('push', '-u', 'origin', releaseBranch) 365 + git('checkout', baseBranch) 366 + } 367 + 368 + const hasToken = Boolean(process.env.GITHUB_TOKEN) 369 + if (!hasToken && !dryRun) throw new Error('GITHUB_TOKEN is required to create or update the PR') 370 + 371 + const cutoff = latestTag 372 + ? git('log', '-1', '--format=%aI', latestTag.ref) 373 + : null 374 + 375 + // Contributor + existing-PR lookups hit public endpoints, so they work 376 + // unauthenticated against public repos. We still benefit from a token 377 + // (5000 req/h vs 60), but don't require one for previews. 378 + const contributors = await getContributors(commits, repo, cutoff) 379 + const newContributors = contributors.filter(c => c.isFirstTime) 380 + 381 + const existing = await gh<Array<{ number: number, body: string | null }>>( 382 + `/repos/${repo.owner}/${repo.repo}/pulls?head=${repo.owner}:${releaseBranch}&state=open`, 383 + ) 384 + const currentPR = existing[0] 385 + const preamble = currentPR?.body?.replace(/## 👉 Changelog[\s\S]*$/, '').trim() 386 + || seedPreamble 387 + || `> v${newVersion} is the next ${bump} release.\n>\n> **Timetable**: to be announced.` 388 + 389 + const body = [ 390 + preamble, 391 + '', 392 + '## 👉 Changelog', 393 + '', 394 + changelog, 395 + ...(newContributors.length 396 + ? [ 397 + '', 398 + '### 🎉 New Contributors', 399 + '', 400 + newContributors.map(c => `- ${c.name} (@${c.username})`).join('\n'), 401 + ] 402 + : []), 403 + '', 404 + '### ❤️ Contributors', 405 + '', 406 + contributors.length 407 + ? contributors.map(c => `- ${c.name} (@${c.username})`).join('\n') 408 + : '_no contributors yet_', 409 + ].join('\n') 410 + 411 + if (dryRun) { 412 + console.log('\n--- DRY RUN: PR body ---\n') 413 + console.log(body) 414 + return 415 + } 416 + 417 + if (currentPR) { 418 + await gh(`/repos/${repo.owner}/${repo.repo}/pulls/${currentPR.number}`, { 419 + method: 'PATCH', 420 + body: JSON.stringify({ body }), 421 + requireAuth: true, 422 + }) 423 + console.log(`Updated PR #${currentPR.number}`) 424 + } else { 425 + const created = await gh<{ number: number, html_url: string }>( 426 + `/repos/${repo.owner}/${repo.repo}/pulls`, 427 + { 428 + method: 'POST', 429 + requireAuth: true, 430 + body: JSON.stringify({ 431 + title: `v${newVersion}`, 432 + head: releaseBranch, 433 + base: baseBranch, 434 + body, 435 + draft: true, 436 + }), 437 + }, 438 + ) 439 + console.log(`Created PR #${created.number}: ${created.html_url}`) 440 + } 441 + } 442 + 443 + main().catch((err) => { 444 + console.error(err) 445 + process.exit(1) 446 + })
+19
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2023", 4 + "module": "NodeNext", 5 + "moduleResolution": "NodeNext", 6 + "lib": ["ES2023"], 7 + "types": ["node"], 8 + "strict": true, 9 + "noUncheckedIndexedAccess": true, 10 + "noImplicitOverride": true, 11 + "skipLibCheck": true, 12 + "allowImportingTsExtensions": true, 13 + "noEmit": true, 14 + "isolatedModules": true, 15 + "esModuleInterop": true, 16 + "resolveJsonModule": true 17 + }, 18 + "include": ["scripts/**/*.ts"] 19 + }