Skip to content

Release to npm

Release to npm #410

Workflow file for this run

name: Release to npm
on:
# Fire only after the "Tests" workflow completes
workflow_run:
workflows: ["Tests"] # must match your Tests workflow name exactly
types: [completed]
permissions:
contents: read # default for all jobs; least-privileged
concurrency:
group: release-${{ github.workflow }}-${{ github.run_id }}
cancel-in-progress: false
jobs:
check:
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
outputs:
release: ${{ steps.bump.outputs.release }}
steps:
- name: Checkout (pinned SHA, minimal history)
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 2
persist-credentials: false
- name: Check for version bump
id: bump
run: |
prev=$(git show HEAD^:package.json | jq -r .version)
curr=$(jq -r .version package.json)
if [ "$prev" != "$curr" ]; then
echo "release=true" >> $GITHUB_OUTPUT
else
echo "release=false" >> $GITHUB_OUTPUT
fi
publish:
needs: check
if: needs.check.outputs.release == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
environment:
name: npm
steps:
- name: Checkout (pinned SHA, full history)
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
cache: npm
- name: Install
run: npm ci
- name: Test
run: npm test -- --ci
- name: Build
run: npm run build:rollup
- name: Disable npm lifecycle scripts (safety)
run: |
npm config set ignore-scripts true
echo "npm_config_ignore_scripts=true" >> $GITHUB_ENV
- name: Read pkg name & version
id: pkg
run: |
echo "name=$(node -p "require('./package.json').name")" >> $GITHUB_OUTPUT
echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Skip if version already on npm
id: exists
run: |
if npm view "${{ steps.pkg.outputs.name }}@${{ steps.pkg.outputs.version }}" version >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Publish to npm (with provenance)
if: steps.exists.outputs.exists == 'false'
run: npm publish --access public --provenance
- name: Announce on Discord
if: steps.exists.outputs.exists == 'false'
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
PKG_NAME: ${{ steps.pkg.outputs.name }}
PKG_VERSION: ${{ steps.pkg.outputs.version }}
REPO: ${{ github.repository }}
run: |
node - <<'NODE'
const fs = require('fs');
const https = require('https');
const webhook = process.env.DISCORD_WEBHOOK_URL;
const name = process.env.PKG_NAME; // e.g. @radui/ui
const version = process.env.PKG_VERSION; // e.g. 0.1.3
const repo = process.env.REPO;
const npmUrl = `https://www.npmjs.com/package/${encodeURIComponent(name)}/v/${version}`;
const ghUrl = `https://github.com/${repo}`;
const read = p => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } };
const trunc = (s, n) => s && s.length > n ? s.slice(0, n - 1) + '…' : (s || '');
const squash = s => (s || '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\r/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
const esc = v => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// ---- locate CHANGELOG for this package ----
const short = name.includes('/') ? name.split('/').pop() : name;
const candidates = [
'CHANGELOG.md',
`packages/${short}/CHANGELOG.md`,
...(
fs.existsSync('packages')
? fs.readdirSync('packages')
.filter(d => fs.existsSync(`packages/${d}/CHANGELOG.md`))
.map(d => `packages/${d}/CHANGELOG.md`)
: []
)
];
let changelogRaw = '';
let changelogPath = '';
const headingRE = new RegExp(`^(#{2,6})\\s*\\[?v?${esc(version)}\\]?\\s*$`, 'm');
for (const p of candidates) {
const raw = read(p);
if (raw && headingRE.test(raw)) {
changelogRaw = raw;
changelogPath = p;
break;
}
}
if (!changelogRaw) changelogRaw = read('CHANGELOG.md'); // final fallback
// ---- extract only this version's section (respect heading level) ----
let description = 'Changelog is available in the repo.';
if (changelogRaw) {
const m = changelogRaw.match(headingRE);
if (m) {
const level = m[1].length; // ## or ###
const start = m.index + m[0].length;
const rest = changelogRaw.slice(start);
// Next heading of same or higher level ends this section
const nextRE = new RegExp(`^#{1,${level}}\\s+`, 'm');
const endIdx = rest.search(nextRE);
const section = (endIdx >= 0 ? rest.slice(0, endIdx) : rest)
.replace(/^#{2,6}.*$/m, '') // drop the matched heading line
.trim();
if (section) description = trunc(squash(section), 3600);
}
}
// ---- README summary (first paragraph after title/badges) ----
let readmeSummary = '';
const readme = read('README.md');
if (readme) {
const lines = readme.split('\n');
let i = 0;
while (i < lines.length && (
/^\s*#\s/.test(lines[i]) ||
/!\[.*\]\(.*\)|<img/i.test(lines[i]) ||
/^\s*$/.test(lines[i])
)) i++;
const para = [];
for (; i < lines.length && !/^\s*$/.test(lines[i]); i++) para.push(lines[i]);
readmeSummary = trunc(squash(para.join('\n')), 900);
}
// ---- build Discord embed ----
const body = {
username: "Rad UI Release Bot",
embeds: [{
title: `Released ${name} ${version} 🎉`,
url: npmUrl,
description,
fields: [
...(readmeSummary ? [{ name: "Summary", value: readmeSummary, inline: false }] : []),
{ name: "npm", value: `[${name}@${version}](${npmUrl})`, inline: true },
{ name: "GitHub", value: ghUrl, inline: true },
...(changelogPath ? [{ name: "Changelog file", value: changelogPath, inline: false }] : [])
],
timestamp: new Date().toISOString()
}]
};
const data = JSON.stringify(body);
const u = new URL(webhook);
const req = https.request(
{
hostname: u.hostname,
path: u.pathname + u.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data)
}
},
res => {
res.resume();
res.on('end', () => process.exit(res.statusCode < 300 ? 0 : 1));
}
);
req.on('error', e => { console.error(e); process.exit(1); });
req.write(data);
req.end();
NODE