Skip to content

Commit 7cb5e0a

Browse files
committed
chore: setup OICD publisher for github actions to npm
1 parent 0be594d commit 7cb5e0a

7 files changed

Lines changed: 349 additions & 24 deletions

File tree

.github/workflows/ci-pipeline.yml

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,3 @@ jobs:
2323
run: npm ci
2424
- name: Run tests
2525
run: npm test
26-
release:
27-
needs: [test]
28-
runs-on: ubuntu-latest
29-
permissions:
30-
contents: write
31-
if: github.ref == 'refs/heads/main' && ${{ success() }}
32-
steps:
33-
- name: Checkout code
34-
uses: actions/checkout@v4
35-
- name: Use Node.js
36-
uses: actions/setup-node@v4
37-
with:
38-
node-version: '24'
39-
- name: Semantic Release
40-
env:
41-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42-
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
43-
run: npx semantic-release

.github/workflows/release.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Manual Release
2+
3+
on:
4+
workflow_dispatch:
5+
6+
permissions:
7+
id-token: write # Required for OIDC
8+
contents: write
9+
actions: read
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@v4
17+
with:
18+
fetch-depth: 0
19+
ref: ${{ github.sha }}
20+
- name: Setup Node.js
21+
uses: actions/setup-node@v4
22+
with:
23+
node-version: '24.x'
24+
registry-url: 'https://registry.npmjs.org'
25+
- name: Check CI pipeline status
26+
id: check_ci
27+
env:
28+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29+
WORKFLOW_FILE: ci-pipeline.yml
30+
run: node .github/scripts/check-ci.mjs
31+
- name: Check CodeQL status
32+
id: check_codeql
33+
env:
34+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35+
WORKFLOW_FILE: codeql.yml
36+
run: node .github/scripts/check-ci.mjs
37+
- name: Configure Git user
38+
run: |
39+
git config user.name 'github-actions[bot]'
40+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
41+
- name: Install dependencies
42+
run: npm ci
43+
- name: Run release script
44+
id: release
45+
if: ${{ steps.check_ci.outputs.result == 'true' && steps.check_codeql.outputs.result == 'true' }}
46+
env:
47+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48+
run: node .github/scripts/release.mjs
49+
- name: Check npm for current version
50+
id: check_npm
51+
if: ${{ steps.check_ci.outputs.result == 'true' && steps.check_codeql.outputs.result == 'true' }}
52+
run: node .github/scripts/check-npm.mjs
53+
- name: Publish to npm
54+
if: ${{ steps.check_ci.outputs.result == 'true' && steps.check_codeql.outputs.result == 'true' && (steps.release.outputs.released == 'true' || steps.check_npm.outputs.exists == 'false') }}
55+
run: npm publish --provenance --access public
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Checks that the latest run of the CI workflow for this commit succeeded
2+
// Writes `result=true|false` to GITHUB_OUTPUT
3+
4+
const token = process.env.GITHUB_TOKEN
5+
const repoFull = process.env.GITHUB_REPOSITORY || ''
6+
const sha = process.env.GITHUB_SHA || ''
7+
const outputPath = process.env.GITHUB_OUTPUT
8+
9+
if (!token) {
10+
console.log('no GITHUB_TOKEN provided')
11+
if (outputPath) {
12+
const fs = await import('node:fs/promises')
13+
await fs.appendFile(outputPath, 'result=false\n')
14+
}
15+
process.exit(0)
16+
}
17+
18+
const [owner, repo] = repoFull.split('/')
19+
20+
const api = 'https://api.github.com'
21+
const workflowId = process.env.WORKFLOW_FILE || 'ci-pipeline.yml'
22+
const branch = process.env.BRANCH || process.env.GITHUB_REF_NAME || 'main'
23+
24+
const headers = {
25+
'authorization': `Bearer ${token}`,
26+
'accept': 'application/vnd.github+json'
27+
}
28+
29+
const url = `${api}/repos/${owner}/${repo}/actions/workflows/${workflowId}/runs?per_page=50&branch=${encodeURIComponent(branch)}`
30+
31+
let ok = false
32+
33+
try {
34+
const res = await fetch(url, { headers })
35+
if (!res.ok) throw new Error(`github api ${res.status}`)
36+
const data = await res.json()
37+
const run = (data.workflow_runs || []).find(r => r.head_sha === sha)
38+
if (!run) {
39+
console.log(`no CI run found for ${sha} on ${branch}`)
40+
} else if (run.status !== 'completed' || run.conclusion !== 'success') {
41+
console.log(`CI run not successful on ${branch}: status=${run.status} conclusion=${run.conclusion}`)
42+
} else {
43+
ok = true
44+
console.log(`${workflowId} success for this commit on ${branch}`)
45+
}
46+
} catch (e) {
47+
console.log(`error checking ${workflowId} status on ${branch}: ${e.message}`)
48+
}
49+
50+
if (outputPath) {
51+
const fs = await import('node:fs/promises')
52+
await fs.appendFile(outputPath, `result=${ok ? 'true' : 'false'}\n`)
53+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
4+
const outputPath = process.env.GITHUB_OUTPUT
5+
6+
const readPackageJson = async () => {
7+
const pkgPath = path.join(process.cwd(), 'package.json')
8+
const json = await fs.readFile(pkgPath, 'utf8')
9+
const pkg = JSON.parse(json)
10+
return { pkgPath, pkg }
11+
}
12+
13+
const existsOnNpm = async (name, version) => {
14+
const encoded = encodeURIComponent(name)
15+
const url = `https://registry.npmjs.org/${encoded}`
16+
try {
17+
const res = await fetch(url)
18+
if (!res.ok) return false
19+
const data = await res.json()
20+
const versions = data && data.versions ? Object.keys(data.versions) : []
21+
return versions.includes(version)
22+
} catch (e) {
23+
return false
24+
}
25+
}
26+
27+
const main = async () => {
28+
const { pkg } = await readPackageJson()
29+
const name = pkg.name
30+
const version = pkg.version
31+
if (!name || !version) {
32+
console.log('package.json missing name or version')
33+
if (outputPath) await fs.appendFile(outputPath, 'exists=false\n')
34+
return
35+
}
36+
37+
const exists = await existsOnNpm(name, version)
38+
console.log(`npm version check: ${name}@${version} ${exists ? 'exists' : 'missing'}`)
39+
if (outputPath) await fs.appendFile(outputPath, `exists=${exists ? 'true' : 'false'}\n`)
40+
}
41+
42+
await main()
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { exec as cpExec } from 'node:child_process'
2+
import { promisify } from 'node:util'
3+
import fs from 'node:fs/promises'
4+
import path from 'node:path'
5+
6+
const exec = promisify(cpExec)
7+
8+
const run = async (cmd, opts = {}) => {
9+
try {
10+
const { stdout } = await exec(cmd, { ...opts })
11+
return stdout.trim()
12+
} catch (err) {
13+
return ''
14+
}
15+
}
16+
17+
const ensureGitUser = async () => {
18+
await run("git config user.name 'github-actions[bot]'")
19+
await run("git config user.email '41898282+github-actions[bot]@users.noreply.github.com'")
20+
}
21+
22+
const getLastTag = async () => {
23+
const tag = await run('git describe --tags --abbrev=0')
24+
return tag || ''
25+
}
26+
27+
const getCommitBlocks = async range => {
28+
const logCmd = range
29+
? `git log ${range} --pretty=format:%s%n%b%n==END==`
30+
: 'git log --pretty=format:%s%n%b%n==END=='
31+
const out = await run(logCmd)
32+
if (!out) return []
33+
return out
34+
.split('==END==')
35+
.map(s => s.trim())
36+
.filter(Boolean)
37+
}
38+
39+
const determineBump = commits => {
40+
let hasBreaking = false
41+
let hasFeat = false
42+
let hasFix = false
43+
let hasChore = false
44+
45+
const typeBang = /^([a-z]+)(\([^)]*\))?!:/i
46+
const feat = /^feat(\([^)]*\))?:/i
47+
const fix = /^fix(\([^)]*\))?:/i
48+
const chore = /^chore(\([^)]*\))?:/i
49+
50+
for (const block of commits) {
51+
const lines = block.split('\n')
52+
const subject = (lines.shift() || '').trim()
53+
const body = lines.join('\n')
54+
55+
if (typeBang.test(subject)) hasBreaking = true
56+
if (/BREAKING CHANGE/i.test(body)) hasBreaking = true
57+
58+
if (feat.test(subject)) hasFeat = true
59+
if (fix.test(subject)) hasFix = true
60+
if (chore.test(subject)) hasChore = true
61+
}
62+
63+
if (hasBreaking) return 'major'
64+
if (hasFeat) return 'minor'
65+
if (hasFix || hasChore) return 'patch'
66+
return 'none'
67+
}
68+
69+
const parseVersion = v => {
70+
const cleaned = String(v || '0.0.0').replace(/^v/, '')
71+
const [maj, min, pat] = cleaned.split('.').map(n => parseInt(n || '0', 10))
72+
return [isNaN(maj) ? 0 : maj, isNaN(min) ? 0 : min, isNaN(pat) ? 0 : pat]
73+
}
74+
75+
const incVersion = (version, level) => {
76+
let [maj, min, pat] = parseVersion(version)
77+
if (level === 'major') {
78+
maj += 1
79+
min = 0
80+
pat = 0
81+
} else if (level === 'minor') {
82+
min += 1
83+
pat = 0
84+
} else if (level === 'patch') {
85+
pat += 1
86+
}
87+
return `${maj}.${min}.${pat}`
88+
}
89+
90+
const readPackageJson = async () => {
91+
const pkgPath = path.join(process.cwd(), 'package.json')
92+
const json = await fs.readFile(pkgPath, 'utf8')
93+
const pkg = JSON.parse(json)
94+
return { pkgPath, pkg }
95+
}
96+
97+
const writePackageJson = async (pkgPath, pkg) => {
98+
const json = JSON.stringify(pkg, null, 2) + '\n'
99+
await fs.writeFile(pkgPath, json, 'utf8')
100+
}
101+
102+
const main = async () => {
103+
const isDryRun = process.argv.includes('--dry-run') || ['1', 'true', 'yes'].includes(String(process.env.DRY_RUN || '').toLowerCase())
104+
105+
await ensureGitUser()
106+
await run('git fetch --tags --force')
107+
108+
const lastTag = await getLastTag()
109+
if (!lastTag) {
110+
console.log('no existing tag found — skipping release to avoid scanning entire history. create an initial baseline tag like v0.0.0')
111+
if (process.env.GITHUB_OUTPUT) {
112+
await fs.appendFile(process.env.GITHUB_OUTPUT, 'released=false\n')
113+
}
114+
return
115+
}
116+
117+
const range = `${lastTag}..HEAD`
118+
const commits = await getCommitBlocks(range)
119+
120+
if (commits.length === 0) {
121+
console.log(`no commits found since ${lastTag || 'beginning'} — nothing to release`)
122+
if (process.env.GITHUB_OUTPUT) {
123+
await fs.appendFile(process.env.GITHUB_OUTPUT, 'released=false\n')
124+
}
125+
return
126+
}
127+
128+
const bump = determineBump(commits)
129+
if (bump === 'none') {
130+
console.log('no conventional commits requiring a release — exiting')
131+
if (process.env.GITHUB_OUTPUT) {
132+
await fs.appendFile(process.env.GITHUB_OUTPUT, 'released=false\n')
133+
}
134+
return
135+
}
136+
137+
const { pkgPath, pkg } = await readPackageJson()
138+
const baseVersion = lastTag.replace(/^v/, '')
139+
const newVersion = incVersion(baseVersion, bump)
140+
const newTag = `v${newVersion}`
141+
142+
if (lastTag && lastTag.replace(/^v/, '') === newVersion) {
143+
console.log(`computed version ${newVersion} equals last tag ${lastTag} — nothing to do`)
144+
if (process.env.GITHUB_OUTPUT) {
145+
await fs.appendFile(process.env.GITHUB_OUTPUT, 'released=false\n')
146+
}
147+
return
148+
}
149+
150+
// do not recreate an existing tag
151+
const tagExists = (await run(`git tag -l ${newTag}`)).trim() === newTag
152+
if (tagExists) {
153+
console.log(`tag ${newTag} already exists — skipping`)
154+
if (process.env.GITHUB_OUTPUT) {
155+
await fs.appendFile(process.env.GITHUB_OUTPUT, 'released=false\n')
156+
}
157+
return
158+
}
159+
160+
if (isDryRun) {
161+
const currentPkgVersion = pkg.version || 'unknown'
162+
console.log('[dry-run] last tag:', lastTag)
163+
console.log('[dry-run] bump type:', bump)
164+
console.log('[dry-run] package.json current version:', currentPkgVersion)
165+
console.log('[dry-run] would set package.json version to:', newVersion)
166+
console.log('[dry-run] would create tag:', newTag)
167+
console.log('[dry-run] would commit with message:', `chore(release): v${newVersion} [skip ci]`)
168+
if (process.env.GITHUB_OUTPUT) {
169+
await fs.appendFile(process.env.GITHUB_OUTPUT, 'released=false\n')
170+
}
171+
return
172+
}
173+
174+
pkg.version = newVersion
175+
await writePackageJson(pkgPath, pkg)
176+
177+
await run('git add package.json')
178+
const commitMsg = `chore(release): v${newVersion} [skip ci]`
179+
await run(`git commit -m "${commitMsg}"`)
180+
181+
await run(`git tag -a ${newTag} -m "release ${newTag}"`)
182+
183+
const branch = await run('git rev-parse --abbrev-ref HEAD')
184+
await run(`git push origin ${branch}`)
185+
await run(`git push origin ${newTag}`)
186+
187+
console.log(`released ${newTag} from base ${lastTag} and pushed changes`)
188+
if (process.env.GITHUB_OUTPUT) {
189+
await fs.appendFile(process.env.GITHUB_OUTPUT, 'released=true\n')
190+
}
191+
}
192+
193+
await main()

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)