diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a84de10 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + dev-dependencies: + dependency-type: development + ignore: + # TypeScript 7 ships no tsserver.js, which editors and typescript-language-server still need. + - dependency-name: typescript + update-types: ["version-update:semver-major"] + # @types/node tracks the oldest Node version CI tests (22). + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + actions: + patterns: ["*"] diff --git a/.github/scripts/changelog-section.mjs b/.github/scripts/changelog-section.mjs new file mode 100644 index 0000000..6f51fae --- /dev/null +++ b/.github/scripts/changelog-section.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Prints the CHANGELOG.md section for a version (used as GitHub Release notes). +// Fails when the section is missing or still marked "Unreleased", so a release cannot ship undated notes. +import { readFileSync } from 'node:fs'; + +const version = process.argv[2]; +if (!version) { + console.error('Usage: changelog-section.mjs '); + process.exit(2); +} + +const lines = readFileSync(new URL('../../CHANGELOG.md', import.meta.url), 'utf8').split('\n'); +const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const start = lines.findIndex((line) => new RegExp(`^## ${escaped}(\\s|$)`).test(line)); + +if (start === -1) { + console.error(`CHANGELOG.md has no "## ${version}" section.`); + process.exit(1); +} +if (/unreleased/i.test(lines[start] ?? '')) { + console.error(`CHANGELOG.md section "${lines[start]}" is still marked Unreleased; replace it with the release date.`); + process.exit(1); +} + +const end = lines.findIndex((line, index) => index > start && line.startsWith('## ')); +const body = lines.slice(start + 1, end === -1 ? undefined : end).join('\n').trim(); +process.stdout.write(`${body}\n`); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..840b3b2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_call: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (${{ matrix.os }}, Node ${{ matrix.node }}) + # GitHub only: the Gitea origin also runs .github/workflows but has no macOS/Windows runners. + if: github.server_url == 'https://github.com' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: [22, 24, 26] + include: + - os: macos-latest + node: 24 + - os: windows-latest + node: 24 + env: + HUSKY: "0" + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm test + - name: Check package contents + run: npm pack --dry-run diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..5b5c8a3 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,45 @@ +name: Pages + +on: + push: + branches: [main] + paths: + - "site/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build site + # GitHub only: the Gitea origin also runs .github/workflows but has no Pages; deploy skips with this job. + if: github.server_url == 'https://github.com' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/configure-pages@v6 + - name: Stage site without source notes + run: rsync -a --exclude='*.md' site/ _site/ + - uses: actions/upload-pages-artifact@v5 + with: + path: _site/ + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..33e74bc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +# Push a vX.Y.Z tag after bumping package.json and dating the CHANGELOG section. +# Publishing uses npm trusted publishing (OIDC): no npm token is stored in this repository. + +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + name: Verify + # GitHub only: npm trusted publishing needs GitHub's OIDC token; later jobs skip with this one. + if: github.server_url == 'https://github.com' + uses: ./.github/workflows/ci.yml + + publish: + name: Publish to npm + needs: verify + runs-on: ubuntu-latest + environment: npm + permissions: + contents: read + id-token: write + env: + HUSKY: "0" + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + - name: Use npm 12 (trusted publishing needs 11.5.1 or later) + run: npm install -g npm@12 + - name: Check tag, package.json, and CHANGELOG agree + run: | + version="${GITHUB_REF_NAME#v}" + package_version="$(node -p "require('./package.json').version")" + if [ "$version" != "$package_version" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match package.json version $package_version" + exit 1 + fi + node .github/scripts/changelog-section.mjs "$version" > /dev/null + - run: npm ci + - run: npm run build + - name: Publish + run: | + version="${GITHUB_REF_NAME#v}" + if [[ "$version" == *-* ]]; then + npm publish --access public --tag next + else + npm publish --access public + fi + + github-release: + name: GitHub Release + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + - name: Create release from CHANGELOG + env: + GH_TOKEN: ${{ github.token }} + run: | + version="${GITHUB_REF_NAME#v}" + node .github/scripts/changelog-section.mjs "$version" > release-notes.md + prerelease="" + if [[ "$version" == *-* ]]; then prerelease="--prerelease"; fi + gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --notes-file release-notes.md $prerelease