From 6c78ce1c9c4301870b9843f7718b8ae2e02a94d8 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Tue, 20 Oct 2026 11:10:37 +0300 Subject: [PATCH 1/2] Trunk-based release: main, required build check, publish by GitHub Release - build.yml: build + unit tests + live tests against staging on Node 20/22/24, aggregated into the required `build` check; packs a 0.0.0-ci artifact. - publish.yml: a GitHub Release v{semver} by tomasr78 publishes to npm with provenance via Trusted Publishing from the `npm` environment. Stable versions go to the `module` dist-tag; `latest` stays on the non-module 1.0.8. - package.json: 0.0.0-local placeholder version, build/test scripts, local publish refused, repository URL points at convertapi-library-js, types. - Tests (node:test): stubbed-fetch unit tests and live tests that skip without CONVERT_API_SECRET. - RELEASING.md, CHANGELOG.md, Dependabot; publish.sh removed; lockfile committed. - README: working remote-file example URL, links to the renamed repository. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 31 +++++ .github/workflows/build.yml | 151 ++++++++++++++++++++++ .github/workflows/publish.yml | 235 ++++++++++++++++++++++++++++++++++ .gitignore | 1 - CHANGELOG.md | 29 +++++ README.md | 6 +- RELEASING.md | 65 ++++++++++ package-lock.json | 30 +++++ package.json | 15 ++- publish.sh | 5 - test/convertapi.test.js | 111 ++++++++++++++++ test/live.test.js | 44 +++++++ test/setup.js | 23 ++++ 13 files changed, 731 insertions(+), 15 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/publish.yml create mode 100644 CHANGELOG.md create mode 100644 RELEASING.md create mode 100644 package-lock.json delete mode 100755 publish.sh create mode 100644 test/convertapi.test.js create mode 100644 test/live.test.js create mode 100644 test/setup.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..432abb8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +# Weekly dependency updates, each opened as a pull request against main that must pass the `build` check +# and is merged like any other change (merge commit). Merging releases nothing: a new npm version still +# needs a GitHub Release (see RELEASING.md). +version: 2 +updates: + # The workflows pin every action to a commit SHA with a `# vX.Y.Z` comment; Dependabot updates both. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + groups: + github-actions: + patterns: ["*"] + commit-message: + prefix: ci + labels: [dependencies] + + # The package has no runtime dependencies; TypeScript only builds dist/. + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + groups: + npm-minor-and-patch: + update-types: [minor, patch] + commit-message: + prefix: deps + labels: [dependencies] + open-pull-requests-limit: 5 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b2116bc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,151 @@ +name: Build +run-name: >- + ${{ github.event_name == 'pull_request' && format('Checking that #{0} builds', github.event.pull_request.number) + || format('Building {0}', github.ref_name) }} + +# Trunk-based: main is the only long-lived branch. Every pull request and every push to main builds the +# TypeScript sources and runs the tests on each supported Node.js LTS line, and packs the package, but +# nothing is published here: this is a public npm package, and npm versions are permanent, so a release is +# a deliberate act - publishing a GitHub Release v{semver}, which runs publish.yml. The required status +# check is the last job's name, `build`, which passes only when every Node.js version passed. +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +# A newer push to the same pull request cancels the older build; builds of main never cancel each other. +concurrency: + group: build-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: true + +env: + # test/live.test.js converts real files against the staging API with a staging token. Without the token + # (a pull request from a fork gets no secrets) those tests are skipped and the mocked tests still run. + CONVERT_API_HOST: stag-v2.convertapi.com + +jobs: + test: + name: test (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + node: [20, 22, 24] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + cache: npm + + # --ignore-scripts: no dependency runs install scripts on the runner. + - name: Install + run: npm ci --ignore-scripts + + - name: Build + run: npm run build + + - name: Unit tests + run: node --test test/convertapi.test.js + + # The staging API now and then answers 503 (no healthy upstream), so up to three attempts. + - name: Live tests against staging + env: + CONVERT_API_SECRET: ${{ secrets.CONVERTAPI_STAGING_TOKEN }} + run: | + if [ -z "$CONVERT_API_SECRET" ]; then + echo "::warning::The CONVERTAPI_STAGING_TOKEN secret is not available to this run, so the live tests are skipped" + fi + for attempt in 1 2 3; do + if node --test test/live.test.js; then + exit 0 + fi + echo "::warning::Attempt $attempt of the live tests failed" + sleep 10 + done + echo "::error::The live tests failed three times" + exit 1 + + build: + name: build + needs: test + # Runs even when a test job failed, so that the required check reports a failure instead of being + # skipped (a skipped required check counts as passed). + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check every Node.js version passed + env: + RESULT: ${{ needs.test.result }} + run: | + if [ "$RESULT" != success ]; then + echo "::error::The tests did not pass on every Node.js version ($RESULT)" + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: npm + + - name: Install + run: npm ci --ignore-scripts + + # The package has no runtime dependencies today; this keeps it that way unless they are clean. + - name: Audit the runtime dependencies + run: npm audit --omit=dev --audit-level=high + + - name: Build + run: npm run build + + # A pre-release version that sorts below any real release and names the run, so a package + # downloaded from this run can be tried out but never mistaken for a published one. + - name: Pack + id: pack + env: + RUN: ${{ github.run_number }} + SHORT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + VERSION="0.0.0-ci.$RUN.g${SHORT_SHA:0:7}" + npm version "$VERSION" --no-git-tag-version --ignore-scripts + mkdir out + npm pack --ignore-scripts --pack-destination out + TGZ="out/convertapi-js-$VERSION.tgz" + test -f "$TGZ" + for f in dist/convertapi.js dist/convertapi.d.ts; do + tar -tzf "$TGZ" | grep -qx "package/$f" || { echo "::error::$TGZ has no $f"; exit 1; } + done + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: convertapi-js-${{ steps.pack.outputs.version }} + path: out/ + retention-days: 14 + if-no-files-found: error + + - name: Summary + env: + VERSION: ${{ steps.pack.outputs.version }} + run: | + { + echo "| | |" + echo "|---|---|" + echo "| Package | \`convertapi-js $VERSION\` (artifact only, not published) |" + echo "| Files | $(tar -tzf "out/convertapi-js-$VERSION.tgz" | wc -l) |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..189a89d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,235 @@ +name: Publish +run-name: Publishing convertapi-js ${{ github.event.release.tag_name }} to npm + +# A release is a GitHub Release, never a merge. Merging to main only builds (build.yml); publishing the +# Release v{semver} runs this file, which tests and builds that tag's commit with the tag's version and +# publishes it to npm with provenance. npm versions are public and permanent - a published version can be +# deprecated but never replaced - so nothing here retries, overwrites or skips a duplicate. +# +# gh release create v1.1.10 --target main --generate-notes -> convertapi-js 1.1.10 (module) +# gh release create v1.2.0-beta.1 --target main --prerelease ... -> convertapi-js 1.2.0-beta.1 (next) +# +# The npm dist-tag `latest` is deliberately left on 1.0.8, the last non-module (script tag) build from the +# `last_nonmodule` git tag, and the ES module line (1.1.x, this branch) is published under the dist-tag +# `module`; the README installs it with `npm i convertapi-js@~1.1`. Moving `latest` to the module line would +# change what `npm i convertapi-js` and unversioned CDN URLs serve, so it is a separate, deliberate step +# (`npm dist-tag add convertapi-js@X.Y.Z latest`), not something a release does. +# +# A stable version must be a commit on main. A pre-release may be any commit, so a branch can be tried +# out as a preview package before it is merged. +on: + release: + types: [published] + +permissions: + contents: read + +env: + # Only this account may publish a Release that reaches npm. GitHub cannot express this as a permission + # on the Team plan, so it is checked here; the real controls are the v* tag ruleset (only repository + # admins create v* tags) and the npm environment, which accepts only v* tags. + PUBLISHER: tomasr78 + PACKAGE: convertapi-js + STABLE_DIST_TAG: module + CONVERT_API_HOST: stag-v2.convertapi.com + +jobs: + publish: + name: publish + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: + name: npm + url: https://www.npmjs.com/package/convertapi-js/v/${{ steps.tag.outputs.version }} + concurrency: + group: publish-npm + cancel-in-progress: false + permissions: + contents: read + # For npm Trusted Publishing, which trades this run's OIDC token for a short-lived publish token, + # and for the provenance statement, which is signed with it. + id-token: write + steps: + - name: Check who published the release + run: | + # A re-run keeps GITHUB_ACTOR as the original user; GITHUB_TRIGGERING_ACTOR is whoever pressed + # "Re-run", so both must be the publisher or anyone with write access could replay a release. + if [ "$GITHUB_ACTOR" != "$PUBLISHER" ] || [ "${GITHUB_TRIGGERING_ACTOR:-$GITHUB_ACTOR}" != "$PUBLISHER" ]; then + echo "::error::Only $PUBLISHER may publish to npm - this release was published by $GITHUB_ACTOR and run by ${GITHUB_TRIGGERING_ACTOR:-$GITHUB_ACTOR}." + exit 1 + fi + echo "Published by $GITHUB_ACTOR" + + # The release event runs this file from the tag, so the workflow and the code come from the same commit. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + # Node 24 ships npm 11, which Trusted Publishing needs. No dependency cache: a publishing job must + # not restore anything another run could have written. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + + - name: Check the tag and the version + id: tag + env: + TAG: ${{ github.event.release.tag_name }} + PRERELEASE: ${{ github.event.release.prerelease }} + run: | + set -euo pipefail + # SemVer 2.0 without build metadata (npm drops "+..." and it would only confuse). + SEMVER='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' + if ! [[ "$TAG" =~ $SEMVER ]]; then + echo "::error::Tag '$TAG' is not v{major}.{minor}.{patch} or v{major}.{minor}.{patch}-{pre-release}, e.g. v1.1.10 or v1.2.0-beta.1" + exit 1 + fi + if [ "$GITHUB_REF" != "refs/tags/$TAG" ]; then + echo "::error::The run is on $GITHUB_REF, not on the release tag refs/tags/$TAG" + exit 1 + fi + VERSION="${TAG#v}" + SHA=$(git rev-parse "refs/tags/$TAG^{commit}") + if [ "$(git rev-parse HEAD)" != "$SHA" ]; then + echo "::error::Checked out $(git rev-parse HEAD), but $TAG points at $SHA" + exit 1 + fi + + # The GitHub "pre-release" tick and the version must agree, so a preview cannot become the + # package's current version by a forgotten tick, and a stable version cannot hide as a pre-release. + if [[ "$VERSION" == *-* ]]; then + if [ "$PRERELEASE" != true ]; then + echo "::error::$TAG is a pre-release version, so the GitHub Release must be marked as a pre-release (gh release create --prerelease)" + exit 1 + fi + DIST_TAG=next + else + if [ "$PRERELEASE" = true ]; then + echo "::error::$TAG is a stable version, but the GitHub Release is marked as a pre-release. Use a pre-release version such as ${TAG}-beta.1, or untick pre-release." + exit 1 + fi + if ! git merge-base --is-ancestor "$SHA" origin/main; then + echo "::error::$TAG ($SHA) is not on main. Stable versions are released from main only; release a branch as a pre-release instead." + exit 1 + fi + DIST_TAG=$STABLE_DIST_TAG + fi + + # The registry lists every version ever published, deprecated ones included. + META=$(curl -fsS --retry 3 "https://registry.npmjs.org/$PACKAGE") + if jq -e --arg v "$VERSION" '.versions | has($v)' <<<"$META" >/dev/null || \ + jq -e --arg v "$VERSION" '.time | has($v)' <<<"$META" >/dev/null; then + echo "::error::$PACKAGE $VERSION already exists on npm (or was unpublished) and can never be published again. Release the next version instead." + exit 1 + fi + # A stable version below the current one (a backport) must not take the stable dist-tag, or + # installs of that tag would go back to it. + CURRENT=$(jq -r --arg t "$STABLE_DIST_TAG" '."dist-tags"[$t] // empty' <<<"$META") + if [ "$DIST_TAG" = "$STABLE_DIST_TAG" ] && [ -n "$CURRENT" ] && \ + [ "$(printf '%s\n%s\n' "$CURRENT" "$VERSION" | sort -V | tail -n1)" != "$VERSION" ]; then + echo "::warning::$VERSION is lower than $CURRENT ($STABLE_DIST_TAG), so it is published under the dist-tag 'maintenance'." + DIST_TAG=maintenance + fi + + { + echo "version=$VERSION" + echo "sha=$SHA" + echo "dist-tag=$DIST_TAG" + } >> "$GITHUB_OUTPUT" + echo "Publishing $PACKAGE $VERSION from $SHA under the dist-tag $DIST_TAG" + + # --ignore-scripts here and below: no lifecycle script runs in a job that can publish. + - name: Install + run: npm ci --ignore-scripts + + - name: Audit the runtime dependencies + run: npm audit --omit=dev --audit-level=high + + - name: Set the version and build + env: + VERSION: ${{ steps.tag.outputs.version }} + run: | + set -euo pipefail + npm version "$VERSION" --no-git-tag-version --allow-same-version --ignore-scripts + npm run build + + # The staging API now and then answers 503 (no healthy upstream), so the live tests get up to three + # attempts. Without the CONVERTAPI_STAGING_TOKEN secret a release would ship untested against the + # API, so here a missing token fails. + - name: Test + env: + CONVERT_API_SECRET: ${{ secrets.CONVERTAPI_STAGING_TOKEN }} + run: | + set -uo pipefail + if [ -z "$CONVERT_API_SECRET" ]; then + echo "::error::The CONVERTAPI_STAGING_TOKEN secret is not set" + exit 1 + fi + node --test test/convertapi.test.js || exit 1 + for attempt in 1 2 3; do + if node --test test/live.test.js; then + exit 0 + fi + echo "::warning::Attempt $attempt of the live tests failed" + sleep 10 + done + echo "::error::The live tests failed three times" + exit 1 + + - name: Check the package + env: + VERSION: ${{ steps.tag.outputs.version }} + run: | + set -euo pipefail + mkdir out + npm pack --ignore-scripts --pack-destination out + TGZ="out/$PACKAGE-$VERSION.tgz" + test -f "$TGZ" + PKG_JSON=$(tar -xzOf "$TGZ" package/package.json) + [ "$(jq -r .version <<<"$PKG_JSON")" = "$VERSION" ] || { echo "::error::$TGZ does not carry version $VERSION"; exit 1; } + for f in dist/convertapi.js dist/convertapi.d.ts; do + tar -tzf "$TGZ" | grep -qx "package/$f" || { echo "::error::$TGZ has no $f"; exit 1; } + done + tar -tzf "$TGZ" + + # npm Trusted Publishing: npmjs.com trusts this repository, publish.yml and the npm environment, and + # npm exchanges this run's OIDC token for a short-lived publish token. No token is stored anywhere. + # No --force and no retry: a version that already exists must fail loudly, never pass as published. + - name: Publish to npm + env: + VERSION: ${{ steps.tag.outputs.version }} + DIST_TAG: ${{ steps.tag.outputs.dist-tag }} + run: npm publish "out/$PACKAGE-$VERSION.tgz" --provenance --access public --tag "$DIST_TAG" --ignore-scripts + + # The registry usually serves a new version within seconds. A slow registry is not a failed release, + # so this only warns. + - name: Wait for npm to serve the package + env: + VERSION: ${{ steps.tag.outputs.version }} + run: | + set -uo pipefail + for _ in $(seq 1 20); do + if curl -fsS "https://registry.npmjs.org/$PACKAGE/$VERSION" >/dev/null 2>&1; then + echo "$PACKAGE $VERSION is available on npm" + exit 0 + fi + sleep 15 + done + echo "::warning::$PACKAGE $VERSION was published but npm has not served it after 5 minutes. Check https://www.npmjs.com/package/$PACKAGE/v/$VERSION" + + - name: Summary + env: + VERSION: ${{ steps.tag.outputs.version }} + SHA: ${{ steps.tag.outputs.sha }} + DIST_TAG: ${{ steps.tag.outputs.dist-tag }} + run: | + { + echo "| | |" + echo "|---|---|" + echo "| Package | [\`$PACKAGE $VERSION\`](https://www.npmjs.com/package/$PACKAGE/v/$VERSION) (dist-tag \`$DIST_TAG\`) |" + echo "| Commit | \`$SHA\` |" + echo "| Published with | npm Trusted Publishing, with provenance |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 5635ece..e553d0f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,3 @@ .npmrc node_modules dist -package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..80de909 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to the `convertapi-js` npm package. Versions follow [Semantic Versioning](https://semver.org/); +from 1.1.10 on each one is a GitHub Release `v{version}` (see [RELEASING.md](RELEASING.md)). Stable 1.1.x +versions are published under the npm dist-tag `module`; `latest` stays on the non-module 1.0.8. + +## 1.1.10 - 2026-10-20 + +- No change to the library code. +- `package.json` points at the renamed repository, https://github.com/ConvertAPI/convertapi-library-js, + and names the type declarations (`types`). +- The README's remote-file example uses a file that exists + (https://cdn.convertapi.com/cara/testfiles/presentation.pptx). +- Published from GitHub Actions with npm provenance. Tested on Node.js 20, 22 and 24, including live + conversions against the staging API. + +## 1.1.9 - 2024-08-20 + +- Authenticate with an API token in the `Authorization: Bearer` header. `ConvertApi.auth()` takes the token + as a string. + +## 1.1.0 - 2022-06-21 + +- ES module build (`"type": "module"`, `dist/`), published under the dist-tag `module`. + +## 1.0.8 - 2022-06-21 + +- Last non-module build (git tag `last_nonmodule`), still the npm dist-tag `latest`. Authenticates with + the retired secret or API key and token in the query string. diff --git a/README.md b/README.md index f72ea19..dc6a357 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ In just few minutes you can integrate it into your application and use it easily The ConvertAPI-JS library makes it easier to use the Convert API from your web projects without having to build your own API calls. You can get your token at https://www.convertapi.com/a/authentication -If you are interested in our old version of **non-module** JavaScript library you can [find it here](https://github.com/ConvertAPI/convertapi-js/tree/last_nonmodule). +If you are interested in our old version of **non-module** JavaScript library you can [find it here](https://github.com/ConvertAPI/convertapi-library-js/tree/last_nonmodule). ## Try it online @@ -62,7 +62,7 @@ let url = result.files[0].Url ```js let params = convertApi.createParams() -params.add('file', new URL('https://cdn.convertapi.com/test-files/presentation.pptx')) +params.add('file', new URL('https://cdn.convertapi.com/cara/testfiles/presentation.pptx')) let result = await convertApi.convert('pptx', 'pdf', params) // Get result file URL @@ -103,4 +103,4 @@ We'll respond to your request ASAP! ### License The ConvertAPI JS Library is licensed under the [MIT](http://www.opensource.org/licenses/mit-license.php "Read more about the MIT license form") license. -Refere to the [LICENSE](https://github.com/ConvertAPI/convertapi-js/blob/master/LICENSE) file for more information. \ No newline at end of file +Refere to the [LICENSE](https://github.com/ConvertAPI/convertapi-library-js/blob/main/LICENSE) file for more information. \ No newline at end of file diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..4fadbbe --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,65 @@ +# Releasing + +`main` is the only long-lived branch. Changes reach it through pull requests, merge commits only, and +every pull request and push to `main` must pass the `build` check (`.github/workflows/build.yml`: build +the TypeScript sources and run the tests on Node.js 20, 22 and 24, audit the runtime dependencies, pack). +Merging publishes nothing. + +The unit tests (`test/convertapi.test.js`) stub `fetch`. The live tests (`test/live.test.js`) convert real +files against the staging API (`stag-v2.convertapi.com`) with the `CONVERTAPI_STAGING_TOKEN` repository +secret (also set as a Dependabot secret); without it they are skipped. Locally: + +```bash +npm ci +npm test +CONVERT_API_SECRET= CONVERT_API_HOST=stag-v2.convertapi.com npm test +``` + +A release is a **GitHub Release** whose tag is the version, published by `tomasr78`: + +```bash +gh release create v1.1.10 --repo ConvertAPI/convertapi-library-js --target main --title v1.1.10 --generate-notes +``` + +That runs `.github/workflows/publish.yml` in the `npm` environment (deployable from `v*` tags only), which: + +1. refuses a Release published or re-run by anyone else; +2. checks the tag is SemVer (`v1.1.10`, `v1.2.0-beta.1`), that the GitHub pre-release tick matches the + version, that a stable version is a commit on `main`, and that npm has never had that version; +3. audits the runtime dependencies, sets `package.json` to the tag's version + (`npm version --no-git-tag-version`), builds `dist/` and runs the unit and live tests (the live tests + are required here); +4. publishes the packed tarball to npm with `--provenance` through npm **Trusted Publishing** (OIDC, no + stored token) and waits until npm serves it. + +The trusted publisher on npmjs.com (https://www.npmjs.com/package/convertapi-js/access) is: publisher +GitHub Actions, organization or user `ConvertAPI`, repository `convertapi-library-js`, workflow filename +`publish.yml`, environment name `npm`. + +## Dist-tags + +| Dist-tag | Gets | Installed with | +|---|---|---| +| `latest` | `1.0.8`, the last **non-module** build (git tag `last_nonmodule`), kept on purpose | `npm i convertapi-js` | +| `module` | stable releases from `main` (the ES module line, `1.1.x`) | `npm i convertapi-js@~1.1` (as the README says) | +| `next` | pre-releases | `npm i convertapi-js@next` | +| `maintenance` | a stable version lower than the current `module` one | `npm i convertapi-js@maintenance` | + +A release never moves `latest`. Moving it to the module line changes what `npm i convertapi-js` and +unversioned CDN URLs (for example https://cdn.jsdelivr.net/npm/convertapi-js) serve, which breaks pages +that load the non-module build with a script tag, so it is a separate decision: +`npm dist-tag add convertapi-js@1.1.10 latest`. + +**Pre-releases** work the same way and may come from any branch: + +```bash +gh release create v1.2.0-beta.1 --repo ConvertAPI/convertapi-library-js --target my-branch --prerelease --title v1.2.0-beta.1 --notes "Preview of ..." +``` + +npm versions are permanent. A bad version can only be deprecated +(`npm deprecate convertapi-js@1.1.10 "..."`) and fixed by releasing the next version. Never delete and +re-create a release tag. + +The version in `package.json` is a `0.0.0-local` placeholder, and `npm publish` from a working copy is +refused. Pull request builds pack `0.0.0-ci.{run}.g{sha}` as a downloadable artifact so a change can be +tried before it is released. Update `CHANGELOG.md` in the pull request that changes behaviour. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9b32ce7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "convertapi-js", + "version": "0.0.0-local", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "convertapi-js", + "version": "0.0.0-local", + "license": "MIT", + "devDependencies": { + "typescript": "~5.5.4" + } + }, + "node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json index 6a2ecce..e9ae2fa 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "convertapi-js", - "version": "1.1.9", + "version": "0.0.0-local", "description": "ConvertApi JavaScript and TypeScript library", "main": "./dist/convertapi.js", + "types": "./dist/convertapi.d.ts", "type": "module", "dependencies": {}, "devDependencies": { @@ -15,11 +16,13 @@ "dist" ], "scripts": { - "prepublish": "./node_modules/typescript/bin/tsc" + "build": "tsc", + "test": "npm run build && node --test test/*.test.js", + "prepublishOnly": "echo 'convertapi-js is published by a GitHub Release, see RELEASING.md' && exit 1" }, "repository": { "type": "git", - "url": "git+https://github.com/ConvertAPI/convertapi-js" + "url": "git+https://github.com/ConvertAPI/convertapi-library-js.git" }, "keywords": [ "convertapi", @@ -30,7 +33,7 @@ "author": "Baltsoft", "license": "MIT", "bugs": { - "url": "https://github.com/ConvertAPI/convertapi-js/issues" + "url": "https://github.com/ConvertAPI/convertapi-library-js/issues" }, - "homepage": "https://github.com/ConvertAPI/convertapi-js" -} \ No newline at end of file + "homepage": "https://github.com/ConvertAPI/convertapi-library-js" +} diff --git a/publish.sh b/publish.sh deleted file mode 100755 index 02787ca..0000000 --- a/publish.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -# Tag "latest" must be non module implementation -npm publish --tag=module -#npm publish --tag=debug \ No newline at end of file diff --git a/test/convertapi.test.js b/test/convertapi.test.js new file mode 100644 index 0000000..533821c --- /dev/null +++ b/test/convertapi.test.js @@ -0,0 +1,111 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { stubFetch } from './setup.js' +import ConvertApi from '../dist/convertapi.js' +import { FileValue } from '../dist/file-param.js' + +const result = { + ConversionCost: 1, + Files: [{ FileName: 'test.pdf', FileExt: 'pdf', FileSize: 100, FileId: 'f1', Url: 'https://v2.convertapi.com/d/f1/test.pdf' }] +} + +test('auth() keeps the token and defaults to v2.convertapi.com', () => { + const api = ConvertApi.auth('token-1') + assert.equal(api.authCredentials, 'token-1') + assert.equal(api.host, 'v2.convertapi.com') + assert.equal(ConvertApi.auth('token-1', 'example.test').host, 'example.test') +}) + +test('convert() posts the parameters with a Bearer token and returns the result', async t => { + const calls = stubFetch(t, { body: result }) + const api = ConvertApi.auth('token-1', 'example.test') + const params = api.createParams() + params.add('File', new FileValue('test.docx', 'id-1')) + params.add('PageRange', '1-2') + + const res = await api.convert('docx', 'pdf', params) + + assert.equal(calls.length, 1) + assert.equal(calls[0].url, 'https://example.test/convert/docx/to/pdf?storefile=true') + assert.equal(calls[0].init.method, 'POST') + assert.equal(calls[0].init.headers.authorization, 'Bearer token-1') + assert.deepEqual(JSON.parse(calls[0].init.body), { + Parameters: [ + { Name: 'File', FileValue: { Id: 'id-1' } }, + { Name: 'PageRange', Value: '1-2' } + ] + }) + assert.equal(res.duration, 1) + assert.equal(res.files[0].Url, result.Files[0].Url) +}) + +test('convert() rejects with the API error', async t => { + stubFetch(t, { status: 401, body: { Code: 4013, Message: 'Invalid token' } }) + const api = ConvertApi.auth('bad', 'example.test') + await assert.rejects(api.convert('docx', 'pdf', api.createParams()), { Code: 4013, Message: 'Invalid token' }) +}) + +test('a File parameter is uploaded first and sent as its file id', async t => { + const calls = stubFetch(t, { body: { FileId: 'up-1', FileName: 'a b.txt', FileExt: 'txt' } }, { body: result }) + const api = ConvertApi.auth('token-1', 'example.test') + const params = api.createParams() + params.add('File', new File(['hello'], 'a b.txt')) + + await api.convert('txt', 'pdf', params) + + assert.equal(calls[0].url, 'https://example.test/upload?filename=a%20b.txt') + assert.equal(calls[0].init.method, 'POST') + assert.deepEqual(JSON.parse(calls[1].init.body).Parameters, [{ Name: 'File', FileValue: { Id: 'up-1' } }]) +}) + +test('a URL parameter is uploaded by URL', async t => { + const calls = stubFetch(t, { body: { FileId: 'up-2' } }, { body: result }) + const api = ConvertApi.auth('token-1', 'example.test') + const params = api.createParams() + params.add('File', new URL('https://cdn.convertapi.com/test-files/presentation.pptx')) + + await api.convert('pptx', 'pdf', params) + + assert.equal(calls[0].url, 'https://example.test/upload?url=https%3A%2F%2Fcdn.convertapi.com%2Ftest-files%2Fpresentation.pptx') + assert.deepEqual(JSON.parse(calls[1].init.body).Parameters, [{ Name: 'File', FileValue: { Id: 'up-2' } }]) +}) + +test('a failed upload rejects the conversion', async t => { + stubFetch(t, { status: 500, body: {} }) + const api = ConvertApi.auth('token-1', 'example.test') + const params = api.createParams() + params.add('File', new File(['hello'], 'a.txt')) + await assert.rejects(api.convert('txt', 'pdf', params), { Code: 5007 }) +}) + +test('file lists, file ids and URLs become FileValues', async () => { + const api = ConvertApi.auth('token-1', 'example.test') + const params = api.createParams() + params.add('Files', ['id-1', new URL('https://example.test/a.pdf')]) + assert.deepEqual(await params.dto, { + Parameters: [{ Name: 'Files', FileValues: [{ Id: 'id-1' }, { Url: 'https://example.test/a.pdf' }] }] + }) +}) + +test('get() and delete() find parameters by name', () => { + const params = ConvertApi.auth('token-1').createParams([{ name: 'PageRange', value: '1', isFile: false }]) + assert.equal(params.get('PageRange').value, '1') + assert.equal(params.delete('PageRange').name, 'PageRange') + assert.equal(params.get('PageRange'), undefined) +}) + +test('a result chains into the next conversion', async t => { + const calls = stubFetch(t, { body: result }, { body: result }) + const api = ConvertApi.auth('token-1', 'example.test') + const first = await api.convert('docx', 'pdf', api.createParams()) + + const params = api.createParams() + params.add('File', first.toParamFile()) + params.add('Files', first.toParamFiles()) + await api.convert('pdf', 'jpg', params) + + assert.deepEqual(JSON.parse(calls[1].init.body).Parameters, [ + { Name: 'File', FileValue: { Id: 'f1' } }, + { Name: 'Files', FileValues: [{ Id: 'f1' }] } + ]) +}) diff --git a/test/live.test.js b/test/live.test.js new file mode 100644 index 0000000..5bbe484 --- /dev/null +++ b/test/live.test.js @@ -0,0 +1,44 @@ +// Converts real files through the API. Runs only when CONVERT_API_SECRET holds an API token; CI points +// CONVERT_API_HOST at the staging API (stag-v2.convertapi.com) and uses a staging token. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import './setup.js' +import ConvertApi from '../dist/convertapi.js' + +const token = process.env.CONVERT_API_SECRET +const host = process.env.CONVERT_API_HOST || 'v2.convertapi.com' +const skip = token ? false : 'CONVERT_API_SECRET is not set' + +test('uploads a file, converts it and chains the result', { skip, timeout: 120_000 }, async () => { + const api = ConvertApi.auth(token, host) + + const params = api.createParams() + params.add('File', new File(['Hello from convertapi-js'], 'hello.txt', { type: 'text/plain' })) + const pdf = await api.convert('txt', 'pdf', params) + assert.equal(pdf.files.length, 1) + assert.equal(pdf.files[0].FileExt, 'pdf') + assert.ok(pdf.files[0].FileSize > 0) + + const next = api.createParams() + next.add('File', pdf.toParamFile()) + const jpg = await api.convert('pdf', 'jpg', next) + assert.equal(jpg.files[0].FileExt, 'jpg') + + const download = await fetch(jpg.files[0].Url) + assert.equal(download.status, 200) +}) + +test('converts a remote file by URL', { skip, timeout: 120_000 }, async () => { + const api = ConvertApi.auth(token, host) + const params = api.createParams() + params.add('File', new URL('https://cdn.convertapi.com/cara/testfiles/presentation.pptx')) + const res = await api.convert('pptx', 'pdf', params) + assert.equal(res.files[0].FileExt, 'pdf') +}) + +test('rejects an invalid token', { skip, timeout: 60_000 }, async () => { + const api = ConvertApi.auth('not-a-valid-token', host) + const params = api.createParams() + params.add('Url', 'https://www.convertapi.com') + await assert.rejects(api.convert('web', 'pdf', params), err => typeof err.Code === 'number' && typeof err.Message === 'string') +}) diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 0000000..30ff1a9 --- /dev/null +++ b/test/setup.js @@ -0,0 +1,23 @@ +// The library is written for browsers. Node.js 20+ has fetch, File and URL, but no FileList, which +// Params.add() checks with instanceof, so the tests give it an empty stand-in before importing dist/. +if (typeof globalThis.FileList === 'undefined') { + globalThis.FileList = class FileList {} +} + +// Replaces globalThis.fetch for one test with a stub that records each request and answers with the +// next of the given responses ({ status, body }). +export function stubFetch(t, ...responses) { + const calls = [] + const original = globalThis.fetch + globalThis.fetch = async (url, init = {}) => { + calls.push({ url: String(url), init }) + const next = responses.shift() + if (!next) throw new Error(`Unexpected request to ${url}`) + return new Response(JSON.stringify(next.body), { + status: next.status ?? 200, + headers: { 'content-type': 'application/json' } + }) + } + t.after(() => { globalThis.fetch = original }) + return calls +} From fdaab22e6012a6c9099ee5a92e99bf485fc4ed46 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Tue, 20 Oct 2026 11:14:41 +0300 Subject: [PATCH 2/2] Reject leading zeroes in numeric pre-release identifiers Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 189a89d..9e59841 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -82,7 +82,8 @@ jobs: run: | set -euo pipefail # SemVer 2.0 without build metadata (npm drops "+..." and it would only confuse). - SEMVER='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' + # Numeric pre-release identifiers have no leading zeroes (v1.2.0-beta.01 is not SemVer). + SEMVER='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?$' if ! [[ "$TAG" =~ $SEMVER ]]; then echo "::error::Tag '$TAG' is not v{major}.{minor}.{patch} or v{major}.{minor}.{patch}-{pre-release}, e.g. v1.1.10 or v1.2.0-beta.1" exit 1