[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.

ci: pin sha in README after release

+147
+33
.github/workflows/pin-readme.yml
··· 1 + name: pin-readme 2 + 3 + # After a release tag lands, rewrite the `uses: danielroe/uppt/*@<ref>` 4 + # lines in README.md to pin to the new tag's SHA and commit straight 5 + # to main. See scripts/pin-readme.ts for the rewrite logic. 6 + 7 + on: 8 + push: 9 + tags: ['v*'] 10 + workflow_dispatch: 11 + 12 + permissions: {} 13 + 14 + jobs: 15 + pin: 16 + runs-on: ubuntu-latest 17 + permissions: 18 + contents: write # push the README update directly to main 19 + steps: 20 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 21 + with: 22 + ref: main 23 + fetch-depth: 0 24 + token: ${{ secrets.GITHUB_TOKEN }} 25 + 26 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 27 + with: 28 + node-version: '24' 29 + 30 + - name: Pin README and push to main 31 + env: 32 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 + run: node scripts/pin-readme.ts
+114
scripts/pin-readme.ts
··· 1 + // Pin the `uses: danielroe/uppt/<sub>@<ref>` lines in `README.md` to the 2 + // SHA of the tag that just triggered this run, and commit directly to 3 + // `main` via the GitHub Contents API. 4 + // 5 + // Env: 6 + // GITHUB_TOKEN required (contents: write on the repo) 7 + // GITHUB_REPOSITORY "owner/repo" (set automatically inside Actions) 8 + // GITHUB_REF_NAME tag name, e.g. "v1.2.3" (set automatically) 9 + // GITHUB_SHA commit SHA the tag points at (set automatically) 10 + 11 + import process from 'node:process' 12 + import { Buffer } from 'node:buffer' 13 + 14 + interface GhRefResponse { object: { sha: string } } 15 + interface GhContentResponse { sha: string, content: string, encoding: 'base64' } 16 + interface GhPutResponse { commit: { sha: string, html_url: string } } 17 + 18 + async function gh<T> (token: string, method: string, path: string, body?: unknown): Promise<{ status: number, data: T }> { 19 + const res = await fetch(`https://api.github.com${path}`, { 20 + method, 21 + headers: { 22 + 'Authorization': `Bearer ${token}`, 23 + 'Accept': 'application/vnd.github+json', 24 + 'X-GitHub-Api-Version': '2022-11-28', 25 + 'User-Agent': 'danielroe/uppt pin-readme', 26 + ...(body ? { 'Content-Type': 'application/json' } : {}), 27 + }, 28 + body: body ? JSON.stringify(body) : undefined, 29 + }) 30 + const text = await res.text() 31 + const data = text ? JSON.parse(text) as T : ({} as T) 32 + if (!res.ok && res.status !== 409 && res.status !== 422) { 33 + throw new Error(`${method} ${path} failed: ${res.status} ${text}`) 34 + } 35 + return { status: res.status, data } 36 + } 37 + 38 + function rewrite (readme: string, sha: string, tag: string) { 39 + // Match `uses: danielroe/uppt/<sub>@<ref>` optionally followed by a `# 40 + // <version>` trailing comment. `<sub>` is restricted to the three known 41 + // subactions so we don't accidentally rewrite future siblings. 42 + const pinRe = /(\buses:\s*danielroe\/uppt\/(?:pr|release|publish))@\S+(?:\s+#\s*v\S+)?/g 43 + return readme.replace(pinRe, (_match, prefix: string) => `${prefix}@${sha} # ${tag}`) 44 + } 45 + 46 + async function main () { 47 + const token = process.env.GITHUB_TOKEN 48 + if (!token) throw new Error('GITHUB_TOKEN is required') 49 + 50 + const repo = process.env.GITHUB_REPOSITORY 51 + if (!repo || !/^[^/]+\/[^/]+$/.test(repo)) throw new Error(`GITHUB_REPOSITORY invalid: ${repo}`) 52 + 53 + const tag = process.env.GITHUB_REF_NAME ?? '' 54 + if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag)) { 55 + throw new Error(`GITHUB_REF_NAME is not a semver tag: ${tag}`) 56 + } 57 + 58 + const sha = process.env.GITHUB_SHA ?? '' 59 + if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`GITHUB_SHA invalid: ${sha}`) 60 + 61 + // Discover the default branch rather than hardcoding `main`. The release 62 + // workflow already parameterises this via `base-branch`; mirror that here 63 + // so a fork on a non-`main` default doesn't break. 64 + const { data: repoData } = await gh<{ default_branch: string }>(token, 'GET', `/repos/${repo}`) 65 + const branch = repoData.default_branch 66 + if (!branch) throw new Error('Could not resolve default branch') 67 + 68 + // Retry loop: if `main` advances between read and write, GitHub returns 69 + // 409 (or 422 with "does not match") and we restart from the new head. 70 + // In practice this almost never fires; the window is seconds wide and 71 + // releases are not concurrent. Cap at a small number so a genuine 72 + // conflict (someone hand-editing the README between read and write) 73 + // surfaces as a workflow failure rather than spinning. 74 + for (let attempt = 1; attempt <= 5; attempt++) { 75 + const { data: file } = await gh<GhContentResponse>( 76 + token, 77 + 'GET', 78 + `/repos/${repo}/contents/README.md?ref=${encodeURIComponent(branch)}`, 79 + ) 80 + if (file.encoding !== 'base64') throw new Error(`Unexpected README encoding: ${file.encoding}`) 81 + const before = Buffer.from(file.content, 'base64').toString('utf8') 82 + const after = rewrite(before, sha, tag) 83 + 84 + if (after === before) { 85 + console.log('README.md already pinned to the current release; nothing to do.') 86 + return 87 + } 88 + 89 + const put = await gh<GhPutResponse>(token, 'PUT', `/repos/${repo}/contents/README.md`, { 90 + message: `chore: pin README example to ${tag}`, 91 + content: Buffer.from(after, 'utf8').toString('base64'), 92 + sha: file.sha, 93 + branch, 94 + }) 95 + 96 + if (put.status === 409 || put.status === 422) { 97 + console.log(`attempt ${attempt}: ${branch} advanced under us, retrying`) 98 + continue 99 + } 100 + if (put.status < 200 || put.status >= 300) { 101 + throw new Error(`PUT contents returned ${put.status}`) 102 + } 103 + 104 + console.log(`pinned README.md to ${tag} in ${put.data.commit.html_url}`) 105 + return 106 + } 107 + 108 + throw new Error('Exhausted retries trying to update README.md') 109 + } 110 + 111 + main().catch((err) => { 112 + console.error(err) 113 + process.exit(1) 114 + })