自作のnpmパッケージをGitHub Packagesにworkflowで登録する

Node.jsを利用して書いた自作のコマンドラインツールをnpm installして使えるようにしたくて、npmパッケージを作って[GitHub Packages](https://docs.github.com/en/packages)に登録することにしました。が、ドキュメント読んでもやり方が飲み込めず、試行錯誤。なんとか実現できたので、ここに手順のメモを残します。

npmパッケージを作るためのソースとして、"hello world"を出力するだけの小さなJavaScriptプログラムを使います。本記事で作成したリポジトリはこちら：
https://github.com/kazhashimoto/hello

**実行環境**

- macOS Big Sur 11.5
- Node.js 16.4.0
- npm 7.18.1
- git 2.30.1 (Apple Git-130)[^1]
- エディタ: Atom 1.58.0[^2]

[^1]: macOSプリインストール版のgitです。Atom内蔵のgitは別に存在します。
[^2]: v1.58.0現在、Atom内蔵のgitはv2.26.2です。

## 1. GitHubにリポジトリを作成する
[Create a new repository](https://github.com/new)画面で、Initialize this repository withの

- Add a README file
- Choose a license

にチェックを入れてリポジトリを作成します。[^3]

:::note info
リポジトリ作成時にREADME.mdやLICENSEファイルを一緒に作っておくと、リポジトリのdefault branchの名前が"main"に設定されます。
:::

[^3]: ローカルリポジトリのdefault branch名を"master"ではなく"main"にするには、 `git config --global init.defaultBranch main`で設定できますが、gitがinit.defaultBranchを追加したのはv2.28、一方Atom 1.58.0内蔵のgitはこれより低いv2.26.2を使っているため、Atomは.gitconfigのこの設定を無視してしまいます。Atomエディタを使用する自分としては、GitHubに合わせてdefault branchを"main"にしたい場合、GitHubのリポジトリ作成時に"main"にしといた方が後々楽だと思いました。


記事中のコードや出力例などに現れるパス名やユーザー名などは筆者の環境のものです。具体的には以下の文字列です。

| 項目 | 値 |
|:--|:--|
| リポジトリ名 | hello  |
| リポジトリの所有者  | @kazhashimoto  |
| リポジトリのURL  | https://github.com/kazhashimoto/hello.git  |
| ローカルのホームディレクトリ  | /Users/me/  |
| ローカルのclone先のディレクトリ  | /Users/me/github/  |
| npmのglobal prefix<br>`$ npm prefix -g`| /Users/me/.nodebrew/node/v16.4.0  |


## 2. 最初のnpmパッケージを作る
ローカルにclone先のディレクトリを作り、リモートのリポジトリのURLを指定してcloneを作成します。cloneしたディレクトリで[npm init](https://docs.npmjs.com/cli/v7/commands/npm-init)を実行します。

```shell-session
$ cd /Users/me/github
$ git clone https://github.com/kazhashimoto/hello.git
$ cd hello
$ npm init -y
```

package.jsonの`bin`のパス名を修正します。

```json:package.json（一部）
"bin": {
  "hello": "bin/hello.js"
},
```

.gitignoreファイルを作り、node_modules/を追加して保存します。

```shell-session
$ touch .gitignore
```
```:.gitignore
node_modules/
```

hello.jsがrequireしているパッケージをローカルにinstallします。

```shell-session
$ npm install commander debug
```

その結果、プロジェクトのディレクトリにnode_modulesが作られ、

```shell-session
$ ls
LICENSE			bin			package-lock.json
README.md		node_modules		package.json
$
$ npm ls
hello@1.0.0 /Users/me/github/hello
├── commander@8.0.0
└── debug@4.3.2
```

package.jsonとpackage-lock.binに"dependencies"が追加されているのがわかります。

```json:package.json（一部）
"dependencies": {
  "commander": "^8.0.0",
  "debug": "^4.3.2"
}
```

ここまでの状態でファイルをリモートに一旦pushします。

## 3. パッケージのinstallテスト
npmを使ってローカルの環境にインストールできるか確認します。まず、テスト用のディレクトリを作り、そこにリモートからhelloのファイル一式をcloneします。

```shell-session
$ mkdir test-package
$ cd test-package/
$ git clone https://github.com/kazhashimoto/hello.git .
```

このままではhello.jsがrequireしているパッケージがまだローカルにインストールされていないので、[npm install](https://docs.npmjs.com/cli/v7/commands/npm-install)を実行して、package.jsonの"dependencies"にリストしたパッケージすべてを`./node_modules`にダウンロードさせます。

```shell-session
$ npm install
```

"dependencies"のパッケージもここにインストールされました。

```shell-session
$ ls
LICENSE			bin			package-lock.json
README.md		node_modules		package.json
$ npm ls
hello@1.0.0 /Users/me/github/test-package
├── commander@8.0.0
└── debug@4.3.2
```

手元のtest-packageディレクトリにあるhelloのリソースをglobalにインストールした状態にするため、[npm link](https://docs.npmjs.com/cli/v7/commands/npm-link)を実行して、globalフォルダからtest-packageへのシンボリックリンクを作成します。

```shell-session
$ npm link
added 1 package, and audited 3 packages in 864ms
```

globalフォルダの下にあるbin/helloからtest-packageへのシンボリックリンクが作られました。

```shell-session
$ cd $(npm prefix -g)
$ pwd
/Users/me/.nodebrew/node/v16.4.0
$ ls -l bin/ lib/node_modules/ | grep hello
lrwxr-xr-x  1 me  staff   38  7 25 09:31 hello -> ../lib/node_modules/hello/bin/hello.js
lrwxr-xr-x  1 me  staff   34  7 25 09:31 hello -> ../../../../../github/test-package
```

globalにインストールされているのもわかります。

```shell-session
$ npm ls -g
/Users/me/.nodebrew/node/v16.4.0/lib
├── hello@1.0.0 -> ./../../../../github/test-package
└── npm@7.18.1
```

別のディレクトリに移ってhelloを実行してみます。

```shell-session
$ pwd
/Users/me
$ hello
/Users/me/.nodebrew/current/bin/hello: line 1: syntax error near unexpected token `('
/Users/me/.nodebrew/current/bin/hello: line 1: `const { program } = require("commander");'
```

このエラーは実行時のインタプリタが指定されていないためです。hello.jsの１行目に以下を追加します。

```shell:hello.js
#!/usr/bin/env node
```

実行できました。

```shell-session
$ hello
hello, world!
```

ここまでの修正をリモートにpushしておきます。

`npm link`で作成したシンボリックリンクを削除するには、通常のパッケージ削除と同じく
[npm uninstall](https://docs.npmjs.com/cli/v7/commands/npm-uninstall) -gを実行します。

```shell-session
$ npm uninstall -g hello
removed 1 package, and audited 1 package in 229ms
```

:::note info
"npm unlink"ではシンボリックリンクは削除されません。"unlink"は"uninstall"のエイリアスにすぎないので、-gオプションが必要です。
:::

globalから削除され、シンボリックリンクも消えているのがわかります。

```shell-session
$ npm ls -g
/Users/me/.nodebrew/node/v16.4.0/lib
└── npm@7.18.1
$ cd $(npm prefix -g)
$ ls -l bin/ lib/node_modules/ | grep hello
$
```

## 4. eslintをworkflowに組み込む
次に、リモートにpushした際にGitHub側で[eslint](https://github.com/eslint/eslint)が自動的に実行されるように設定してみます。
まず、ローカルのディレクトリでeslintが実行できるようにpackageを構成します。

### 4.1 eslintをpackageに追加する
ローカルに作ったパッケージのディレクトリを一旦削除し、リモートから再度cloneした後、helloが依存するパッケージもそこにインストールします。

```shell-session
$ cd /Users/kaz_hashimoto/github
$ git clone https://github.com/kazhashimoto/hello.git
$ cd hello
$ npm install
```

cloneしたディレクトリの下で、eslintパッケージをローカルにインストールします。
`npm install`は--save-devオプションを付けてを実行します。

```shell-session
$ npm install eslint --save-dev
```

--initオプションを付けてeslintを実行し、いくつかの質問に答えていくと最後に`.eslintrc.js`ファイルが現在のディレクトリに生成されます。

```shell-session
$ ./node_modules/.bin/eslint --init
✔ How would you like to use ESLint? · problems
✔ What type of modules does your project use? · commonjs
✔ Which framework does your project use? · none
✔ Does your project use TypeScript? · No / Yes
✔ Where does your code run? · node
✔ What format do you want your config file to be in? · JavaScript
Successfully created .eslintrc.js file in /Users/me/github/hello
$
$ npm ls
hello@1.0.0 /Users/me/github/hello
├── commander@8.0.0
├── debug@4.3.2
└── eslint@7.31.0
```

--save-devを付けてinstallしたので、package.jsonに"devDependencieses"が追加され、.eslintのエントリが作られました。

```json:package.json
"devDependencies": {
  "eslint": "^7.31.0"
}
```

実行できるか確認します。

```shell-session
 $ ./node_modules/.bin/eslint bin/hello.js
```

package.jsonの"scripts"項目に"lint"のエントリを追記します。直前の行に「,」を付け足すのを忘れずに。

```json:package.json（一部）
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "lint": "eslint bin/*.js"
},
```

[npm run](https://docs.npmjs.com/cli/v7/commands/npm-run-script)でeslintが実行できることを確認します。

```shell-session
$ npm run lint
> hello@1.0.0 lint
> eslint bin/*.js
$
```

ここまで行った修正を一旦リモートにpushします。

### 4.2 GitHubのworkflowにActionを追加する
GitHubのリポジトリ画面のActionsタブを開き、"set up a workflow yourself"をクリックして新しいworkflowのテンプレートを開きます。

![actions_1_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/44f224fe-05fc-0ff7-3911-d9f021469397.png)

![actions_template_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/25698689-bf32-13f4-7240-40b46207e6be.png)


Actionのテンプレートを元にlint.ymlという名前でActionのファイルを作成します。編集した箇所は下図の赤枠で囲った部分です。最後に画面右側にある"Start commit"ボタンをクリックして編集内容をcommitします。

![lint_yml_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/672eb756-d81a-dfb6-d50a-495b6eeb5b39.png)



ActionsタブのAll workflowsの一覧にlint.ymlが処理状況が表示されます。

![run_lint_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/5ecf3cbf-e14e-eb3a-675d-10cbdf25f36b.png)

lintの出力を確認するには、workflow名をクリックします。

![run_result_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/7d0da30c-4735-ff12-0bce-688ac2f13813.png)


## 5. Code scanningをworkflowに追加する
JavaScriptプログラムのcodingエラーや脆弱性を検出するためのActionもworkflowに追加しましょう。

リポジトリのSecurityタブを開き、"Code scanning alerts"の右側"Set up code scannning"ボタンをクリックします。

![codescan_1_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/0a11f45f-5875-1f20-dff8-975b8e2e9434.png)

"CodeQL Analysis"の"Set up this workflow"ボタンをクリックします。

![codescan_2_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/93840180-7275-fc31-eef4-c0457d89e3e3.png)

`codeql-analysis.yml`というテンプレートが開きます。内容はそのまま編集せずにcommitします。

![codescan_3_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/43133515-afc8-b8a6-72f7-e9e88266ae4c.png)

Actionsタブを開くと、codeql-analysis.ymlのcommitによって２つのworkflow "CodeQL"と"Lint"が wor実行されたのを確認できます。

![codescan_4_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/3269e89d-2358-3c1f-8e8b-a010eef72a84.png)

ステップ4と5で作成した`.github/workflows/lint.yml`および`codeql-analysis.yml`はまだリモートリポジトリのみにあるため、pullしてローカルリポジトリにも反映させておきましょう。

## 6. GitHub Packagesにnpmパッケージとして登録する
### 6.1 ローカルのディレクトリから登録
GitHub Packagesにアクセスする際の認証に必要となるpersonal access token (PAT)を入手します。
GitHubアカウントのプルダウンメニューから"Settings" > "Developer settings" > [Personal access tokens](https://github.com/settings/tokens)をクリックします。"Generate new token"ボタンをクリックしてトークンの編集画面を開きます。scopeのwrite:packagesにチェックを入れて画面下の"Generate token"ボタンをクリックします。
![token_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/a513bc2c-155f-9d82-ebb9-d0423ed30be0.png)

"Personal access tokens"画面に表示された"ghp_"で始まるtokenの文字列をコピーしておきます。

ローカルのパッケージのディレクトリで[npm login](https://docs.npmjs.com/cli/v7/commands/npm-adduser)を実行します。下記出力例で、Usernameは自分のGItHubユーザー名、Passwordに対するTOKENは上記で取得したPATの文字列に置き換えて入力します。

```shell-session
$ cd hello
$ npm login --scope=@kazhashimoto --registry=https://npm.pkg.github.com

> Username: kazhashimoto
> Password: TOKEN
> Email: (email address)
```

`npm login`を実行すると[~/.npmrc](https://docs.npmjs.com/cli/v7/configuring-npm/npmrc)ファイルが作られて、次の行が書き込まれます。

```
//npm.pkg.github.com/:_authToken=TOKEN
```

package.jsonに"publishConfig"項目を追加し、"registry"のURLを指定します。

```json:package.json（一部）
"publishConfig": {
  "registry": "https://npm.pkg.github.com"
}
```

"name"項目はscopeを指定した形式に書き換えます。

```json:package.json（一部）
"name": "@kazhashimoto/hello",
```

`npm publish`でパッケージをレジストリに公開する際、`node_modules/`や`.github/`ディレクトリの中身は不要なので、これらを除外するように[.npmignore](https://docs.npmjs.com/cli/v7/commands/npm-publish)ファイルに書いておきます。

```shell-session
$ touch .npmignore
```
```:.npmignore
node_modules/
.github/
```

ステップ6.1で更新したファイルを一旦リモートにpushします。

[npm publish](https://docs.npmjs.com/cli/v7/commands/npm-publish)を実行してGitHub Packagesのレジストリにパッケージを登録します。

```shell-session
$ npm publish
```

しばらくすると、Repositoryページ右側のPackages欄に、登録したパッケージが表示されます。


### 6.2 GitHub Packagesからのインストール
GitHub Packagesのレジストリからパッケージをインストールしてみましょう。ローカルにディレクトリを作って、`npm init`を実行します。レジストリのURLを指定するため、同じディレクトリに`.npmrc`ファイルをエディタで作成します。

```shell-session
$ mkdir test-package
$ cd test-package/
$ npm init -y
$ touch .npmrc
```

.npmrcには以下の行を記述します。

```:.npmrc
@kazhashimoto:registry=https://npm.pkg.github.com
```

scopeを指定して、パッケージをglobalにインストールします。

```shell-session
$ npm install -g @kazhashimoto/hello@1.0.0
```

```shell-session
$ npm ls -g
/Users/me/.nodebrew/node/v16.4.0/lib
├── @kazhashimoto/hello@1.0.0
└── npm@7.18.1
```

インストールできました。

パッケージをアンインストールします。

```shell-session
$ npm uninstall -g @kazhashimoto/hello
```

## 7. GitHub Packagesへの登録をworkflowに組み込む
### 7.1 publish.ymlの作成
ステップ6では手作業でパッケージをpublishしました。最後に、GitHubのリポジトリ上で新しいリリースを作成した時に、その時点でのパッケージも生成してGitHub Packagesのリポジトリに登録する処理をworkflowに組み込みます。

Repository画面のActionsタブ > "New workflow" > "set up a workflow yourself"でエディタを開き、
[Publishing packages to GitHub Packages](https://docs.github.com/en/actions/guides/publishing-nodejs-packages#publishing-packages-to-github-packages)
のExample workflowセクションに記載されているYAMLのソースコードをエディタにコピー＆ペーストします。`node-version`の値を対象バージョンに書き換え、`scope`の値を自分のGitHubアカウント名に書き換えます。

![publish_yml_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/00ee92fb-9b09-f4c7-0929-b4c253f08611.png)

ファイル名をpublish.ymlとしてcommitします。

３個目のworkflow "Node.js Package"が追加されました。
![all_workflows_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/502c87aa-c660-5f02-7603-eb81b46b04f9.png)

### 7.2 publishの実行
GitHubリポジトリのReleases画面で"Draft a new release"ボタンをクリックします。tagなどを入力し、"Publish release"ボタンをクリックします。新しいリリースが作成されると共に、workflow画面で"Node.js Package"が実行されるのを確認できます。

![npm-publish_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/efebf8de-9c67-1879-f168-c00089aa51f4.png)

パッケージが登録されました。

![github-package.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/656b7ea4-5589-98a2-fc14-43daaf819391.png)

パッケージのバージョンを更新するには、ローカルでpackage.jsonの`version`の値を更新してからリモートにpushし、新しいリリースを作成します。

## 8. GitHub Packagesからのインストール
登録したhelloパッケージをnpmを使ってインストールしてみましょう。

```shell-session
$ mkdir test-package
$ cd test-package
$ npm init -y
```

作業ディレクトリに`.npmrc`ファイルを作成し、scope `@kazhashimoto`に対するレジストリのURLを書いて保存します。

```shell-session
$ touch .npmrc
```
```:.npmrc
@kazhashimoto:registry=https://npm.pkg.github.com
```

パッケージの画面（上記7.2）に表示された"Install from the command line"に表示された行をコピーします。グローバルにインストールしたいので、コマンドラインには-gオプションを追加してインストールします。

```shell-session
$ npm install @kazhashimoto/hello@1.0.6 -g
```

インストールされました。

```shell-session
$ npm ls -g
/Users/me/.nodebrew/node/v16.4.0/lib
├── @kazhashimoto/hello@1.0.6
└── npm@7.18.1
```
```shell-session
$ hello
hello, world!
```

アンインストールします。

```shell-session
$ npm uninstall @kazhashimoto/hello -g
```


手順終わり。