diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index a217d6f..2b3f184 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,6 +1,11 @@ # Publish on GitHub release using npm Trusted Publishing (OIDC). # Docs: https://docs.npmjs.com/trusted-publishers # +# Versioning: npm rejects republishing the same version. Before publish, CI sets +# the package version from the GitHub release tag (v1.5.0 → 1.5.0). If that +# version is already on the registry, the patch number is incremented until it +# is unpublished. Bump major/minor by tagging the release (for example v1.6.0). +# # Required on npmjs.com → @veryfi/veryfi-sdk → Settings → Trusted Publisher: # Organization or user: veryfi # Repository: veryfi-nodejs @@ -28,12 +33,25 @@ jobs: node-version: "24" package-manager-cache: false - run: npm ci + - name: Set unpublished package version + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: node scripts/set-publish-version.js + - uses: actions/upload-artifact@v4 + with: + name: package-version + path: | + package.json + package-lock.json publish-npm: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - uses: actions/download-artifact@v4 + with: + name: package-version # Intentionally omit registry-url. setup-node writes an empty # _authToken placeholder that blocks the OIDC exchange (E404). - uses: actions/setup-node@v6 @@ -121,6 +139,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - uses: actions/download-artifact@v4 + with: + name: package-version - uses: actions/setup-node@v6 with: node-version: "24" diff --git a/package-lock.json b/package-lock.json index a39cf43..62a067f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@veryfi/veryfi-sdk", - "version": "1.4.8", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@veryfi/veryfi-sdk", - "version": "1.4.8", + "version": "1.5.0", "license": "MIT", "dependencies": { "@jest/globals": "^30.4.1", diff --git a/package.json b/package.json index a8ccc44..6d40608 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@veryfi/veryfi-sdk", - "version": "1.4.8", + "version": "1.5.0", "description": "Node.js module for communicating with the Veryfi OCR API", "main": "lib/client/client.js", "typings": "lib/types/Client.ts", diff --git a/scripts/next-publish-version.js b/scripts/next-publish-version.js new file mode 100644 index 0000000..42cc2f4 --- /dev/null +++ b/scripts/next-publish-version.js @@ -0,0 +1,87 @@ +/** + * Resolve an unpublished npm version for CI releases. + * + * Preference order: + * 1. GitHub release tag (v1.5.0 → 1.5.0) when it is valid semver + * 2. package.json version + * + * If that version is already on the registry, bump the patch until it is free. + */ + +/** + * @param {string|undefined|null} value + * @returns {{major: number, minor: number, patch: number}|null} + */ +function parseSemver(value) { + if (!value || typeof value !== "string") { + return null; + } + const match = value.trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!match) { + return null; + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +/** + * @param {{major: number, minor: number, patch: number}} version + * @returns {string} + */ +function formatSemver(version) { + return `${version.major}.${version.minor}.${version.patch}`; +} + +/** + * @param {{major: number, minor: number, patch: number}} version + * @returns {{major: number, minor: number, patch: number}} + */ +function incrementPatch(version) { + return { + major: version.major, + minor: version.minor, + patch: version.patch + 1, + }; +} + +/** + * @param {string} packageVersion + * @param {string|undefined|null} releaseTag + * @param {string[]|string|undefined|null} publishedVersions + * @returns {string} + */ +function resolvePublishVersion(packageVersion, releaseTag, publishedVersions) { + const fromPackage = parseSemver(packageVersion); + if (!fromPackage) { + throw new Error(`Invalid package.json version: ${packageVersion}`); + } + + const candidate = parseSemver(releaseTag) || fromPackage; + const publishedList = Array.isArray(publishedVersions) + ? publishedVersions + : publishedVersions + ? [publishedVersions] + : []; + const published = new Set( + publishedList + .map((version) => parseSemver(version)) + .filter(Boolean) + .map((version) => formatSemver(version)) + ); + + let next = candidate; + while (published.has(formatSemver(next))) { + next = incrementPatch(next); + } + return formatSemver(next); +} + +module.exports = { + parseSemver, + formatSemver, + incrementPatch, + resolvePublishVersion, +}; diff --git a/scripts/set-publish-version.js b/scripts/set-publish-version.js new file mode 100644 index 0000000..5598a0f --- /dev/null +++ b/scripts/set-publish-version.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Set package.json (and package-lock.json) to an unpublished version before npm publish. + * + * Reads RELEASE_TAG or GITHUB_REF_NAME for the GitHub release tag. + * Queries the npm registry for already-published versions of this package. + */ +const {execFileSync} = require("child_process"); +const path = require("path"); +const {resolvePublishVersion} = require("./next-publish-version"); + +function readPublishedVersions(packageName) { + try { + const output = execFileSync( + "npm", + ["view", packageName, "versions", "--json"], + {encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]} + ); + const parsed = JSON.parse(output); + if (Array.isArray(parsed)) { + return parsed; + } + if (typeof parsed === "string") { + return [parsed]; + } + return []; + } catch (error) { + const stderr = error.stderr ? String(error.stderr) : String(error.message || error); + if (/E404|404 Not Found|code E404/i.test(stderr)) { + return []; + } + console.warn("Could not read published versions from npm; treating as none."); + console.warn(stderr.trim()); + return []; + } +} + +function main() { + const packageJsonPath = path.join(__dirname, "..", "package.json"); + const pkg = require(packageJsonPath); + const releaseTag = process.env.RELEASE_TAG || process.env.GITHUB_REF_NAME || ""; + const published = readPublishedVersions(pkg.name); + const nextVersion = resolvePublishVersion(pkg.version, releaseTag, published); + + console.log(`package.json version: ${pkg.version}`); + console.log(`release tag: ${releaseTag || "(none)"}`); + console.log(`latest published: ${published[published.length - 1] || "(none)"}`); + console.log(`publish version: ${nextVersion}`); + + if (nextVersion === pkg.version) { + console.log("package.json already matches an unpublished version"); + return; + } + + execFileSync("npm", ["version", nextVersion, "--no-git-tag-version", "--allow-same-version"], { + stdio: "inherit", + cwd: path.join(__dirname, ".."), + }); +} + +if (require.main === module) { + main(); +} + +module.exports = {readPublishedVersions, main}; diff --git a/test/next-publish-version.test.js b/test/next-publish-version.test.js new file mode 100644 index 0000000..e9a8552 --- /dev/null +++ b/test/next-publish-version.test.js @@ -0,0 +1,40 @@ +const {describe, expect, test} = require("@jest/globals"); +const {parseSemver, resolvePublishVersion} = require("../scripts/next-publish-version"); + +describe("parseSemver", () => { + test("accepts v-prefixed release tags", () => { + expect(parseSemver("v1.5.0")).toEqual({major: 1, minor: 5, patch: 0}); + }); + + test("rejects extra numeric segments used on some git tags", () => { + expect(parseSemver("v1.4.8.1")).toBeNull(); + }); +}); + +describe("resolvePublishVersion", () => { + const published = ["1.4.6", "1.4.8"]; + + test("uses an unpublished GitHub release tag instead of package.json", () => { + expect(resolvePublishVersion("1.4.8", "v1.5.0", published)).toBe("1.5.0"); + }); + + test("autoincrements patch when the release tag is already published", () => { + expect(resolvePublishVersion("1.4.8", "v1.4.8", published)).toBe("1.4.9"); + }); + + test("falls back to package.json when the tag is not valid semver", () => { + expect(resolvePublishVersion("1.4.8", "v1.4.8.1", published)).toBe("1.4.9"); + }); + + test("keeps walking patch until the version is unpublished", () => { + expect(resolvePublishVersion("1.4.8", "v1.4.8", ["1.4.8", "1.4.9"])).toBe("1.4.10"); + }); + + test("uses package.json when it is already unpublished and no tag is set", () => { + expect(resolvePublishVersion("1.5.0", "", published)).toBe("1.5.0"); + }); + + test("accepts a single published version string from npm view", () => { + expect(resolvePublishVersion("1.0.0", "v1.0.0", "1.0.0")).toBe("1.0.1"); + }); +});