[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
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: closed` after the caller's workflow has checked
5// 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// PACKAGES newline-separated list of publishable workspace
14// dirs/globs (monorepo); omit for single-package repos
15
16import process from 'node:process'
17import { execFileSync } from 'node:child_process'
18import { isSemver, resolveCurrentVersion } from './_workspaces.ts'
19
20function run (cmd: string, args: string[], opts: { env?: NodeJS.ProcessEnv } = {}) {
21 execFileSync(cmd, args, { stdio: 'inherit', env: { ...process.env, ...opts.env } })
22}
23
24function capture (cmd: string, args: string[], env?: NodeJS.ProcessEnv): string {
25 return execFileSync(cmd, args, { encoding: 'utf8', env: { ...process.env, ...env } }).trim()
26}
27
28function tagExists (repo: string, tag: string, env: NodeJS.ProcessEnv): boolean {
29 // `gh api` exits non-zero on 404; treat that as "does not exist". Any other
30 // failure (auth, network) we want to propagate, so we re-check by asking gh
31 // to ignore HTTP errors and inspect the JSON.
32 try {
33 const out = execFileSync(
34 'gh',
35 ['api', '-H', 'Accept: application/vnd.github+json', `/repos/${repo}/git/ref/tags/${tag}`],
36 { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...env } },
37 )
38 return Boolean(out.trim())
39 } catch {
40 return false
41 }
42}
43
44function main () {
45 const token = process.env.GITHUB_TOKEN
46 if (!token) throw new Error('GITHUB_TOKEN is required')
47 const repo = process.env.GITHUB_REPOSITORY
48 if (!repo || !repo.includes('/')) throw new Error('GITHUB_REPOSITORY is required')
49
50 const version = resolveCurrentVersion(process.cwd(), process.env.PACKAGES?.trim() ?? '')
51 // `version` flows into ref names and `gh` argv. Pin to strict semver to
52 // rule out flag-injection (`--upload-pack=...`) and ref-confusion attacks.
53 if (!isSemver(version)) {
54 throw new Error(`Refusing to tag: resolved version "${version}" is not strict semver`)
55 }
56 const tag = `v${version}`
57 const ghEnv = { GH_TOKEN: token }
58
59 if (tagExists(repo, tag, ghEnv)) {
60 throw new Error(`Refusing to tag: ${tag} already exists on ${repo}. If this is a rerun, delete the tag and the release first, or bump the version.`)
61 }
62
63 // Create the tag via the GitHub API instead of `git push`, so this step
64 // doesn't need git-level write credentials baked into the runner.
65 const sha = capture('git', ['rev-parse', 'HEAD'])
66 run('gh', [
67 'api', '-X', 'POST',
68 '-H', 'Accept: application/vnd.github+json',
69 `/repos/${repo}/git/refs`,
70 '-f', `ref=refs/tags/${tag}`,
71 '-f', `sha=${sha}`,
72 ], { env: ghEnv })
73
74 const body = process.env.PR_BODY ?? ''
75 run('gh', ['release', 'create', tag, '--title', tag, '--notes', body], { env: ghEnv })
76
77 const workflow = process.env.PUBLISH_WORKFLOW || 'release.yml'
78 run('gh', ['workflow', 'run', workflow, '--ref', tag], { env: ghEnv })
79
80 console.log(`Tagged ${tag}, created release, dispatched ${workflow}.`)
81}
82
83try {
84 main()
85}
86catch (err) {
87 console.error(err)
88 process.exit(1)
89}