Select the types of activity you want to include in your feed.
[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
···11+// Pin the `uses: danielroe/uppt/<sub>@<ref>` lines in `README.md` to the
22+// SHA of the tag that just triggered this run, and commit directly to
33+// `main` via the GitHub Contents API.
44+//
55+// Env:
66+// GITHUB_TOKEN required (contents: write on the repo)
77+// GITHUB_REPOSITORY "owner/repo" (set automatically inside Actions)
88+// GITHUB_REF_NAME tag name, e.g. "v1.2.3" (set automatically)
99+// GITHUB_SHA commit SHA the tag points at (set automatically)
1010+1111+import process from 'node:process'
1212+import { Buffer } from 'node:buffer'
1313+1414+interface GhRefResponse { object: { sha: string } }
1515+interface GhContentResponse { sha: string, content: string, encoding: 'base64' }
1616+interface GhPutResponse { commit: { sha: string, html_url: string } }
1717+1818+async function gh<T> (token: string, method: string, path: string, body?: unknown): Promise<{ status: number, data: T }> {
1919+ const res = await fetch(`https://api.github.com${path}`, {
2020+ method,
2121+ headers: {
2222+ 'Authorization': `Bearer ${token}`,
2323+ 'Accept': 'application/vnd.github+json',
2424+ 'X-GitHub-Api-Version': '2022-11-28',
2525+ 'User-Agent': 'danielroe/uppt pin-readme',
2626+ ...(body ? { 'Content-Type': 'application/json' } : {}),
2727+ },
2828+ body: body ? JSON.stringify(body) : undefined,
2929+ })
3030+ const text = await res.text()
3131+ const data = text ? JSON.parse(text) as T : ({} as T)
3232+ if (!res.ok && res.status !== 409 && res.status !== 422) {
3333+ throw new Error(`${method} ${path} failed: ${res.status} ${text}`)
3434+ }
3535+ return { status: res.status, data }
3636+}
3737+3838+function rewrite (readme: string, sha: string, tag: string) {
3939+ // Match `uses: danielroe/uppt/<sub>@<ref>` optionally followed by a `#
4040+ // <version>` trailing comment. `<sub>` is restricted to the three known
4141+ // subactions so we don't accidentally rewrite future siblings.
4242+ const pinRe = /(\buses:\s*danielroe\/uppt\/(?:pr|release|publish))@\S+(?:\s+#\s*v\S+)?/g
4343+ return readme.replace(pinRe, (_match, prefix: string) => `${prefix}@${sha} # ${tag}`)
4444+}
4545+4646+async function main () {
4747+ const token = process.env.GITHUB_TOKEN
4848+ if (!token) throw new Error('GITHUB_TOKEN is required')
4949+5050+ const repo = process.env.GITHUB_REPOSITORY
5151+ if (!repo || !/^[^/]+\/[^/]+$/.test(repo)) throw new Error(`GITHUB_REPOSITORY invalid: ${repo}`)
5252+5353+ const tag = process.env.GITHUB_REF_NAME ?? ''
5454+ if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag)) {
5555+ throw new Error(`GITHUB_REF_NAME is not a semver tag: ${tag}`)
5656+ }
5757+5858+ const sha = process.env.GITHUB_SHA ?? ''
5959+ if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`GITHUB_SHA invalid: ${sha}`)
6060+6161+ // Discover the default branch rather than hardcoding `main`. The release
6262+ // workflow already parameterises this via `base-branch`; mirror that here
6363+ // so a fork on a non-`main` default doesn't break.
6464+ const { data: repoData } = await gh<{ default_branch: string }>(token, 'GET', `/repos/${repo}`)
6565+ const branch = repoData.default_branch
6666+ if (!branch) throw new Error('Could not resolve default branch')
6767+6868+ // Retry loop: if `main` advances between read and write, GitHub returns
6969+ // 409 (or 422 with "does not match") and we restart from the new head.
7070+ // In practice this almost never fires; the window is seconds wide and
7171+ // releases are not concurrent. Cap at a small number so a genuine
7272+ // conflict (someone hand-editing the README between read and write)
7373+ // surfaces as a workflow failure rather than spinning.
7474+ for (let attempt = 1; attempt <= 5; attempt++) {
7575+ const { data: file } = await gh<GhContentResponse>(
7676+ token,
7777+ 'GET',
7878+ `/repos/${repo}/contents/README.md?ref=${encodeURIComponent(branch)}`,
7979+ )
8080+ if (file.encoding !== 'base64') throw new Error(`Unexpected README encoding: ${file.encoding}`)
8181+ const before = Buffer.from(file.content, 'base64').toString('utf8')
8282+ const after = rewrite(before, sha, tag)
8383+8484+ if (after === before) {
8585+ console.log('README.md already pinned to the current release; nothing to do.')
8686+ return
8787+ }
8888+8989+ const put = await gh<GhPutResponse>(token, 'PUT', `/repos/${repo}/contents/README.md`, {
9090+ message: `chore: pin README example to ${tag}`,
9191+ content: Buffer.from(after, 'utf8').toString('base64'),
9292+ sha: file.sha,
9393+ branch,
9494+ })
9595+9696+ if (put.status === 409 || put.status === 422) {
9797+ console.log(`attempt ${attempt}: ${branch} advanced under us, retrying`)
9898+ continue
9999+ }
100100+ if (put.status < 200 || put.status >= 300) {
101101+ throw new Error(`PUT contents returned ${put.status}`)
102102+ }
103103+104104+ console.log(`pinned README.md to ${tag} in ${put.data.commit.html_url}`)
105105+ return
106106+ }
107107+108108+ throw new Error('Exhausted retries trying to update README.md')
109109+}
110110+111111+main().catch((err) => {
112112+ console.error(err)
113113+ process.exit(1)
114114+})