Merge pull request #1112 from nteract/align-ssr #98
Workflow file for this run
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 | |
| 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" |