From 800be045babef4dd5de97f051168aada4dff9788 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 2 Oct 2026 11:48:51 +0000 Subject: [PATCH] feat(pr): run on pushes to release branches --- README.md | 12 +++-- pr/action.yml | 37 ++++++++----- scripts/resolve-pr-target.ts | 59 +++++++++++++++++++++ test/resolve-pr-target.test.ts | 95 ++++++++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 scripts/resolve-pr-target.ts create mode 100644 test/resolve-pr-target.test.ts diff --git a/README.md b/README.md index 3fd86e3..1880425 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ name: release on: push: - branches: [main] + # release/v* picks up hand edits to an open release PR + branches: [main, 'release/v*'] pull_request: types: [closed] branches: [main] @@ -65,7 +66,10 @@ jobs: && ( ( github.event_name == 'push' - && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + && ( + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + || startsWith(github.ref, 'refs/heads/release/v') + ) ) || ( github.event_name == 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/') @@ -146,13 +150,13 @@ jobs: > [!TIP] > You can edit the release PR to add your own release notes. Anything above `## 👉 Changelog` is preserved when the changelog is updated. > -> To release a different version than the one uppt picked, just change `version` in a `package.json` on the release branch. The next run will update the PR title and changelog to match (and, in a lockstep monorepo, the other manifests), and keep that version on later pushes. The branch keeps its original name. +> To release a different version than the one uppt picked, just change `version` in a `package.json` on the release branch. On the next run (straight away if your workflow has the `release/v*` push trigger above, otherwise on the next push to the base branch), uppt will update the PR title and changelog to match (and, in a lockstep monorepo, the other manifests), and keep that version on later pushes. The branch keeps its original name. > > If base changes a manifest the release PR also bumps, the next run rebases the PR onto it, keeping its version. A release branch carrying other changes is left for you to update. ### Inputs -All subactions take a `node-version` input (default `24`; uppt needs `--experimental-strip-types`, so Node 22.6+ also works) and, where applicable, a `checkout` input (`true` by default; set to `false` if the caller has already checked out the right ref - `fetch-depth: 0` for `pr`, the merge commit for `release`, the tag for `pack`). +All subactions take a `node-version` input (default `24`; uppt needs `--experimental-strip-types`, so Node 22.6+ also works) and, where applicable, a `checkout` input (`true` by default; set to `false` if the caller has already checked out the right ref - `fetch-depth: 0` for `pr`, on the release PR's base for a `release/v*` push, the merge commit for `release`, the tag for `pack`).
uppt/pr diff --git a/pr/action.yml b/pr/action.yml index affb530..9746219 100644 --- a/pr/action.yml +++ b/pr/action.yml @@ -12,7 +12,7 @@ inputs: required: false default: ${{ github.token }} base-branch: - description: 'Branch the release PR is opened against.' + description: 'Branch the release PR is opened against. On a push to a `release/v*` branch, the base of that branch''s open release PR is used instead.' required: false default: ${{ github.event.repository.default_branch }} node-version: @@ -20,7 +20,7 @@ inputs: required: false default: '24' checkout: - description: 'Whether the action should run `actions/checkout` itself. Set to `false` if the caller has already checked out with `fetch-depth: 0`.' + description: 'Whether the action should run `actions/checkout` itself. Set to `false` if the caller has already checked out with `fetch-depth: 0` (on the release PR''s base branch, for a push to `release/v*`).' required: false default: 'true' packages: @@ -66,14 +66,6 @@ runs: exit 1 fi - - name: Checkout - if: steps.guard.outputs.skip != 'true' && inputs.checkout == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - token: ${{ inputs.token }} - persist-credentials: false - - name: Setup Node if: steps.guard.outputs.skip != 'true' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -81,14 +73,33 @@ runs: node-version: ${{ inputs.node-version }} package-manager-cache: false - - name: Update changelog and open/update release PR + - name: Resolve release PR target + id: target if: steps.guard.outputs.skip != 'true' shell: bash env: GITHUB_TOKEN: ${{ inputs.token }} - RELEASE_BASE: ${{ inputs.base-branch }} - PACKAGES: ${{ inputs.packages }} + BASE_BRANCH: ${{ inputs.base-branch }} PRERELEASE: ${{ inputs.prerelease }} + run: node --experimental-strip-types ${{ github.action_path }}/../scripts/resolve-pr-target.ts + + - name: Checkout + if: steps.guard.outputs.skip != 'true' && steps.target.outputs.skip != 'true' && inputs.checkout == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + ref: ${{ startsWith(github.ref, 'refs/heads/release/v') && steps.target.outputs.base || '' }} + token: ${{ inputs.token }} + persist-credentials: false + + - name: Update changelog and open/update release PR + if: steps.guard.outputs.skip != 'true' && steps.target.outputs.skip != 'true' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.token }} + RELEASE_BASE: ${{ steps.target.outputs.base }} + PACKAGES: ${{ inputs.packages }} + PRERELEASE: ${{ steps.target.outputs.prerelease }} MODE: ${{ inputs.mode }} SCOPES: ${{ inputs.scopes }} run: node --experimental-strip-types ${{ github.action_path }}/../scripts/update-changelog.ts diff --git a/scripts/resolve-pr-target.ts b/scripts/resolve-pr-target.ts new file mode 100644 index 0000000..e776c84 --- /dev/null +++ b/scripts/resolve-pr-target.ts @@ -0,0 +1,59 @@ +// Base branch and prerelease identifier for a `uppt/pr` run: the action +// inputs, or for a push to `release/v*`, those of that branch's open PR. +// +// Env: +// GITHUB_REF ref that triggered the run +// GITHUB_REPOSITORY "owner/repo" +// GITHUB_TOKEN token for the PR lookup +// BASE_BRANCH `base-branch` input +// PRERELEASE `prerelease` input +// GITHUB_OUTPUT receives `skip`, `base` and `prerelease` + +import process from 'node:process' +import { appendFileSync } from 'node:fs' +import { runMain } from './_cli.ts' + +/** Identifier that continues the prerelease line of `version`, or `''` for a stable version. */ +export function prereleaseIdentifier (version: string): string { + const pre = version.match(/^\d+\.\d+\.\d+-([0-9a-zA-Z.-]+)$/)?.[1] + if (!pre) return '' + if (/^\d+$/.test(pre)) return '0' + return pre.replace(/\.\d+$/, '') +} + +export async function main () { + const ref = process.env.GITHUB_REF ?? '' + const output = (values: Record) => { + const lines = Object.entries(values).map(([key, value]) => `${key}=${value}\n`).join('') + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, lines) + else process.stdout.write(lines) + } + + const branch = ref.match(/^refs\/heads\/(release\/v.+)$/)?.[1] + if (!branch) { + output({ skip: 'false', base: process.env.BASE_BRANCH ?? '', prerelease: process.env.PRERELEASE ?? '' }) + return + } + + const repo = process.env.GITHUB_REPOSITORY! + const owner = repo.split('/')[0] + const token = process.env.GITHUB_TOKEN + const res = await fetch(`https://api.github.com/repos/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${branch}`)}`, { + headers: { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'release-pr-updater', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }) + if (!res.ok) throw new Error(`GitHub GET pulls for ${branch} -> ${res.status} ${res.statusText}: ${await res.text()}`) + const [pr] = await res.json() as Array<{ base: { ref: string } }> + if (!pr) { + console.log(`::notice::danielroe/uppt/pr skipped: ${branch} has no open release PR.`) + output({ skip: 'true', base: '', prerelease: '' }) + return + } + output({ skip: 'false', base: pr.base.ref, prerelease: prereleaseIdentifier(branch.slice('release/v'.length)) }) +} + +runMain(import.meta.url, main) diff --git a/test/resolve-pr-target.test.ts b/test/resolve-pr-target.test.ts new file mode 100644 index 0000000..7f5fe2f --- /dev/null +++ b/test/resolve-pr-target.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import process from 'node:process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { main, prereleaseIdentifier } from '../scripts/resolve-pr-target.ts' + +describe('prereleaseIdentifier', () => { + it.each([ + ['1.3.0', ''], + ['5.0.0-beta.0', 'beta'], + ['5.0.0-rc', 'rc'], + ['5.0.0-alpha.1.2', 'alpha.1'], + ['5.0.0-3', '0'], + ])('%s -> %j', (version, id) => { + expect(prereleaseIdentifier(version)).toBe(id) + }) +}) + +describe('main', () => { + let env: NodeJS.ProcessEnv + let output: string + let pulls: unknown[] + let status: number + + beforeEach(() => { + env = { ...process.env } + output = resolve(mkdtempSync(resolve(tmpdir(), 'uppt-target-')), 'out') + writeFileSync(output, '') + Object.assign(process.env, { + GITHUB_OUTPUT: output, + GITHUB_REPOSITORY: 'owner/repo', + GITHUB_TOKEN: 'tok', + BASE_BRANCH: 'main', + PRERELEASE: 'beta', + }) + pulls = [] + status = 200 + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ + ok: status === 200, + status, + statusText: '', + text: () => Promise.resolve('nope'), + json: () => Promise.resolve(pulls), + }))) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + process.env = env + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('passes the inputs through for a push to the base branch', async () => { + process.env.GITHUB_REF = 'refs/heads/main' + await main() + expect(readFileSync(output, 'utf8')).toBe('skip=false\nbase=main\nprerelease=beta\n') + expect(fetch).not.toHaveBeenCalled() + }) + + it('targets the base and track of the open PR for a pushed release branch', async () => { + process.env.GITHUB_REF = 'refs/heads/release/v5.0.0-rc.1' + pulls = [{ base: { ref: '4.x' } }] + await main() + expect(readFileSync(output, 'utf8')).toBe('skip=false\nbase=4.x\nprerelease=rc\n') + expect(fetch).toHaveBeenCalledWith( + 'https://api.github.com/repos/owner/repo/pulls?state=open&head=owner%3Arelease%2Fv5.0.0-rc.1', + expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer tok' }) }), + ) + }) + + it('skips a pushed release branch with no open PR', async () => { + process.env.GITHUB_REF = 'refs/heads/release/v1.3.0' + delete process.env.GITHUB_TOKEN + await main() + expect(readFileSync(output, 'utf8')).toBe('skip=true\nbase=\nprerelease=\n') + }) + + it('throws when the PR lookup fails', async () => { + process.env.GITHUB_REF = 'refs/heads/release/v1.3.0' + status = 500 + await expect(main()).rejects.toThrow(/-> 500/) + }) + + it('writes to stdout outside Actions', async () => { + delete process.env.GITHUB_OUTPUT + delete process.env.GITHUB_REF + delete process.env.BASE_BRANCH + delete process.env.PRERELEASE + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + await main() + expect(write).toHaveBeenCalledWith('skip=false\nbase=\nprerelease=\n') + }) +}) -- 2.51.2