Release to npm #410
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |