diff --git a/.changeset/config.json b/.changeset/config.json index 1af1a48..568784b 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -4,8 +4,12 @@ "commit": false, "fixed": [], "linked": [], - "access": "restricted", + "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [], + "snapshot": { + "useCalculatedVersion": true, + "prereleaseTemplate": "{tag}.{datetime}" + } } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8aa02fc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,258 @@ +--- +name: Release + +# Publishes the package to npm. A module made from this template inherits both +# channels and turns them on by completing the setup in RELEASING.md. +# +# dev: every push to main with a pending changeset publishes a snapshot under +# the `dev` dist-tag. `latest` never moves, nothing is committed and no git tag +# is made. Install one with `@dev`. +# +# release: while changesets are pending, keeps a "version packages" pull +# request open against main. Merging it is the release decision: the push that +# follows finds a version npm does not have, publishes it to `latest`, tags it +# and creates a GitHub Release. +# +# Building and publishing are separate jobs. The build job runs repository +# code, install scripts included, so it holds no publishing rights: it ends by +# packing a tarball. The publish job holds the OIDC permission and runs nothing +# from the repository: it downloads that tarball and hands it to npm. +# +# Publishing authenticates through npm trusted publishing (OIDC), so there is +# no token. It stays off until the repository variable NPM_PUBLISH is `true`. +# Until then the packed tarball is the dry run. + +on: + push: + branches: [main] + pull_request: + branches: [main] + paths: + - .github/workflows/release.yml + - .changeset/config.json + - scripts/release-check.mjs + - package.json + workflow_dispatch: + +permissions: + contents: read + +# Never cancel a publish half way through. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + # Runs on pull requests too, as a rehearsal. No id-token here: a lifecycle + # script in a pull request must not be able to reach npm. + snapshot: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + count: ${{ steps.pending.outputs.count }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: .nvmrc + cache: npm + + - run: corepack enable && corepack prepare --activate + + - run: npm ci + + - name: Count pending changesets + id: pending + run: | + count=$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l) + echo "count=$count" >> "$GITHUB_OUTPUT" + echo "Pending changesets: $count" >> "$GITHUB_STEP_SUMMARY" + + # A snapshot cannot be cut in pre mode. The checkout is thrown away, so + # dropping the marker here changes nothing on the branch. + - name: Version the snapshot + if: steps.pending.outputs.count != '0' + run: | + rm -f .changeset/pre.json + npx changeset version --snapshot dev + + - name: Build + if: steps.pending.outputs.count != '0' + run: npm run build + + - name: Check the release + if: steps.pending.outputs.count != '0' + run: npm run release:check + + - name: Pack the tarball + if: steps.pending.outputs.count != '0' + run: | + mkdir dev-package + npm pack --pack-destination dev-package + node -p "const p = require('./package.json'); p.name + '@' + p.version" >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: steps.pending.outputs.count != '0' + with: + name: dev-package + path: dev-package + retention-days: 7 + if-no-files-found: error + + # No checkout and no install: nothing from the repository runs with the + # OIDC permission. Trusted publishing needs npm 11.5.1 or later, which needs + # a newer Node than the one the package builds on. + dev: + needs: snapshot + if: >- + github.event_name != 'pull_request' && + github.ref == 'refs/heads/main' && + vars.NPM_PUBLISH == 'true' && + needs.snapshot.outputs.count != '0' + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + # An exact version, and no install scripts: this job can publish. + - name: Update npm + run: npm install --global --ignore-scripts npm@11.19.1 + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dev-package + path: dev-package + + # The leading ./ matters: npm reads a bare "dir/file.tgz" as the GitHub + # shorthand "user/repo" and tries to clone it. + - name: Publish under the dev tag + run: | + npm publish ./dev-package/*.tgz --tag dev --access public + echo "Published under the dev tag." >> "$GITHUB_STEP_SUMMARY" + + # Opens or updates the version pull request, and packs a version npm does + # not have yet. It runs repository code, so it holds no publishing rights. + version: + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + tag: ${{ steps.unpublished.outputs.tag }} + version: ${{ steps.unpublished.outputs.version }} + steps: + # GitHub runs no checks on a pull request opened with the workflow's own + # token, and a protected main requires them. An app token gets them run. + # Without the app the pull request still opens, and its checks need a + # manual run. + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: app + if: vars.RELEASE_APP_ID != '' + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + # The install and the build run package code next. The pull request + # step below talks to the API with its own token, so git needs none. + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: .nvmrc + cache: npm + + - run: corepack enable && corepack prepare --activate + + - run: npm ci + + # No publish script: this step only opens or updates the pull request. + # The title is the squash commit's subject, so it has to be conventional. + - uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 + id: changesets + with: + github-token: ${{ steps.app.outputs.token || github.token }} + pr-title: 'chore(release): version packages' + commit-message: 'chore(release): version packages' + + - name: Find a version npm does not have + id: unpublished + if: steps.changesets.outputs.has-changesets == 'false' + run: | + tag=$(node scripts/release-check.mjs --unpublished) + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Build + if: steps.unpublished.outputs.tag != '' + run: npm run build + + - name: Check the release + if: steps.unpublished.outputs.tag != '' + run: npm run release:check + + - name: Pack the tarball + if: steps.unpublished.outputs.tag != '' + run: | + mkdir release-package + npm pack --pack-destination release-package + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: steps.unpublished.outputs.tag != '' + with: + name: release-package + path: release-package + retention-days: 30 + if-no-files-found: error + + # No checkout and no install: nothing from the repository runs with the + # OIDC permission. A rerun skips whatever an earlier attempt published. + release: + needs: version + if: needs.version.outputs.tag != '' && vars.NPM_PUBLISH == 'true' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + steps: + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + # An exact version, and no install scripts: this job can publish. + - name: Update npm + run: npm install --global --ignore-scripts npm@11.19.1 + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-package + path: release-package + + - name: Publish, tag and create the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.version.outputs.tag }} + VERSION: ${{ needs.version.outputs.version }} + run: | + if [ -n "$(npm view "$TAG" version 2>/dev/null)" ]; then + echo "$TAG is already on npm." + else + npm publish ./release-package/*.tgz --access public + fi + if ! gh release view "v$VERSION" > /dev/null 2>&1; then + gh release create "v$VERSION" --target "$GITHUB_SHA" --generate-notes + fi + echo "Published v$VERSION." >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index bd822ca..6505958 100644 --- a/README.md +++ b/README.md @@ -32,18 +32,18 @@ wholesale is worse than it looks, because you inherit this template's coverage f ## What you get -| | | -| ------------ | -------------------------------------------------------------------------------------------- | -| Build | siroc, producing ESM and SSR bundles | -| Unit tests | Jest, with an enforced coverage floor | -| Visual tests | Playwright against the example application, three viewports, committed baselines | -| Lint | ESLint, Prettier, markdownlint, cspell, yamllint, knip | -| Secrets | gitleaks, with a canary that proves the scanner still detects | -| Commits | Conventional Commits, checked by a hook and over the merge-request range | -| CI | GitLab and GitHub Actions, running the same set | -| Preview | A manual job serving the example application through a Cloudflare tunnel and posting the URL | -| Releases | Changesets | -| Environment | A devcontainer for VS Code, Codespaces and DevPod | +| | | +| ------------ | ---------------------------------------------------------------------------------------------------- | +| Build | siroc, producing ESM and SSR bundles | +| Unit tests | Jest, with an enforced coverage floor | +| Visual tests | Playwright against the example application, three viewports, committed baselines | +| Lint | ESLint, Prettier, markdownlint, cspell, yamllint, knip | +| Secrets | gitleaks, with a canary that proves the scanner still detects | +| Commits | Conventional Commits, checked by a hook and over the merge-request range | +| CI | GitLab and GitHub Actions, running the same set | +| Preview | A manual job serving the example application through a Cloudflare tunnel and posting the URL | +| Releases | Changesets, with `dev` snapshots and stable releases published from CI: [RELEASING.md](RELEASING.md) | +| Environment | A devcontainer for VS Code, Codespaces and DevPod | ## Commands diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..2aa9f03 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,61 @@ +# Releasing + +The package publishes to npm from `.github/workflows/release.yml`. Nobody runs `npm publish` by hand. A module made from this template inherits the workflow, and turns it on with the [one-time setup](#one-time-setup). + +| Channel | npm dist-tag | When it publishes | What it is for | +| ----------- | ------------ | --------------------------------------------------------- | ------------------------------------- | +| Development | `dev` | Every push to `main` with a pending changeset | Trying unreleased work on a real site | +| Stable | `latest` | When you merge the pull request that versions the package | Everyone else | + +## Development releases + +A push to `main` with a pending changeset cuts a snapshot and publishes it under the `dev` tag: + +```bash +npm install @dev +``` + +A snapshot version reads `0.1.0-dev.20260920005709`: the version the pending changesets add up to, then the tag and a timestamp. Nothing is committed, no git tag is made, and `latest` does not move. + +To follow the channel on a site, let Renovate track the tag: + +```json +{ + "packageRules": [{ "matchPackageNames": [""], "followTag": "dev" }] +} +``` + +## Stable releases + +1. Merge pull requests that carry a changeset. Add one with `npm run changeset`. +2. The workflow opens a pull request titled `chore(release): version packages`, and keeps it up to date. It holds the version bump and the changelog entry. +3. Merge that pull request when the release is ready. This is the release decision. +4. The push that follows publishes the new version to `latest`. It also pushes a `v` tag, with a GitHub Release. + +## Build and publish jobs + +Each channel runs as a build job followed by a publish job. + +| Job | Runs repository code | Can publish to npm | +| ------- | ----------------------------- | ------------------ | +| Build | Yes, install scripts included | No | +| Publish | No, it checks out nothing | Yes | + +The build job ends by packing a tarball, and the publish job hands that tarball to npm. A pull request rehearses the build job only, so code in a pull request never runs with publishing rights. + +## The pre-publish gate + +`npm run release:check` runs before every publish, on both channels. It refuses a release when: + +- a dependency uses a specifier that only resolves on the author's machine, such as `link:` or `workspace:` +- the version is at or below the one npm already has +- an entry in `files` was not built + +## One-time setup + +Publishing uses npm trusted publishing, so there is no npm token to store or rotate. Until the setup is complete the workflow stops after packing: the tarball it would have published is attached to the run as an artifact, and nothing reaches npm. + +1. Publish the first version by hand, from a clean build: `npm publish --access public`. A package that does not exist on npm yet cannot name a trusted publisher. +2. On the npm website, open the package, then **Settings**, then **Trusted publisher**. Choose GitHub Actions, and enter the organization, the repository and the workflow filename `release.yml`. Leave the environment empty. Under **Allowed actions**, tick **Allow npm publish**: the workflow publishes directly, and a publisher limited to staged publishing refuses it. +3. Create a GitHub App with read and write access to **Contents** and **Pull requests**, and install it on the repository. Store its ID as the repository variable `RELEASE_APP_ID` and its private key as the secret `RELEASE_APP_PRIVATE_KEY`. GitHub doesn't run checks on a pull request opened with the workflow's own token, so a protected `main` would never see them pass. +4. Set the repository variable `NPM_PUBLISH` to `true`. diff --git a/package.json b/package.json index 7e04bca..ad50f4b 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,7 @@ "main": "dist/index.ssr.js", "module": "dist/index.esm.js", "files": [ - "dist", - "templates" + "dist" ], "scripts": { "build": "siroc build", @@ -48,6 +47,7 @@ "lint:prose": "PROSE_LINT_GLOB='!{.changeset,example/drupal}/**' bash .gitlab/scripts/lint-prose.sh --all", "lint:prose:install": "bash .gitlab/scripts/install-vale.sh .vale/bin .vale/styles", "postinstall": "node scripts/postinstall.mjs", + "release:check": "node scripts/release-check.mjs", "serve": "node scripts/serve.js example/nuxt/dist 3000", "test": "jest", "test:e2e": "playwright test --grep-invert @visual", diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs new file mode 100644 index 0000000..19758ce --- /dev/null +++ b/scripts/release-check.mjs @@ -0,0 +1,218 @@ +/** + * Pre-publish gate: refuse a release that would publish a broken package. + * The release workflow runs it after versioning and building, before any + * publish. + * + * node scripts/release-check.mjs [--offline] [--skip-files] [--unpublished] + * + * --offline skip the npm registry comparison. + * --skip-files skip the built-files check, for when no build has run. + * --unpublished print `name@version` when npm does not have this version + * yet, and check nothing. + * + * It checks three things. A dependency specifier such as `link:` or + * `workspace:` resolves on the author's machine and nowhere else. A version + * at or below the published one either fails to publish or, as a snapshot, + * sorts below the release it was cut after. And a build that emits nothing + * still exits zero, so a `files` entry has to exist and hold something. + */ + +import fs from 'node:fs' +import https from 'node:https' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const FIELDS = ['dependencies', 'peerDependencies', 'optionalDependencies'] +const UNPUBLISHABLE = + /^(workspace|link|file|portal|git|git\+ssh|git\+https|github|https?):/ +const REGISTRY_TIMEOUT = 15000 +const VERSION = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/ + +/** + * Splits a version into its numeric core and whether it is a prerelease. + * + * @param {string} version - The version to parse. + * @returns {object|null} `{ core, prerelease }`, or `null` when it is invalid. + */ +export function parseVersion(version) { + const match = VERSION.exec(version) + if (!match) return null + return { + core: match.slice(1, 4).map(Number), + prerelease: Boolean(match[4]), + } +} + +// Compares two numeric cores: negative, zero or positive, as a sort would. +const compareCores = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2] + +/** + * Checks a manifest against the rules. + * + * @param {object} options - The check input. + * @param {object} options.manifest - The parsed package.json. + * @param {string} options.dir - The package directory. + * @param {object|null} [options.record] - `{ latest, versions }` from npm, or `null` when unpublished. Omit to skip the registry rule. + * @param {boolean} [options.files] - Whether to check the `files` entries. + * @returns {string[]} One message per problem. + */ +export function checkManifest({ manifest, dir, record, files = true }) { + const problems = [] + const parsed = parseVersion(manifest.version) + + if (!parsed) { + return [`"${manifest.version}" is not a valid version.`] + } + + for (const field of FIELDS) { + for (const [dep, range] of Object.entries(manifest[field] || {})) { + if (UNPUBLISHABLE.test(range)) { + problems.push( + `${field}.${dep} is "${range}", which does not resolve from npm.` + ) + } + } + } + + if (record && !record.versions.includes(manifest.version)) { + const latest = parseVersion(record.latest) + const order = latest ? compareCores(parsed.core, latest.core) : 1 + if ( + order < 0 || + (order === 0 && (parsed.prerelease || !latest.prerelease)) + ) { + problems.push( + `${manifest.version} does not rise above the published ${record.latest}.` + ) + } + } + + if (files) { + // Without a list npm packs the whole directory, and nothing here could + // say whether the build output is in it. + if (!Array.isArray(manifest.files) || manifest.files.length === 0) { + problems.push('package.json has no "files" list to check the build by.') + } + for (const entry of manifest.files || []) { + const target = path.join(dir, entry) + if (!fs.existsSync(target)) { + problems.push(`files entry "${entry}" does not exist. Build first.`) + } else if ( + fs.statSync(target).isDirectory() && + fs.readdirSync(target).length === 0 + ) { + problems.push(`files entry "${entry}" is empty.`) + } + } + } + + return problems +} + +/** + * Fetches the published versions of a package. + * + * @param {string} name - The package name. + * @returns {Promise} `{ latest, versions }`, or `null` when npm has no such package. + */ +export function fetchRecord(name) { + const url = `https://registry.npmjs.org/${name.replace(/\//g, '%2f')}` + const headers = { accept: 'application/vnd.npm.install-v1+json' } + + return new Promise((resolve, reject) => { + const request = https.get( + url, + { headers, timeout: REGISTRY_TIMEOUT }, + (response) => { + if (response.statusCode === 404) { + response.resume() + return resolve(null) + } + if (response.statusCode !== 200) { + response.resume() + return reject( + new Error(`npm answered ${response.statusCode} for ${name}.`) + ) + } + + let body = '' + response.setEncoding('utf8') + response.on('data', (chunk) => { + body += chunk + }) + response.on('end', () => { + const data = JSON.parse(body) + resolve({ + latest: data['dist-tags'].latest, + versions: Object.keys(data.versions), + }) + }) + } + ) + + // Without this a stalled connection holds the job until its own timeout. + request.on('timeout', () => + request.destroy( + new Error(`npm did not answer for ${name} within 15 seconds.`) + ) + ) + request.on('error', reject) + }) +} + +export async function main(argv = process.argv.slice(2)) { + const flags = ['--offline', '--skip-files', '--unpublished'] + const options = argv.filter((arg) => arg.startsWith('--')) + const unknown = options.filter( + (arg) => !flags.includes(arg) && !arg.startsWith('--registry-file=') + ) + if (unknown.length) throw new Error(`Unknown option: ${unknown.join(', ')}`) + + const dir = path.resolve(argv.find((arg) => !arg.startsWith('--')) ?? ROOT) + const manifest = JSON.parse( + fs.readFileSync(path.join(dir, 'package.json'), 'utf8') + ) + if (manifest.private) { + console.log('release-check: the package is private, nothing to publish.') + return + } + + // A recorded registry answer, so the tests never depend on the network. + const recorded = options.find((arg) => arg.startsWith('--registry-file=')) + let record + if (recorded) { + record = JSON.parse(fs.readFileSync(recorded.split('=')[1], 'utf8')) + } else if (!options.includes('--offline')) { + record = await fetchRecord(manifest.name) + } + + if (options.includes('--unpublished')) { + if (record === undefined) { + throw new Error('--unpublished needs the registry, so not --offline.') + } + if (!record || !record.versions.includes(manifest.version)) { + console.log(`${manifest.name}@${manifest.version}`) + } + return + } + + const problems = checkManifest({ + manifest, + dir, + record, + files: !options.includes('--skip-files'), + }) + for (const problem of problems) console.error(`release-check: ${problem}`) + if (problems.length) process.exit(1) + console.log( + `release-check: ${manifest.name}@${manifest.version} can publish.` + ) +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + main().catch((error) => { + console.error(`release-check: ${error.message}`) + process.exit(1) + }) +} diff --git a/test/release-check.test.js b/test/release-check.test.js new file mode 100644 index 0000000..26a540d --- /dev/null +++ b/test/release-check.test.js @@ -0,0 +1,160 @@ +const { spawnSync } = require('node:child_process') +const fs = require('node:fs') +const os = require('node:os') +const { join } = require('node:path') + +// The gate is an ES module and the package is CommonJS, so it runs in a real +// node process here, which is also how the release workflow runs it. +const SCRIPT = join(__dirname, '..', 'scripts', 'release-check.mjs') + +let dir + +const write = (manifest, record) => { + fs.writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + if (record !== undefined) { + fs.writeFileSync(join(dir, 'registry.json'), JSON.stringify(record)) + } +} + +const run = (...flags) => { + const registry = fs.existsSync(join(dir, 'registry.json')) + ? [`--registry-file=${join(dir, 'registry.json')}`] + : ['--offline'] + const result = spawnSync( + process.execPath, + [SCRIPT, dir, ...registry, ...flags], + { encoding: 'utf8' } + ) + return { status: result.status, out: result.stdout + result.stderr } +} + +beforeEach(() => { + dir = fs.mkdtempSync(join(os.tmpdir(), 'release-check-')) +}) + +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }) +}) + +describe('release-check', () => { + test('passes a publishable package', () => { + write({ name: 'x', version: '1.0.0', dependencies: { druxt: '^0.24.0' } }) + expect(run('--skip-files')).toEqual({ + status: 0, + out: 'release-check: x@1.0.0 can publish.\n', + }) + }) + + test.each(['workspace:*', 'link:../druxt', 'file:../druxt'])( + 'refuses the specifier %s', + (range) => { + write({ name: 'x', version: '1.0.0', peerDependencies: { druxt: range } }) + const result = run('--skip-files') + expect(result.status).toBe(1) + expect(result.out).toContain('does not resolve from npm') + } + ) + + test('refuses an invalid version', () => { + write({ name: 'x', version: '1.0' }) + expect(run('--skip-files').out).toContain('is not a valid version') + }) + + test('refuses a version below the published one', () => { + write( + { name: 'x', version: '1.1.9' }, + { latest: '1.2.0', versions: ['1.2.0'] } + ) + const result = run('--skip-files') + expect(result.status).toBe(1) + expect(result.out).toContain('does not rise above the published 1.2.0') + }) + + test('refuses a snapshot of an already published version', () => { + write( + { name: 'x', version: '1.2.0-dev.20260920004838' }, + { latest: '1.2.0', versions: ['1.2.0'] } + ) + expect(run('--skip-files').status).toBe(1) + }) + + test('passes a snapshot above the published version', () => { + write( + { name: 'x', version: '1.2.1-dev.20260920004838' }, + { latest: '1.2.0', versions: ['1.2.0'] } + ) + expect(run('--skip-files').status).toBe(0) + }) + + test('passes a stable release over its own prerelease', () => { + write( + { name: 'x', version: '2.0.0' }, + { latest: '2.0.0-beta.1', versions: ['2.0.0-beta.1'] } + ) + expect(run('--skip-files').status).toBe(0) + }) + + test('passes an already published version, because publish skips it', () => { + write( + { name: 'x', version: '1.2.0' }, + { latest: '1.2.0', versions: ['1.2.0'] } + ) + expect(run('--skip-files').status).toBe(0) + }) + + test('passes a package npm has never seen', () => { + write({ name: 'x', version: '0.0.1' }, null) + expect(run('--skip-files').status).toBe(0) + }) + + test('refuses a files entry that was not built, or is empty', () => { + write({ name: 'x', version: '1.0.0', files: ['dist', 'templates'] }) + fs.mkdirSync(join(dir, 'dist')) + const result = run() + expect(result.status).toBe(1) + expect(result.out).toContain('files entry "dist" is empty') + expect(result.out).toContain('files entry "templates" does not exist') + + fs.writeFileSync(join(dir, 'dist', 'index.js'), '') + fs.mkdirSync(join(dir, 'templates')) + fs.writeFileSync(join(dir, 'templates', 'plugin.js'), '') + expect(run().status).toBe(0) + }) + + test.each([undefined, []])('refuses a files list of %p', (list) => { + write({ name: 'x', version: '1.0.0', files: list }) + const result = run() + expect(result.status).toBe(1) + expect(result.out).toContain('has no "files" list') + expect(run('--skip-files').status).toBe(0) + }) + + test('skips a private package', () => { + write({ name: 'x', version: 'nope', private: true }) + expect(run()).toEqual({ + status: 0, + out: 'release-check: the package is private, nothing to publish.\n', + }) + }) + + test('--unpublished prints the version only when npm lacks it', () => { + write( + { name: 'x', version: '1.2.1' }, + { latest: '1.2.0', versions: ['1.2.0'] } + ) + expect(run('--unpublished').out).toBe('x@1.2.1\n') + + write( + { name: 'x', version: '1.2.0' }, + { latest: '1.2.0', versions: ['1.2.0'] } + ) + expect(run('--unpublished').out).toBe('') + }) + + test('refuses an unknown option', () => { + write({ name: 'x', version: '1.0.0' }) + const result = run('--nope') + expect(result.status).toBe(1) + expect(result.out).toContain('Unknown option: --nope') + }) +})