diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..de378d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - run: npm test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..847c1e9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,166 @@ +name: Publish + +on: + push: + branches: [main] + paths: + - "base.json" + - "nodejs.json" + - "node-library.json" + - "react.json" + - "nextjs.json" + - "package.json" + - ".github/workflows/publish.yml" + workflow_dispatch: + inputs: + bump: + description: "Version bump type" + required: true + type: choice + options: + - patch + - minor + - major + +concurrency: + group: publish-${{ github.event_name }} + cancel-in-progress: true + +permissions: + contents: write + id-token: write + +jobs: + quality: + name: Quality Gates + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - run: npm test + + canary: + name: Publish Canary + needs: quality + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Upgrade npm for OIDC support + run: npm install -g npm@latest + + - name: Publish canary + run: | + sed -i '/_authToken/d' "$NPM_CONFIG_USERCONFIG" + unset NODE_AUTH_TOKEN + + BASE_VERSION=$(node -p "require('./package.json').version") + SHORT_SHA=$(echo "$GITHUB_SHA" | cut -c1-7) + CANARY_VERSION="${BASE_VERSION}-canary.${SHORT_SHA}" + npm version "$CANARY_VERSION" --no-git-tag-version + npm publish --tag canary --provenance --access public + + release: + name: Publish Release + needs: quality + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PAT }} + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Bump version + id: version + run: | + npm version ${{ inputs.bump }} --no-git-tag-version + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Generate changelog + id: changelog + run: | + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -z "$LAST_TAG" ]; then + RANGE="HEAD" + else + RANGE="${LAST_TAG}..HEAD" + fi + + { + echo "body</dev/null || true) + if [ -n "$FEATS" ]; then + echo "### Features" + echo "$FEATS" | sed 's/^/- /' + echo "" + fi + + FIXES=$(git log "$RANGE" --pretty=format:"%s" --grep="^fix" 2>/dev/null || true) + if [ -n "$FIXES" ]; then + echo "### Bug Fixes" + echo "$FIXES" | sed 's/^/- /' + echo "" + fi + + OTHERS=$(git log "$RANGE" --pretty=format:"%s" --invert-grep --grep="^feat" --grep="^fix" 2>/dev/null || true) + if [ -n "$OTHERS" ]; then + echo "### Other Changes" + echo "$OTHERS" | sed 's/^/- /' + echo "" + fi + + echo "CHANGELOG_EOF" + } >> "$GITHUB_OUTPUT" + + - name: Commit and tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package.json + git commit -m "chore(release): v${{ steps.version.outputs.version }}" + git tag -a "v${{ steps.version.outputs.version }}" -m "v${{ steps.version.outputs.version }}" + git push origin main --follow-tags + + - name: Upgrade npm for OIDC support + run: npm install -g npm@latest + + - name: Publish to npm + run: | + sed -i '/_authToken/d' "$NPM_CONFIG_USERCONFIG" + unset NODE_AUTH_TOKEN + npm publish --tag latest --provenance --access public + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cat <<'NOTES_EOF' > /tmp/release-notes.md + ${{ steps.changelog.outputs.body }} + NOTES_EOF + gh release create "v${{ steps.version.outputs.version }}" \ + --title "v${{ steps.version.outputs.version }}" \ + --notes-file /tmp/release-notes.md diff --git a/CLAUDE.md b/CLAUDE.md index 6b0e54d..21e27ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,15 +22,12 @@ git push && git push --tags ## Publishing (CI) -Push a version tag — CI handles the rest: +Two modes via `publish.yml`: -```sh -npm version patch -git push && git push --tags -# release.yml: npm publish --provenance → GitHub Release -``` +- **Canary**: auto-publishes on push to main (when config files change) as `x.y.z-canary.` on `canary` tag +- **Release**: manual trigger via Actions → Publish → workflow_dispatch, select bump type (patch/minor/major) -Requires `NPM_TOKEN` secret in GitHub repo settings. +Uses npm OIDC provenance — no `NPM_TOKEN` secret needed. Requires npm trusted publishers configured on npmjs.org. ## Project structure @@ -46,5 +43,6 @@ nextjs.json ← Next.js apps (Bundler, ES2022, noEmit, next plugin) | Command | What | |---------|------| +| `npm test` | Run smoke tests (JSON validation) | | `npm pack --dry-run` | Preview tarball contents | | `npm view @vllnt/typescript` | Check published version | diff --git a/package.json b/package.json index dbc9d8e..ea442a8 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,10 @@ "node-library.json", "react.json" ], + "scripts": { + "test": "node --test tests/smoke.test.js", + "prepublishOnly": "node --test tests/smoke.test.js" + }, "publishConfig": { "access": "public" } diff --git a/tests/smoke.test.js b/tests/smoke.test.js new file mode 100644 index 0000000..bc1f4a6 --- /dev/null +++ b/tests/smoke.test.js @@ -0,0 +1,35 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const { readFileSync, readdirSync } = require('node:fs') +const { join } = require('node:path') + +const root = join(__dirname, '..') + +const configs = readdirSync(root).filter( + (f) => f.endsWith('.json') && f !== 'package.json', +) + +test('all config files are valid JSON with compilerOptions', () => { + for (const config of configs) { + const raw = readFileSync(join(root, config), 'utf-8') + const parsed = JSON.parse(raw) + assert.ok(parsed.compilerOptions, `${config} must have compilerOptions`) + assert.equal(typeof parsed.compilerOptions, 'object') + } +}) + +test('derived configs extend base.json', () => { + const derived = configs.filter((c) => c !== 'base.json') + for (const config of derived) { + const raw = readFileSync(join(root, config), 'utf-8') + const parsed = JSON.parse(raw) + assert.equal(parsed.extends, './base.json', `${config} must extend base.json`) + } +}) + +test('package.json files array includes all configs', () => { + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')) + for (const config of configs) { + assert.ok(pkg.files.includes(config), `${config} must be in package.json files`) + } +})