🟡

npmに公開していたパッケージをjsrにもpublishしてみた

2024/06/04に公開

DenoがJSRというnpmとは別のjs/ts用のレジストリを公開しました。DenoではなくNode.jsを対象としたTypeScriptのパッケージもJSRにpublishできるとのことなので、試しに自分がnpmに公開していたパッケージをJSRにも公開してみました。

最初にゴールを書いておきます。この記事はこれらを達成するために行った作業を記録したものです。

  • 今までnpmに公開していた junit2json をJSRにも公開する
  • npm -> JSRへの乗り換えではなく、両方とも同じバージョンで公開する
  • JSRへのpublishもnpm同様にGitHub Actionsで自動化
  • JSR公開時にはprovenanceを付与する

JSRのパッケージ作成

まず最初にスコープとパッケージをjsrのサイトから作る必要があります。
https://jsr.io/docs/publishing-packages#creating-a-scope-and-package

スコープは既に @kesin11 を取得済み。最終的にnpmと同様に junit2json という名前でpublishしたいので、この名前でパッケージも作っておきます。
jsr1

Createを押すと次の画面で手順が表示される。わかりやすい。
jsr.jsonを用意し、そこで name を指定できるみたいです。実はnpm用のpackage.jsonの方では junit2json となっているので、JSRの方だけ @kesin11/junit2json にリネーム可能なのかちょっと心配していたのですが杞憂でした。

最初は実験のためにローカルからpublishしますが、最終的にはGitHub Actionsから全自動でpublishすることになるので今の段階でLinkだけはしておきましょう。
Kesin11/ts-junit2jsonがリポジトリなので入力して"Link"を押します。

jsr2

リンクが成功して右側のGitHub Actionsからpublishするサンプルコードが表示されました。npmと異なりjsrにpublishする際はトークンを一切使用しないコードなのがちょっと不思議ですが、これについては後で実際にpublishする際に考えましょう。

jsr3

そろそろ実際にpublishに必要なコードを書いていきます。まずは先ほど表示されていたjsr.jsonをコピーして実際のファイルを作成します。versionはとりあえず0.1.0のままにしておきます。exports はエントリポイントのファイルを指定するようなので ./src/index.ts にします。

npmではtsをそのままpublishできないのでトランスパイル後のjsである "main": "./dist/index.js", などと指定する必要がありました。まずここがnpmと異なる点ですね。

{
  "name": "@kesin11/junit2json",
  "version": "0.1.0",
  "exports": "./src/index.ts"
}

先ほどの表示されていたガイドではこの3項目だけなのでこのままでもpublishできると思いますが、npmに公開していたときのpackage.jsonを踏襲して次はpackage.jsonの files に相当する設定を追加します。

https://jsr.io/docs/publishing-packages#filtering-files

jsr publish は.gitignoreのファイルは自動的にpublish対象から外してくれるらしいですが、自分で明示的にinclude/excludeを設定も可能なようです。npmでも files で明示的にpublish対象のファイルを設定していたので、ドキュメントにあるように LISCENSE, README.md, src だけをincludeにしておきます。

{
  "name": "@kesin11/junit2json",
  "version": "0.1.0",
  "exports": "./src/index.ts",
  "publish": {
    "include": [
      "LICENSE",
      "README.md",
      "src/**/*.ts"
    ]
  }
}

実際にpublishする前に npx jsr publish コマンドにはどういう機能があるのかヘルプを確認しておきます。

$ npx jsr --help
jsr.io cli for node

Usage:
     jsr add @std/log  Install the "@std/log" package from jsr.io.
  jsr remove @std/log  Remove the "@std/log" package from the project.

Commands:
              <script>  Run a script from the package.json file
          run <script>  Run a script from the package.json file
       i, install, add  Install one or more JSR packages.
  r, uninstall, remove  Remove one or more JSR packages.
               publish  Publish a package to the JSR registry.
      info, show, view  Show package information.

Options:
      -P, --save-prod  Package will be added to dependencies. This is the default.
       -D, --save-dev  Package will be added to devDependencies.
  -O, --save-optional  Package will be added to optionalDependencies.
                --npm  Use npm to remove and install packages.
               --yarn  Use yarn to remove and install packages.
               --pnpm  Use pnpm to remove and install packages.
                --bun  Use bun to remove and install packages.
            --verbose  Show additional debugging information.
           -h, --help  Show this help text.
        -v, --version  Print the version number.

