ci: track staged npm release availability

This commit is contained in:
Filip Skokan
2026-07-30 01:17:32 +02:00
parent 7fc0aab9bc
commit 754d5d49e4
5 changed files with 261 additions and 14 deletions
+37
View File
@@ -0,0 +1,37 @@
name: npm availability
permissions: {}
on:
workflow_call:
inputs:
tag:
description: Release tag to monitor
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: Release tag to monitor
required: true
type: string
jobs:
wait:
runs-on: ubuntu-latest
timeout-minutes: 65
permissions:
contents: write
discussions: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: lts/*
- name: Wait for npm availability
run: node tools/wait-for-npm.cjs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.tag }}
+10
View File
@@ -91,3 +91,13 @@ jobs:
- run: node tools/release-notes.cjs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
npm-availability:
needs:
- github
permissions:
contents: write
discussions: write
uses: ./.github/workflows/npm-availability.yml
with:
tag: ${{ github.ref_name }}
+1
View File
@@ -48,6 +48,7 @@ All notable changes to this project will be documented in this file. See [commit
* unify base64 decode errors ([3a91833](https://github.com/panva/jose/commit/3a918336051e7b60122327af62baf16b19f4e1d7))
* validate each token once rather than once per layer ([bbdae09](https://github.com/panva/jose/commit/bbdae094ca0acaabf33688f56971991d07523714))
* write each algorithm identifier once ([73d83b5](https://github.com/panva/jose/commit/73d83b5854a797eb6cac8e3d4c8481a9d0207830))
## [6.2.4](https://github.com/panva/jose/compare/v6.2.3...v6.2.4) (2026-07-21)
+26 -14
View File
@@ -1,20 +1,32 @@
const fs = require('fs')
const { execSync } = require('child_process')
const { addPendingNotice } = require('./wait-for-npm.cjs')
execSync('git show HEAD -- CHANGELOG.md > CHANGELOG.diff')
function extractReleaseNotes(changelog, version) {
const releaseHeading = `## [${version}]`
const releaseStart = changelog.indexOf(releaseHeading)
const tag = execSync('git tag --points-at HEAD').toString().trim()
if (releaseStart === -1) {
throw new Error(`could not find ${releaseHeading} in CHANGELOG.md`)
}
fs.writeFileSync(
'notes.md',
fs
.readFileSync('CHANGELOG.diff')
.toString()
.split('\n')
.filter((line) => line.startsWith('+') && !line.startsWith('+++'))
.map((line) => line.slice(1))
.slice(3)
.join('\n'),
)
const notesStart = changelog.indexOf('\n', releaseStart) + 1
const nextRelease = changelog.indexOf('\n## [', notesStart)
return changelog.slice(notesStart, nextRelease === -1 ? undefined : nextRelease).trim()
}
execSync(`gh release create ${tag} -F notes.md --title ${tag} --discussion-category Releases`)
function main() {
const tag = execSync('git tag --points-at HEAD').toString().trim()
const version = tag.replace(/^v/, '')
const changelog = fs.readFileSync('CHANGELOG.md', 'utf8')
const notes = extractReleaseNotes(changelog, version)
fs.writeFileSync('notes.md', addPendingNotice(notes))
execSync(`gh release create ${tag} -F notes.md --title ${tag} --discussion-category Releases`)
}
module.exports = { extractReleaseNotes }
if (require.main === module) {
main()
}
+187
View File
@@ -0,0 +1,187 @@
const { execFileSync } = require('child_process')
const { name } = require('../package.json')
const NOTICE_START = '<!-- npm-publish-pending:start -->'
const NOTICE_END = '<!-- npm-publish-pending:end -->'
const MAX_WAIT = 60 * 60_000
const RETRY_INTERVAL = 60_000
const REQUEST_TIMEOUT = 15_000
const notice = `${NOTICE_START}
> [!NOTE]
> The npm package has been staged and is pending npm approval. It will become available after publish-time scanning completes.
${NOTICE_END}`
function addPendingNotice(body) {
if (body.includes(NOTICE_START)) {
return body
}
return `${notice}\n\n${body}`
}
function removePendingNotice(body) {
const start = body.indexOf(NOTICE_START)
if (start === -1) {
return body
}
const end = body.indexOf(NOTICE_END, start)
if (end === -1) {
throw new Error('npm pending notice is missing its closing marker')
}
const afterNotice = end + NOTICE_END.length
const separatorLength = body.startsWith('\n\n', afterNotice)
? 2
: Number(body.startsWith('\n', afterNotice))
return `${body.slice(0, start)}${body.slice(afterNotice + separatorLength)}`
}
function ghApi(endpoint, options = {}) {
const args = ['api']
if (options.method) {
args.push('--method', options.method)
}
if (options.input) {
args.push('--input', '-')
}
args.push(endpoint)
return execFileSync('gh', args, {
encoding: 'utf8',
input: options.input,
stdio: ['pipe', 'pipe', 'inherit'],
})
}
function updateBody(endpoint, body) {
ghApi(endpoint, {
method: 'PATCH',
input: JSON.stringify({ body }),
})
}
function discussionEndpoint(repository, discussionUrl) {
const url = new URL(discussionUrl)
const match = /^\/[^/]+\/[^/]+\/discussions\/(\d+)$/.exec(url.pathname)
if (!match) {
throw new Error(`unexpected release discussion URL: ${discussionUrl}`)
}
return `repos/${repository}/discussions/${match[1]}`
}
function updateReleaseBodies(repository, tag, transform) {
const releaseEndpoint = `repos/${repository}/releases/tags/${encodeURIComponent(tag)}`
const release = JSON.parse(ghApi(releaseEndpoint))
if (!release.discussion_url) {
throw new Error(`release ${tag} does not have a linked discussion`)
}
const linkedDiscussionEndpoint = discussionEndpoint(repository, release.discussion_url)
const discussion = JSON.parse(ghApi(linkedDiscussionEndpoint))
const releaseBody = transform(release.body || '')
const discussionBody = transform(discussion.body || '')
if (releaseBody !== release.body) {
updateBody(`repos/${repository}/releases/${release.id}`, releaseBody)
}
if (discussionBody !== discussion.body) {
updateBody(linkedDiscussionEndpoint, discussionBody)
}
}
function markReleasePending(repository, tag) {
updateReleaseBodies(repository, tag, addPendingNotice)
}
function finalizeRelease(repository, tag) {
updateReleaseBodies(repository, tag, removePendingNotice)
}
async function isPublished(version) {
const response = await fetch(
`https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(version)}`,
{
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
},
)
if (response.status === 404) {
return false
}
if (!response.ok) {
throw new Error(`npm registry returned ${response.status} ${response.statusText}`)
}
const manifest = await response.json()
return manifest.version === version
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function main() {
const repository = process.env.GITHUB_REPOSITORY
const tag = process.env.RELEASE_TAG || process.env.GITHUB_REF_NAME
if (!repository || !tag) {
throw new Error('GITHUB_REPOSITORY and RELEASE_TAG or GITHUB_REF_NAME must be set')
}
const match = /^v(\d+\.\d+\.\d+)$/.exec(tag)
if (!match) {
throw new Error(`unexpected release tag: ${tag}`)
}
const version = match[1]
const deadline = Date.now() + MAX_WAIT
let attempt = 1
while (Date.now() < deadline) {
let published = false
try {
published = await isPublished(version)
} catch (error) {
console.warn(`npm availability check failed: ${error.message}`)
}
if (published) {
finalizeRelease(repository, tag)
console.log(`${name}@${version} is available on npm; removed the pending notice`)
return
}
if (attempt === 1) {
markReleasePending(repository, tag)
console.log(`npm pending notice is present on release ${tag} and its discussion`)
}
const remaining = deadline - Date.now()
if (remaining > 0) {
console.log(`${name}@${version} is not available on npm; retrying in one minute`)
await wait(Math.min(RETRY_INTERVAL, remaining))
attempt++
}
}
throw new Error(
`${name}@${version} was not available on npm after ${attempt} attempts in one hour`,
)
}
module.exports = {
addPendingNotice,
discussionEndpoint,
removePendingNotice,
}
if (require.main === module) {
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
}