From 14bcbb328d9e911b8512eef0c25c145d32203fc9 Mon Sep 17 00:00:00 2001 From: lookinway Date: Fri, 22 May 2026 20:34:15 +0300 Subject: [PATCH 1/2] ci(release): changelog-driven publish gate; sync releases.json with npm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishing of CLI, n8n and generator now goes through scripts/check-release.mjs: publish only when the version declared in apps/docs/data/releases.json is not yet on npm, the version matches the library's format rule (CLI = CalVer YYYY.M.patch, others = semver), is strictly greater than the latest published version, the package code changed, and (CLI/n8n) the version is in the package's own changelog. Idempotent npm publish (skip if already published) — no more auto-bump that silently re-released identical code (CLI 5.5/5.6, generator 1.1.3/1.1.4). SDK is unchanged: its version comes from typespec.tsp and its publish was already idempotent. Also reconcile releases.json + cli changelog.json with what actually shipped: add CLI 2026.5.6 (examples in `pachca api ... --describe` output). n8n/sdk are already in sync; generator's extra patches lack a recoverable changelog and are left as-is (the gate skips them since they're already on npm). --- .github/workflows/generator.yml | 51 +++------ .github/workflows/n8n.yml | 82 +++++---------- .github/workflows/sdk.yml | 66 ++++-------- apps/docs/data/releases.json | 12 +++ packages/cli/src/data/changelog.json | 11 ++ scripts/check-release.mjs | 150 +++++++++++++++++++++++++++ 6 files changed, 235 insertions(+), 137 deletions(-) create mode 100644 scripts/check-release.mjs diff --git a/.github/workflows/generator.yml b/.github/workflows/generator.yml index 7c4ff4da..86aaa24d 100644 --- a/.github/workflows/generator.yml +++ b/.github/workflows/generator.yml @@ -48,19 +48,21 @@ jobs: if: github.event_name == 'push' runs-on: ubuntu-latest outputs: - generator_changed: ${{ steps.check.outputs.changed }} + should_publish: ${{ steps.check.outputs.should_publish }} + version: ${{ steps.check.outputs.version }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Check for generator/parser changes + - uses: actions/setup-node@v4 + with: + node-version: 20.20.2 + - name: Check generator release (changelog-driven) id: check - run: | - CHANGED=$(git diff --name-only HEAD~1 HEAD -- packages/generator/ packages/openapi-parser/ | grep -q . && echo true || echo false) - echo "changed=$CHANGED" >> $GITHUB_OUTPUT + run: node scripts/check-release.mjs --product generator --npm @pachca/generator --dir "packages/generator,packages/openapi-parser" >> "$GITHUB_OUTPUT" publish-generator: - if: github.event_name == 'push' && needs.check-changes.outputs.generator_changed == 'true' + if: github.event_name == 'push' && needs.check-changes.outputs.should_publish == 'true' needs: [snapshot-tests, check-changes] runs-on: ubuntu-latest concurrency: @@ -88,40 +90,21 @@ jobs: working-directory: packages/generator run: bun run build - - name: Set version (auto-increment patch) - id: version + - name: Set version (from releases.json) working-directory: packages/generator run: | - VERSION=$(node -e " - const { execSync } = require('child_process'); - const base = require('./package.json').version.split('.').slice(0, 2).join('.'); - let versions = []; - try { - const raw = execSync('npm view @pachca/generator versions --json', { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); - const parsed = JSON.parse(raw); - versions = Array.isArray(parsed) ? parsed : [parsed]; - } catch {} - const matching = versions.filter(v => v.startsWith(base + '.')); - const lastPatch = matching.length > 0 - ? Math.max(...matching.map(v => parseInt(v.split('.')[2]))) - : -1; - console.log(base + '.' + (lastPatch + 1)); - ") - echo "version=$VERSION" >> $GITHUB_OUTPUT + VERSION="${{ needs.check-changes.outputs.version }}" node -e "const fs=require('fs'),p=JSON.parse(fs.readFileSync('package.json'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" echo "Publishing @pachca/generator@$VERSION" - - name: Publish to npm + - name: Publish to npm (idempotent) working-directory: packages/generator env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - OUTPUT=$(npm publish --access public --provenance 2>&1) || { - CODE=$? - if echo "$OUTPUT" | grep -q "E409\|already exists"; then - echo "Version ${{ steps.version.outputs.version }} already published, skipping" - else - echo "$OUTPUT" - exit $CODE - fi - } + VERSION="${{ needs.check-changes.outputs.version }}" + if npm view "@pachca/generator@${VERSION}" version 2>/dev/null; then + echo "@pachca/generator@${VERSION} already published, skipping" + else + npm publish --access public --provenance + fi diff --git a/.github/workflows/n8n.yml b/.github/workflows/n8n.yml index acfb193e..8b4ab27c 100644 --- a/.github/workflows/n8n.yml +++ b/.github/workflows/n8n.yml @@ -116,83 +116,55 @@ jobs: - name: Generate n8n node run: bun run integrations/n8n/scripts/generate-n8n.ts - - name: Check for publishable changes + - name: Check n8n release (changelog-driven) id: changes run: | - # workflow_dispatch always publishes (manual trigger = intentional) + OUT=$(node scripts/check-release.mjs --product n8n --npm n8n-nodes-pachca \ + --dir integrations/n8n --changelog integrations/n8n/CHANGELOG.md --changelog-type md) + VERSION=$(echo "$OUT" | grep '^version=' | cut -d= -f2) + SHOULD=$(echo "$OUT" | grep '^should_publish=' | cut -d= -f2) + # Manual dispatch forces a publish of the declared version. if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "changed=true" >> $GITHUB_OUTPUT - echo "Manual dispatch — forcing publish" - exit 0 - fi - # Check committed source changes (exclude tests, scripts, docs, config) - COMMITTED=$(git diff --name-only HEAD~1 HEAD -- integrations/n8n/ \ - | grep -v -E '^integrations/n8n/(tests/|scripts/|docs/|e2e/|eslint|tsconfig|\.gitignore|\.npmrc|vitest)' \ - | grep -q . && echo true || echo false) - # Check if generation produced different files (spec/generator changed) - GENERATED=$(git diff --name-only -- integrations/n8n/nodes/ integrations/n8n/credentials/ \ - | grep -q . && echo true || echo false) - if [ "$COMMITTED" = "true" ] || [ "$GENERATED" = "true" ]; then - echo "changed=true" >> $GITHUB_OUTPUT - echo "Publishable changes detected (committed=$COMMITTED, generated=$GENERATED)" - else - echo "changed=false" >> $GITHUB_OUTPUT - echo "No publishable changes, skipping publish" + echo "Manual dispatch — forcing publish of $VERSION" + SHOULD=true fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "should_publish=$SHOULD" >> "$GITHUB_OUTPUT" - - name: Set version (auto-increment patch) - if: steps.changes.outputs.changed == 'true' - id: version + - name: Set version (from releases.json) + if: steps.changes.outputs.should_publish == 'true' working-directory: integrations/n8n run: | - VERSION=$(node -e " - const { execSync } = require('child_process'); - const base = require('./package.json').version.split('.').slice(0, 2).join('.'); - let versions = []; - try { - const raw = execSync('npm view n8n-nodes-pachca versions --json', { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); - const parsed = JSON.parse(raw); - versions = Array.isArray(parsed) ? parsed : [parsed]; - } catch {} - const matching = versions.filter(v => v.startsWith(base + '.')); - const lastPatch = matching.length > 0 - ? Math.max(...matching.map(v => parseInt(v.split('.')[2]))) - : -1; - console.log(base + '.' + (lastPatch + 1)); - ") - echo "version=$VERSION" >> $GITHUB_OUTPUT + VERSION="${{ steps.changes.outputs.version }}" node -e "const fs=require('fs'),p=JSON.parse(fs.readFileSync('package.json'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" echo "Publishing n8n-nodes-pachca@$VERSION" - name: Build n8n node - if: steps.changes.outputs.changed == 'true' + if: steps.changes.outputs.should_publish == 'true' working-directory: integrations/n8n run: | bun run tsc find nodes icons credentials \( -name '*.png' -o -name '*.svg' \) | while read f; do mkdir -p "dist/$(dirname "$f")" && cp "$f" "dist/$f"; done - - name: Publish to npm - if: steps.changes.outputs.changed == 'true' + - name: Publish to npm (idempotent) + if: steps.changes.outputs.should_publish == 'true' working-directory: integrations/n8n env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - OUTPUT=$(npm publish --access public --provenance --ignore-scripts 2>&1) || { - CODE=$? - if echo "$OUTPUT" | grep -q "E409\|already exists"; then - echo "Version ${{ steps.version.outputs.version }} already published, skipping" - else - echo "$OUTPUT" - exit $CODE - fi - } + VERSION="${{ steps.changes.outputs.version }}" + if npm view "n8n-nodes-pachca@${VERSION}" version 2>/dev/null; then + echo "n8n-nodes-pachca@${VERSION} already published, skipping" + else + npm publish --access public --provenance --ignore-scripts + fi - name: Scan community package - if: steps.changes.outputs.changed == 'true' - run: npx @n8n/scan-community-package n8n-nodes-pachca@${{ steps.version.outputs.version }} + if: steps.changes.outputs.should_publish == 'true' + run: npx @n8n/scan-community-package n8n-nodes-pachca@${{ steps.changes.outputs.version }} - name: Create GitHub Release - if: steps.changes.outputs.changed == 'true' + if: steps.changes.outputs.should_publish == 'true' working-directory: integrations/n8n env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -202,8 +174,8 @@ jobs: tar -xzf n8n-nodes-pachca-*.tgz --strip-components=1 -C n8n-nodes-pachca rm n8n-nodes-pachca-*.tgz tar -czf n8n-nodes-pachca.tgz n8n-nodes-pachca - gh release create "n8n-v${{ steps.version.outputs.version }}" \ + gh release create "n8n-v${{ steps.changes.outputs.version }}" \ n8n-nodes-pachca.tgz \ - --title "n8n-nodes-pachca v${{ steps.version.outputs.version }}" \ + --title "n8n-nodes-pachca v${{ steps.changes.outputs.version }}" \ --notes "See [CHANGELOG](integrations/n8n/CHANGELOG.md) for details." \ --latest diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 3660da5b..6afab494 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -26,7 +26,8 @@ jobs: python_changed: ${{ steps.sdk_changes.outputs.python_changed }} csharp_changed: ${{ steps.sdk_changes.outputs.csharp_changed }} swift_changed: ${{ steps.sdk_changes.outputs.swift_changed }} - cli_changed: ${{ steps.cli_check.outputs.changed }} + cli_should_publish: ${{ steps.cli_check.outputs.should_publish }} + cli_version: ${{ steps.cli_check.outputs.version }} steps: - uses: actions/checkout@v4 with: @@ -76,21 +77,10 @@ jobs: echo "any_changed=false" >> $GITHUB_OUTPUT fi - - name: Check for CLI changes + - name: Check CLI release (changelog-driven) if: github.event_name == 'push' id: cli_check - run: | - PREV_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "") - if [ -n "$PREV_TAG" ]; then - CHANGED=$(git diff --name-only "$PREV_TAG" HEAD -- packages/cli/ | grep -v '^packages/cli/scripts/' | grep -q . && echo true || echo false) - else - CHANGED=$(git diff --name-only HEAD~1 HEAD -- packages/cli/ | grep -v '^packages/cli/scripts/' | grep -q . && echo true || echo false) - fi - # Also check uncommitted changes from generate - if git diff --name-only -- packages/cli/ | grep -v '^packages/cli/scripts/' | grep -q .; then - CHANGED=true - fi - echo "changed=$CHANGED" >> $GITHUB_OUTPUT + run: node scripts/check-release.mjs --product cli --npm @pachca/cli --dir packages/cli --changelog packages/cli/src/data/changelog.json --changelog-type json --version-rule calver >> "$GITHUB_OUTPUT" - name: Build TypeScript SDK if: github.event_name != 'pull_request' || steps.sdk_changes.outputs.ts_changed == 'true' @@ -231,7 +221,7 @@ jobs: fi publish-cli: - if: github.event_name == 'push' && needs.generate-and-build.outputs.cli_changed == 'true' + if: github.event_name == 'push' && needs.generate-and-build.outputs.cli_should_publish == 'true' needs: generate-and-build runs-on: ubuntu-latest concurrency: @@ -252,46 +242,26 @@ jobs: node-version: 20.20.2 registry-url: "https://registry.npmjs.org" - run: bun install --frozen-lockfile - - name: Set CLI version (CalVer YYYY.M.patch) - id: version + - name: Set CLI version (from changelog/releases.json) run: | - VERSION=$(node -e " - const { execSync } = require('child_process'); - const d = new Date(); - const base = d.getFullYear() + '.' + (d.getMonth()+1); - let versions = []; - try { - const raw = execSync('npm view @pachca/cli versions --json', { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); - const parsed = JSON.parse(raw); - versions = Array.isArray(parsed) ? parsed : [parsed]; - } catch {} - const patch = versions.filter(v => v.startsWith(base + '.')).length; - console.log(base + '.' + patch); - ") - echo "version=$VERSION" >> $GITHUB_OUTPUT + # Version comes from the release gate (top of changelog.json / + # releases.json), not an npm auto-bump. CHANGELOG.md is rendered from + # changelog.json by patch-manifest.js during build. + VERSION="${{ needs.generate-and-build.outputs.cli_version }}" cd packages/cli && node -e "const fs=require('fs'),p=JSON.parse(fs.readFileSync('package.json'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" - - name: Generate changelog - run: bun scripts/diff-endpoints.ts - working-directory: packages/cli - run: bun run build working-directory: packages/cli - - working-directory: packages/cli + - name: Publish to npm (idempotent) + working-directory: packages/cli env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - OUTPUT=$(npm publish --access public --provenance 2>&1) || { - CODE=$? - if echo "$OUTPUT" | grep -q "E409\|already exists"; then - CURRENT=$(node -e "console.log(require('./package.json').version)") - PATCH=$(echo $CURRENT | cut -d. -f3) - BASE=$(echo $CURRENT | cut -d. -f1-2) - node -e "const fs=require('fs'),p=JSON.parse(fs.readFileSync('package.json'));p.version='${BASE}.$((PATCH+1))';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" - npm publish --access public --provenance - else - echo "$OUTPUT" - exit $CODE - fi - } + VERSION="${{ needs.generate-and-build.outputs.cli_version }}" + if npm view "@pachca/cli@${VERSION}" version 2>/dev/null; then + echo "@pachca/cli@${VERSION} already published, skipping" + else + npm publish --access public --provenance + fi publish-py: if: github.event_name == 'push' && needs.generate-and-build.outputs.python_changed == 'true' diff --git a/apps/docs/data/releases.json b/apps/docs/data/releases.json index 949d3be5..0c1b710e 100644 --- a/apps/docs/data/releases.json +++ b/apps/docs/data/releases.json @@ -1,4 +1,16 @@ [ + { + "product": "cli", + "version": "2026.5.6", + "date": "2026-05-21", + "changes": [ + { + "type": "~", + "command": "api", + "description": "В выводе `pachca api <МЕТОД> <путь> --describe` у полей запроса и ответа появились примеры значений" + } + ] + }, { "product": "n8n", "version": "2.0.9", diff --git a/packages/cli/src/data/changelog.json b/packages/cli/src/data/changelog.json index e03450a6..87321f1c 100644 --- a/packages/cli/src/data/changelog.json +++ b/packages/cli/src/data/changelog.json @@ -1,4 +1,15 @@ [ + { + "version": "2026.5.6", + "date": "21 мая 2026", + "changes": [ + { + "type": "~", + "command": "api", + "description": "В выводе `pachca api <МЕТОД> <путь> --describe` у полей запроса и ответа появились примеры значений" + } + ] + }, { "version": "2026.5.4", "date": "20 мая 2026", diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs new file mode 100644 index 00000000..5b2c239b --- /dev/null +++ b/scripts/check-release.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * Unified release gate for all published packages (CLI, n8n, SDK, generator). + * + * A package is published for version V only when ALL hold: + * 1. V is declared in the portal changelog apps/docs/data/releases.json + * (the latest entry for this product) — this is the single source of + * the version number. + * 2. V is NOT yet on npm (no duplicate re-publish). + * 3. The package code actually changed in this push (HEAD~1..HEAD), so a + * portal edit alone doesn't trigger a release. + * 4. If the package keeps its own changelog (CLI: changelog.json, + * n8n: CHANGELOG.md), V must also appear there. SDK/generator have no + * own changelog — the portal entry is enough. + * + * Usage: + * node scripts/check-release.mjs --product cli --npm @pachca/cli \ + * --dir packages/cli --changelog packages/cli/src/data/changelog.json --changelog-type json + * + * Prints to stdout (GITHUB_OUTPUT format): + * version= + * should_publish= + * Diagnostics go to stderr. + */ + +import fs from 'node:fs'; +import { execSync } from 'node:child_process'; + +function arg(name) { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +const product = arg('product'); +const npmPkg = arg('npm'); +const dir = arg('dir'); +const changelog = arg('changelog'); +const changelogType = arg('changelog-type') || 'json'; +// Version format rule per library: 'calver' (CLI: YYYY.M.patch) or 'semver'. +const versionRule = arg('version-rule') || 'semver'; + +/** Validate version string against the library's format rule. */ +function validFormat(v) { + if (versionRule === 'calver') return /^\d{4}\.\d{1,2}\.\d+$/.test(v); + return /^\d+\.\d+\.\d+$/.test(v); +} + +/** Numeric component compare; works for both semver and CalVer (YYYY.M.patch). */ +function cmpVersion(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d !== 0) return d; + } + return 0; +} + +/** Highest version currently published on npm (numeric, ignores pre-release). */ +function maxPublished() { + try { + const raw = execSync(`npm view ${npmPkg} versions --json`, { + stdio: ['pipe', 'pipe', 'pipe'], + }).toString(); + const parsed = JSON.parse(raw); + const list = (Array.isArray(parsed) ? parsed : [parsed]).filter((v) => + v.split('.').every((p) => /^\d+$/.test(p)) + ); + if (list.length === 0) return null; + return list.sort(cmpVersion).at(-1); + } catch { + return null; + } +} + +const releases = JSON.parse(fs.readFileSync('apps/docs/data/releases.json', 'utf-8')); +// releases.json is ordered newest-first; the first entry for a product is its +// current declared version. +const entry = releases.find((r) => r.product === product); + +if (!entry) { + console.error(`[check-release] ${product}: no portal entry in releases.json → skip`); + console.log('version='); + console.log('should_publish=false'); + process.exit(0); +} + +const version = entry.version; + +// Rule 1: version must match the library's format (CLI=CalVer, others=semver). +const formatOk = validFormat(version); + +// Rule 2: version must be strictly greater than the latest published on npm +// (monotonic growth — never republish or go backwards). Also yields onNpm. +const published = maxPublished(); +const grows = !published || cmpVersion(version, published) > 0; +const onNpm = published !== null && cmpVersion(version, published) <= 0 && (() => { + try { + execSync(`npm view ${npmPkg}@${version} version`, { stdio: ['pipe', 'pipe', 'pipe'] }); + return true; + } catch { + return false; + } +})(); + +// Rule 3: package code changed in this push (ignore script-only churn). +let codeChanged = false; +try { + const dirs = dir.split(',').map((d) => d.trim()); + const diff = execSync(`git diff --name-only HEAD~1 HEAD -- ${dirs.join(' ')}`, { + encoding: 'utf-8', + }) + .split('\n') + .filter((f) => f && !f.includes('/scripts/')); + codeChanged = diff.length > 0; +} catch { + codeChanged = false; +} + +// Rule 4: version present in the package's own changelog, when it has one. +let inChangelog = true; +if (changelog) { + const content = fs.readFileSync(changelog, 'utf-8'); + if (changelogType === 'json') { + inChangelog = JSON.parse(content).some((e) => e.version === version); + } else { + const escaped = version.replace(/[.]/g, '\\.'); + inChangelog = new RegExp(`^##\\s+${escaped}\\b`, 'm').test(content); + } +} + +const shouldPublish = formatOk && grows && !onNpm && codeChanged && inChangelog; + +console.error( + `[check-release] ${product}: version=${version} (rule=${versionRule}) ` + + `formatOk=${formatOk} grows=${grows} (npm-max=${published ?? 'none'}) ` + + `onNpm=${onNpm} codeChanged=${codeChanged} inChangelog=${inChangelog} → publish=${shouldPublish}` +); +if (!formatOk) { + console.error( + `[check-release] ${product}: version "${version}" violates ${versionRule} format rule` + ); +} +if (!grows) { + console.error( + `[check-release] ${product}: version "${version}" is not greater than published ${published}` + ); +} +console.log(`version=${version}`); +console.log(`should_publish=${shouldPublish}`); From b2b9e61748ff3591142d6dbb295ad1372c55542c Mon Sep 17 00:00:00 2001 From: lookinway Date: Fri, 22 May 2026 20:42:16 +0300 Subject: [PATCH 2/2] ci(release): require exact next version, not just monotonic growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate accepted any version greater than npm-max, allowing skips (1.1.4 → 1.1.6). Now the declared version must be the exact valid next step per the library rule: semver patch+1 / minor+1.0 / major+1.0.0, or CalVer patch+1 within the month / .0 in a later month — all relative to the highest version actually on npm. On mismatch the gate prints the allowed versions (e.g. generator after npm 1.1.4 → 1.1.5, 1.2.0 or 2.0.0). --- scripts/check-release.mjs | 48 +++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index 5b2c239b..c8db951a 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -56,6 +56,32 @@ function cmpVersion(a, b) { return 0; } +/** + * Allowed next versions after `max`, by rule — for diagnostics. Null when + * `max` is null (first release: any well-formed version allowed). + */ +function allowedNext(max) { + if (!max) return null; + if (versionRule === 'calver') { + const [my, mm, mp] = max.split('.').map(Number); + return { 'patch (this month)': `${my}.${mm}.${mp + 1}`, 'new month': `${my}.${mm + 1}.0` }; + } + const [M, m, p] = max.split('.').map(Number); + return { patch: `${M}.${m}.${p + 1}`, minor: `${M}.${m + 1}.0`, major: `${M + 1}.0.0` }; +} + +/** Is `version` exactly a valid next step after `max` per the library rule? */ +function validIncrement(version, max) { + if (!max) return true; + if (versionRule === 'calver') { + const [vy, vm, vp] = version.split('.').map(Number); + const [my, mm, mp] = max.split('.').map(Number); + if (vy === my && vm === mm) return vp === mp + 1; // same month → next patch + return (vy > my || (vy === my && vm > mm)) && vp === 0; // later month → patch 0 + } + return Object.values(allowedNext(max)).includes(version); +} + /** Highest version currently published on npm (numeric, ignores pre-release). */ function maxPublished() { try { @@ -90,10 +116,11 @@ const version = entry.version; // Rule 1: version must match the library's format (CLI=CalVer, others=semver). const formatOk = validFormat(version); -// Rule 2: version must be strictly greater than the latest published on npm -// (monotonic growth — never republish or go backwards). Also yields onNpm. +// Rule 2: version must be EXACTLY the valid next step after the latest +// published on npm (per library rule) — not just greater. Forbids skips +// (1.1.4 → 1.1.6), republishes and going backwards. Also yields onNpm. const published = maxPublished(); -const grows = !published || cmpVersion(version, published) > 0; +const stepOk = validIncrement(version, published); const onNpm = published !== null && cmpVersion(version, published) <= 0 && (() => { try { execSync(`npm view ${npmPkg}@${version} version`, { stdio: ['pipe', 'pipe', 'pipe'] }); @@ -129,11 +156,11 @@ if (changelog) { } } -const shouldPublish = formatOk && grows && !onNpm && codeChanged && inChangelog; +const shouldPublish = formatOk && stepOk && !onNpm && codeChanged && inChangelog; console.error( `[check-release] ${product}: version=${version} (rule=${versionRule}) ` + - `formatOk=${formatOk} grows=${grows} (npm-max=${published ?? 'none'}) ` + + `formatOk=${formatOk} stepOk=${stepOk} (npm-max=${published ?? 'none'}) ` + `onNpm=${onNpm} codeChanged=${codeChanged} inChangelog=${inChangelog} → publish=${shouldPublish}` ); if (!formatOk) { @@ -141,9 +168,16 @@ if (!formatOk) { `[check-release] ${product}: version "${version}" violates ${versionRule} format rule` ); } -if (!grows) { +if (!stepOk && !onNpm) { + const next = allowedNext(published); + const hint = next + ? Object.entries(next) + .map(([k, v]) => `${v} (${k})`) + .join(', ') + : '(any well-formed version)'; console.error( - `[check-release] ${product}: version "${version}" is not greater than published ${published}` + `[check-release] ${product}: version "${version}" is not a valid next step after ` + + `npm ${published}. Allowed: ${hint}` ); } console.log(`version=${version}`);