Publish Options:
     --token <Token>  The API token to use when publishing. If unset, interactive authentication will be used.
           --dry-run  Prepare the package for publishing performing all checks and validations without uploading.
  --allow-slow-types  Allow publishing with slow types.
        --provenance  From CI/CD system, publicly links the package to where it was built and published from.

Environment variables:
        JSR_URL  Use a different registry URL for the publish command.
  DENO_BIN_PATH  Use specified Deno binary instead of local downloaded one.

publish以外jsrのパッケージをインストールしたりするためのコマンドなので関係なさそうです。 --dry-run オプションはこのあとの実験で使えそう。
--token はローカルから実験的にpublishするのに使うのか?
--provenance は多分 npm publish --provenance と同じだろうからGitHub Actionsからpublishする際に使いそうな気がします。

ちょっと気になったのは、npmでは npm version でpackage.jsonの version を書き換えたりタグ打ちを自動でやってくれたましたが、それっぽいコマンドが一覧に存在しない。 jsr.jsonversionを書き換えるようなコマンドは jsr には提供されてなさそう?

探してみたらissueはありました。まだ実装されてなさそう(コントリビューションチャンス?)
https://github.com/jsr-io/jsr/issues/336
https://github.com/jsr-io/jsr/issues/280

最終的にはpackage.jsonとjsr.jsonでバージョンを揃えたいので何かしらの方法を考える必要がありますが一旦後回し。

ローカルからのpublish

とりあえずはv0.1.0としてローカルから jsr publish してみます。

