Skip to content

Merge pull request #1112 from nteract/align-ssr #98

Merge pull request #1112 from nteract/align-ssr

Merge pull request #1112 from nteract/align-ssr #98

Workflow file for this run

name: Release
on:
push:
tags: ["v*"]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
# `contents: write` is required for `gh release create` to publish
# the GitHub Release at the end of the workflow. `id-token: write`
# stays for npm provenance.
contents: write
id-token: write
steps:
- name: Checkout source code
uses: actions/checkout@v7
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22.x
cache: "npm"
registry-url: "https://registry.npmjs.org"
- name: Verify release tag matches package version
run: |
VERSION=$(node -p "require('./package.json').version")
EXPECTED_TAG="v${VERSION}"
if [ "$GITHUB_REF_NAME" != "$EXPECTED_TAG" ]; then
echo "Release tag mismatch: expected $EXPECTED_TAG for package.json version $VERSION, got $GITHUB_REF_NAME"
exit 1
fi
echo "Verified release tag $EXPECTED_TAG"
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Check AI instruction mirror freshness
run: npm run check:ai-instructions
- name: Build library
run: npm run dist:prod
- name: Check public API surface
run: npm run check:api-surface
- name: Build MCP server
run: npm run build:mcp
- name: Run tests with coverage
run: npm run test:coverage
- name: Run type check
run: npm run typescript
- name: Run test type check
run: npm run typescript:tests
- name: Check chart spec registry round-trip
run: npm run check:chart-specs
- name: Check capability matrix freshness
run: npm run check:capabilities
- name: Check generated AI surface manifest freshness
run: npm run check:ai-surface
- name: Check blog metadata registry freshness
run: npm run check:blog-entries
- name: Build docs site and smoke-check generated routes
run: npm run check:website-build
- name: Verify TypeScript declarations
run: |
for f in dist/semiotic.d.ts dist/semiotic-xy.d.ts dist/semiotic-ordinal.d.ts dist/semiotic-network.d.ts dist/semiotic-geo.d.ts dist/semiotic-realtime.d.ts dist/semiotic-ai.d.ts dist/semiotic-data.d.ts dist/semiotic-server.d.ts dist/semiotic-themes.d.ts; do
if [ ! -f "$f" ]; then
echo "MISSING: $f — aborting release"
exit 1
fi
done
echo "All declaration files present"
- name: Determine npm dist-tag
id: dist-tag
run: |
VERSION=$(node -p "require('./package.json').version")
if echo "$VERSION" | grep -qE '[-](alpha|beta|rc)'; then
echo "tag=beta" >> "$GITHUB_OUTPUT"
else
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi
# Construct one archive after every build/gate. All subsequent smoke,
# checksum/SBOM evidence, dry-run, and publication steps use this exact
# file; publishing the checkout would silently create a second archive.
- name: Create immutable release artifact
id: release-artifact
run: |
OUT_DIR="$RUNNER_TEMP/semiotic-release-artifact"
npm run release:artifact -- --out-dir "$OUT_DIR"
node scripts/create-release-artifact.mjs --out-dir "$OUT_DIR" --verify
MANIFEST=$(find "$OUT_DIR" -maxdepth 1 -name '*.provenance.json' -print -quit)
TARBALL=$(node -e "const fs=require('fs'); const path=require('path'); const manifest=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); process.stdout.write(path.join(path.dirname(process.argv[1]), manifest.tarball.file))" "$MANIFEST")
INTEGRITY=$(node -e "const fs=require('fs'); const manifest=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); process.stdout.write(manifest.tarball.integrity)" "$MANIFEST")
echo "directory=$OUT_DIR" >> "$GITHUB_OUTPUT"
echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT"
echo "integrity=$INTEGRITY" >> "$GITHUB_OUTPUT"
- name: Upload immutable release evidence
uses: actions/upload-artifact@v4
with:
name: semiotic-${{ github.ref_name }}-release-artifact
path: ${{ steps.release-artifact.outputs.directory }}
if-no-files-found: error
- name: Pack-and-import smoke test (exact tarball)
run: npm run check:pack -- --tarball "${{ steps.release-artifact.outputs.tarball }}"
- name: Check packed cold-consumer baseline (exact tarball)
run: npm run check:cold-consumer -- --tarball "${{ steps.release-artifact.outputs.tarball }}"
- name: Reverify immutable release artifact before publication
run: node scripts/create-release-artifact.mjs --out-dir "${{ steps.release-artifact.outputs.directory }}" --verify
- name: "Release diagnostics: coverage-risk"
run: npm run check:coverage-risk
continue-on-error: true
- name: "Release diagnostics: visual baseline capabilities"
run: npm run check:visual-baseline-capabilities
continue-on-error: true
- name: "Release diagnostics: deployment lockfile"
run: npm run check:cloud-run-lock
continue-on-error: true
- name: "Release diagnostics: benchmark comparison"
run: npm run bench:compare
continue-on-error: true
- name: Check whether this exact version is already published
id: published-artifact
run: |
VERSION=$(node -p "require('./package.json').version")
PUBLISHED_INTEGRITY=$(npm view "semiotic@${VERSION}" dist.integrity 2>/dev/null || true)
if [ -z "$PUBLISHED_INTEGRITY" ]; then
echo "already-published=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$PUBLISHED_INTEGRITY" != "${{ steps.release-artifact.outputs.integrity }}" ]; then
echo "semiotic@${VERSION} already exists with different integrity"
echo "Expected: ${{ steps.release-artifact.outputs.integrity }}"
echo "Published: $PUBLISHED_INTEGRITY"
exit 1
fi
echo "semiotic@${VERSION} already has the expected integrity; treating this as an idempotent rerun"
echo "already-published=true" >> "$GITHUB_OUTPUT"
- name: Dry-run publish exact artifact
if: steps.published-artifact.outputs.already-published != 'true'
run: npm publish "${{ steps.release-artifact.outputs.tarball }}" --dry-run --access public --tag ${{ steps.dist-tag.outputs.tag }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish exact artifact to npm
if: steps.published-artifact.outputs.already-published != 'true'
run: npm publish "${{ steps.release-artifact.outputs.tarball }}" --provenance --access public --tag ${{ steps.dist-tag.outputs.tag }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Post-publish smoke test
run: |
VERSION=$(node -p "require('./package.json').version")
TAG=${{ steps.dist-tag.outputs.tag }}
echo "Waiting for npm to propagate semiotic@${VERSION}..."
for i in 1 2 3 4 5; do
PUBLISHED=$(npm view semiotic@${VERSION} version 2>/dev/null || echo "")
if [ "$PUBLISHED" = "$VERSION" ]; then
echo "Package available on npm"
break
fi
echo "Attempt $i: not yet available, waiting 15s..."
sleep 15
done
if [ "$PUBLISHED" != "$VERSION" ]; then
echo "Package was not visible on npm registry after 75s"
exit 1
fi
EXPECTED_INTEGRITY="${{ steps.release-artifact.outputs.integrity }}"
PUBLISHED_INTEGRITY=$(npm view "semiotic@${VERSION}" dist.integrity)
if [ "$PUBLISHED_INTEGRITY" != "$EXPECTED_INTEGRITY" ]; then
echo "Published package integrity does not match the immutable release artifact"
echo "Expected: $EXPECTED_INTEGRITY"
echo "Published: $PUBLISHED_INTEGRITY"
exit 1
fi
echo "Published npm integrity matches the immutable release artifact"
# Install in a temp project and verify imports
TMPDIR=$(mktemp -d)
cd "$TMPDIR"
npm init -y > /dev/null 2>&1
npm install semiotic@${VERSION} react react-dom > /dev/null 2>&1
node -e "
const assert = require('assert');
// Main entry
const s = require('semiotic');
assert(s.BarChart, 'BarChart not exported from semiotic');
assert(s.LineChart, 'LineChart not exported from semiotic');
assert(s.ForceDirectedGraph, 'ForceDirectedGraph not exported from semiotic');
// Sub-path entries
const xy = require('semiotic/xy');
assert(xy.LineChart, 'LineChart not exported from semiotic/xy');
const ord = require('semiotic/ordinal');
assert(ord.BarChart, 'BarChart not exported from semiotic/ordinal');
const net = require('semiotic/network');
assert(net.SankeyDiagram, 'SankeyDiagram not exported from semiotic/network');
const rt = require('semiotic/realtime');
assert(rt.RealtimeLineChart, 'RealtimeLineChart not exported from semiotic/realtime');
const srv = require('semiotic/server');
assert(srv.renderChart, 'renderChart not exported from semiotic/server');
const themes = require('semiotic/themes');
assert(themes.resolveThemePreset, 'resolveThemePreset not exported from semiotic/themes');
const utils = require('semiotic/utils');
assert(utils.validateProps, 'validateProps not exported from semiotic/utils');
console.log('All smoke tests passed — 8 entry points verified');
"
rm -rf "$TMPDIR"
- name: Verify published package registry provenance metadata
env:
EXPECTED_INTEGRITY: ${{ steps.release-artifact.outputs.integrity }}
run: |
export VERSION=$(node -p "require('./package.json').version")
node - <<'NODE'
const { execSync } = require("node:child_process")
const packageName = "semiotic"
const version = process.env.VERSION
const expectedIntegrity = process.env.EXPECTED_INTEGRITY
const payload = execSync(`npm view ${packageName}@${version} --json`, {
encoding: "utf8",
})
const manifest = JSON.parse(payload)
const dist = manifest.dist || {}
if (dist.integrity !== expectedIntegrity) {
throw new Error(`Published dist.integrity (${dist.integrity}) does not match expected integrity (${expectedIntegrity})`)
}
const asArray = (value) => (Array.isArray(value) ? value : value ? [value] : [])
const signatures = asArray(dist.signatures)
const attestations = asArray(dist.attestations)
if (signatures.length === 0) {
throw new Error("Published package is missing dist.signatures")
}
if (attestations.length === 0) {
throw new Error("Published package is missing dist.attestations")
}
console.log(`Verified registry provenance for ${packageName}@${version}: ${signatures.length} signature(s), ${attestations.length} attestation(s)`)
NODE
- name: Create or update GitHub Release
# Pulls the matching CHANGELOG section so the GH release page
# mirrors the version's entry. Falls back to `--generate-notes`
# (auto-built from PR titles) if the section is missing or empty
# so a release page is still created either way. Without this
# step a successful npm publish leaves the GitHub Releases page
# stuck on the previous version (the v3.2.3 → v3.4.2 backfill
# in 2026-04-28 fixed exactly that gap).
#
# Idempotent on workflow re-runs: if the release for this tag
# already exists, `gh release edit` updates the title/notes in
# place rather than failing on the conflict. Without that, a
# re-run for any reason (transient infra failure, manually
# re-triggered after a fix) would error out at this step.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
NOTES_FILE="$(mktemp)"
awk -v ver="$VERSION" '
BEGIN { flag = 0 }
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
flag && /^## \[/ { exit }
flag { print }
' CHANGELOG.md > "$NOTES_FILE"
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
echo "Release $GITHUB_REF_NAME already exists; updating in place"
if [ -s "$NOTES_FILE" ]; then
gh release edit "$GITHUB_REF_NAME" \
--title "Semiotic $GITHUB_REF_NAME" \
--notes-file "$NOTES_FILE"
else
echo "No CHANGELOG section for $VERSION; leaving existing notes unchanged"
gh release edit "$GITHUB_REF_NAME" \
--title "Semiotic $GITHUB_REF_NAME"
fi
else
if [ -s "$NOTES_FILE" ]; then
gh release create "$GITHUB_REF_NAME" \
--title "Semiotic $GITHUB_REF_NAME" \
--notes-file "$NOTES_FILE" \
--verify-tag
else
echo "No CHANGELOG section for $VERSION; falling back to --generate-notes"
gh release create "$GITHUB_REF_NAME" \
--title "Semiotic $GITHUB_REF_NAME" \
--generate-notes \
--verify-tag
fi
fi
gh release upload "$GITHUB_REF_NAME" \
"${{ steps.release-artifact.outputs.directory }}"/* \
--clobber
rm -f "$NOTES_FILE"