$ npx jsr publish
Downloading JSR binary...
[00:01] [#################################################>] 44.3 MiB/44.7 MiB
Checking for slow types in the public API...
Visit https://jsr.io/auth?code=WYJQ-JMZB to authorize publishing of @kesin11/junit2json
Waiting...

ブラウザで認証画面が開いたのでApproveします。

jsr4

Authorization successful. Authenticated as Kenta Kase
Publishing @kesin11/junit2json@0.1.0 ...
Successfully published @kesin11/junit2json@0.1.0
Visit https://jsr.io/@kesin11/junit2json@0.1.0 for details

Completed in 1m

えええ、これだけでpublishできてしまった。実際にローカルからpublishしたときのv0.1.0がこれです。
https://jsr.io/@kesin11/junit2json@0.1.0

トークンの発行とか一切なかったのですがこれでいいのか・・・。@kesin11 のスコープに対して権限を持っているアカウント(自分)がブラウザからApproveしたことで2FAにはなる。確かにトークンとか無しに認証はこれで十分なのかもしれない。

ちなみに現時点でのJSRのscoreは70%でした。ドキュメントとprovenanceが足りないようです。
jsr5

ちなみにJSRはREADME用のバッジも提供してくれています。このような記述をREADMEに追加するだけです。

[![JSR](https://jsr.io/badges/@kesin11/junit2json)](https://jsr.io/@kesin11/junit2json)

ドキュメント(JSDoc)

ちょうどいい機会なのでJSDocを書こうかと思いましたが、直すたびにjsrへpublishして確認するのは面倒なのでまずローカルで確認できる環境を整えます。

https://deno.com/blog/document-javascript-package

によるとDeno用の deno doc のコマンドがそのまま使えるらしいです。試してみると、junit2jsonはもともとDenoで書いていないので色々エラーになりました。

$ deno doc --html --name=junit2json src
error: Failed resolving 'fs' from 'file:///home/kesin/github/kesin11/ts-junit2json/src/cli.ts'.

import fs from 'fs' が解釈できないようです。これはDenoの限らず今では import fs from 'node:fs' と書く方が現代的なので直しましょう。

$ deno doc --html --name=junit2json src
error: Failed resolving 'yargs' from 'file:///home/kesin/github/kesin11/ts-junit2json/src/cli.ts'.

yargs は3rdパーティのパッケージですが、Denoの流儀でimportしていないので解決できなかったようです。Denoにnode_modulesを読み込むための --unstable-byonm オプションがあるのでこれを追加しましょう。

$ deno doc --unstable-byonm --html --name=junit2json --lint src
error: Failed resolving './index.js' from 'file:///home/kesin/github/kesin11/ts-junit2json/src/cli.ts'.

import { parse } from './index.js が解釈できないらしいです。ここまでくればtsをトランスパイルしてdist/に.jsを生成すれば多分動くと思いますが、これはimportする際の探索パスに.tsを含めるかどうかの問題のはず。Denoのオプションにimportの挙動を緩める--unstable-sloppy-imports があるのでこれも追加してみましょう。

$ deno doc --unstable-byonm --unstable-sloppy-imports --html --name=junit2json src
Warning Sloppy module resolution (hint: update .js extension to .ts)
    at file:///home/kesin/github/kesin11/ts-junit2json/src/cli.ts:5:23
Written 17 files to "./docs/"

エラーなく完走して生成できたようです。最終的なコマンドはこのようになりました。

deno doc --unstable-byonm --unstable-sloppy-imports --html --name=junit2json src

ローカルでドキュメントを確認する方法を用意できたのでJSDocを書いていきます。
といっても、junit2jsonは関数1つと型しかexportしておらず、型情報に関してはTypeScriptの型をそのままドキュメントに使ってくれるのでJSDocで型の二重管理をする必要はないでしょう。結局exampleを用意しておくぐらいになりました。

/**
 * Parses the given JUnit XML string into a JavaScript object representation using xml2js library.
 * 
 * @example Basic usage
 * ```ts
 * import { parse } from 'junit2json'
 * 
 * const junitXmlString = "..."
 * const output = await parse(xmlString)
 * ```
 * 
 * If you want to filter some tags like `<system-out>` or `<system-err>`, you can use `replacer` function argument in [`JSON.stringify()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify).
 * 
 * @example Filter some tags
 * ```ts
 * import { parse } from 'junit2json'
 * 
 * const junitXmlString = "..."
 * const output = await parse(xmlString)
 * const replacer = (key: any, value: any) => {
 *   if (key === 'system-out' || key === 'system-err') return undefined
 *   return value
 * }
 * console.log(JSON.stringify(output, replacer, 2))
 * ```
 */
export const parse = async (xmlString: convertableToString, xml2jsOptions?: OptionsV2): Promise<TestSuites|TestSuite|undefined|null> => {

一応再度jsrにpublishしてドキュメントを確認。

https://jsr.io/@kesin11/junit2json@0.1.1/doc

ローカルで確認したときと同じだったので deno doc --html を信用しても大丈夫そうです。

package.jsonとjsr.jsonのversionを揃えてpublish

package.jsonとjsr.jsonのversionを揃えるためにpackage.jsonからjsr.jsonにコピーします。GitHub Copilot Chatに聞いてみたらワンライナーを生成してくれました。このワンライナーを npm run jsr:version として呼び出せるようにpackag.jsonに書いておきます。

"jsr:version": "jq -r '.version' package.json | xargs -I {} jq '.version = \"{}\"' jsr.json > temp.json && mv temp.json jsr.json"

npm versionはpackage.jsonのversionを書き換えた後に自動的にgit tagを打ってコミットまでしてくれるコマンドなのですが、npm run jsr:version でpackage.jsonのバージョンをコピーしてからコミットしたいので、その前にコミットされると困ります。仕方がないのでタグ打ちとコミットを行わないようになる --no-git-tag-version オプションを付けて、jsr.jsonにバージョンをコピーした後に自前でgit commitしてからpublishすることにした。

まずは実験なのでprereleaseバージョンでpublishしてみます。

# package.jsonのバージョンを書き換え
npm version prerelease --no-git-tag-version && \
  # jsr.jsonにバージョンをコピー
  npm run jsr:version && \
  # package.jsonとjsr.jsonをコミット
  git add jsr.json package*.json && \
  jq -r '.version' package.json | xargs -I {} git commit -m "{}"

# npmとjsrにpublish
npm publish --provenance --tag=beta && \
  npx -y jsr publish

実際にworkflow_dispatchでprereleaseから試してみます。
https://github.com/Kesin11/ts-junit2json/actions/runs/9244382548/job/25429610553

npmとjsrに同じバージョンでpublishできました。
https://jsr.io/@kesin11/junit2json@3.1.10-2
https://www.npmjs.com/package/junit2json/v/3.1.10-2

npmではこの実験的なバージョンが普通に npm install したときにインストールされないようにチャンネルを分けて beta という名前でリリースしています。jsrではチャンネルの概念は存在しないようですが、3.1.10-2 のようにsemverとしてprereleaseなバージョンであれば自動的に認識されて、通常のインストールではこのバージョンは使われない挙動になるようです。

jsr6

ちなみにjsrへのpublishは、npmや他の多くの言語のレジストリで当たり前に必要なトークンを使わずにGitHub Actionsからpublish可能です。素晴らしい!代わりにGitHub Actionsのワークフローのyamlで以下のように permissionsid-token: write を指定する必要があります。

permissions:
  contents: write # 最低でもreadは必要だが、どのみちGitHub Releasesに新しいバージョンを公開するためにwriteが必要
  id-token: write

トークン無しでpublish可能な理由はOIDCの仕組みを利用していると想像できるのですが、jsrのドキュメントを改めて読んでも詳しい仕組みの解説を見つけることはできませんでした。要件としてあらかじめGitHubリポジトリとリンク済みであることと、先ほどの id-token: write について書かれたドキュメントがあるぐらいです。

気になる人は自分で探してみるか、将来誰かが記事を書いてくれるのを待ちましょう。とりあえずjsrはトークン無しでpublishできて画期的であるということを覚えてみんなに広めてください。

JSRにpublishしたパッケージの動作確認

Deno

Denoはjsrをネイティブに扱えるのでimportに jsr: でパッケージ名を指定するだけで使えます。

https://jsr.io/docs/introduction#using-jsr-packages

junit2jsonのREADMEに書いていたサンプルコードのimportを以下のように変更しただけで動きました。

// jsr_deno.ts
import { parse } from "jsr:@kesin11/junit2json@3.1.10-2";

const main = async () => {
  const xmlString = `<?xml version="1.0" encoding="UTF-8"?>
  <testsuites name="gcf_junit_xml_to_bq_dummy" tests="2" failures="1" time="1.506">
    <testsuite name="__tests__/basic.test.ts" errors="0" failures="0" skipped="0" timestamp="2020-01-26T13:45:02" time="1.019" tests="1">
      <testcase classname="convert xml2js output basic" name="convert xml2js output basic" time="0.01">
      </testcase>
    </testsuite>
    <testsuite name="__tests__/snapshot.test.ts" errors="0" failures="1" skipped="0" timestamp="2020-01-26T13:45:02" time="1.105" tests="1">
      <testcase classname="parse snapshot nunit failure xml" name="parse snapshot nunit failure xml" time="0.013">
        <failure>Error: Something wrong.</failure>
      </testcase>
    </testsuite>
  </testsuites>
  `

  const output = await parse(xmlString)
  console.log(JSON.stringify(output, null, 2))
}
main()

Node.js

今回はパッケージの動作確認なのでややこしくならないように tsc やバンドラーは介さず、素のNode.jsで使えることを確認します。

jsrがESMしかサポートしていないので、まずはプロジェクト全体をデフォルトのcommonjsからESMに変更する必要があります。最低限 "type": "module"が書かれたpackage.jsonを用意します。

{
  "name": "jsr_node",
  "private": true,
  "type": "module",
}

jsrのドキュメントに書かれているように npx jsr add @kesin11/junit2json@3.1.10-2 でパッケージを追加すると dependenciesnpm:@jsr/ 付きでパッケージ名が書き込まれます。kesin11__junit2json という微妙に違った名前ですがスラッシュがエスケープされてそうな雰囲気を感じます。

{
  "name": "jsr_node",
  "private": true,
  "type": "module",
  "dependencies": {
    "@kesin11/junit2json": "npm:@jsr/kesin11__junit2json@^3.1.10-2"
  }
}

同時に.npmrcも作成され、 @jsr はいつものnpmレジストリではなくnpm.jsr.ioを向くように設定が書かれていました。

@jsr:registry=https://npm.jsr.io

このあたりはjsrがnpmとの互換レイヤーのためにそうせざるを得なかった雰囲気をjsrのドキュメントから察することができます。npm install を使ってjsrのパッケージを追加する場合はこのあたりをしっかり理解する必要がありますが npx jsr add で追加する分には、上記のようにほぼ意識しなくても使えるようにやってくれます。

あとはNode.jsなので.tsではなく.jsを用意し、普通にimportを書くだけで動きました。意外と簡単ですね。

// jsr_node.js
import { parse } from "@kesin11/junit2json";

const main = async () => {
// Denoのコードと同じなので省略
}
main()

ちなみにNode.jsで使う場合には当然 node_modules にインストールした実体が置かれるわけですが、jsrへのpublishはトランスパイルした.jsではなく.tsを直接アップロードしていました。では何が node_modules に追加されたのか?ちょっと見てみましょう。

$ ls node_modules/@kesin11/junit2json/**/*
node_modules/@kesin11/junit2json/src/cli.ts
node_modules/@kesin11/junit2json/src/index.js
node_modules/@kesin11/junit2json/src/index.js.map
node_modules/@kesin11/junit2json/src/index.ts

node_modules/@kesin11/junit2json/_dist/src:
index.d.ts  index.d.ts.map

index.jsと型定義の.d.ts、それぞれに対応したsourcemapの.mapが存在していることがわかります。npmと違ってjsrにpublishする場合は自分でts -> jsに変換はしていないので、これらはjsr側が自動的に生成してくれたファイルということですね。
今までnpmにpublishする場合にはtsconfig.jsonのオプションを理解し、.jsに加えて.d.tsや.mapも正しく生成する必要がありましたが、jsrの場合は.tsから全て自動で生成してくれるので圧倒的に楽です。

GitHub ActionsからのPublishとProvenance

最後にGitHub Actionsからprereleaseではなく実際に次のバージョンをpublishします。
コードは先ほどのprereleaseのときとほぼ同じで、 npm version prerelease の代わりに npm version $VERSION で外から次のバージョンを指定できるようにしているだけです。junit2jsonは自分の趣味で無駄に複雑なフロー[1]になっていて参考にならないので省略します。

リリース用のワークフローは workflow_dispatch トリガーを設定しているので手動で実行します。
https://github.com/Kesin11/ts-junit2json/actions/runs/9257531431

無事に完走してv3.1.10としてnpmとjsrにリリースされました。

最後にjsrでもProvenanceが設定されているかを確認します。Provenanceは最近のサプライチェーン攻撃への対策の1つで、パッケージがどこでビルドされたかを公開することで信頼性を高めるものです。実はnpmは以前からProvenanceをサポートしている[2]のでまずはnpmの方を見てみましょう。

npmはGitHub Actionsからpublishする際に npm publish --provenance オプションを付けることでnpmのページ下部にこのような表示が追加され、publishが実行されたビルドログへのリンクが追加されます。

jsr7

ぜひ以下のURLから一番下に表示されているProvenanceの項目の View build summary をクリックしてみてください。上述のPublishが行われたビルドログである github.com/Kesin11/ts-junit2json/actions/runs/9257531431 にリンクされているはずです。

https://www.npmjs.com/package/junit2json/v/3.1.10

ではjsrの方も見てみましょう。jsrもGitHub ActionsからPublishするとnpmと若干似ている表示がページの下部に追加されます。 jsr publish にも --provenance オプションは存在しますが、単に jsr publish だけでも表示されていたのでデフォルトで有効なようです。

jsr8

https://jsr.io/@kesin11/junit2json@3.1.10

View transparency log をクリックするとsigstoreのページが開きます。npmでは単にGitHub Actionsのビルドログへのリンクだけでしたが、もっと色々な情報が表示されています。

jsr9

下の方に Run Invocation URI: https://github.com/Kesin11/ts-junit2json/actions/runs/9257531431/attempts/1 というURLを見つけることができ、jsrにもnpm同様にProvenanceが設定されていることが確認できました。

まとめ

この記事は最初に書いていた以下のゴールを達成するための作業を記録したものでした。

  • 今までnpmに公開していた junit2json をJSRにも公開する
  • npm -> JSRへの乗り換えではなく、両方とも同じバージョンで公開する
  • JSRへのpublishもnpm同様にGitHub Actionsで自動化
  • JSR公開時にはprovenanceを付与する

実際にやってみた感想としては、DenoではなくNode.jsによるTypeScriptで書かれたパッケージであってもjsrに公開するのはnpmと比べて圧倒的に簡単です。正直、jsrの体験と比べると今まで使っていたにも関わらずnpmはかなり面倒くさいと感じてしまいますね。

今後新しいパッケージを作成した場合にもうjsrだけで公開したいぐらいなのですが、さすがにまだしばらくはjsrとnpmの両方で公開することになるかなあ。jsrが流行ればnpmもjsrの良いところを取り込んで発展してくれる可能性もあると思うので、みんなでjsrを流行らせていきましょう。

脚注
  1. GitHub Actionsから手動トリガーでpublishするワークフローを組んでいますが、次期バージョンをrelease-drafterで決定し、実際のビルドの大半はEarthlyというビルドツールで行っています。興味がある人はリリースのワークフローEarthfileを見てみてください。 ↩︎

  2. https://docs.npmjs.com/generating-provenance-statements ↩︎

Discussion