Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

GitHub Actions로 npm publish 자동화하기

npm 모듈을 배포해서 사용하는 경우 예전에는 npm 레지스트리를 사용했지만 이젠 GitHub Packages가 있으므로 GitHub 저장소를 이용하고 Actions를 쓴다면 GitHub Packages도 대안이 될 수 있다. 특히 공개적으로 쓰는 모듈이 아니라 내부에서만 쓴다면 npm을 결제해서 쓸 필요 없이 바로 GitHub Packages를 사용하는 게 더 편하다.(회사 자체는 npm도 GitHub이긴 하지만...) 이 글에 의도가 꼭 GitHub Packages를 써야 하는 건 아니지만 여기서는 GitHub Packages를 사용했다.

이런 모듈의 경우 레지스트리의 배포를 해야 하므로 로컬에서 매번 하는 대신 GitHub Actions에서 자동으로 발행되게 하면 더 편하게 배포를 할 수 있다. 오픈소스의 경우 npm을 이용할 때 몇 번의 사고로 2FA를 이용하고 있어서 자동화하기 어려운 면이 있는데(요즘 공개적으로 모듈을 배포할 일이 없어서 따로 연구는 안 해봤다.) 회사 등에서 쓰는 경우에는 어차피(?) 다 GitHub이므로 이런 부분까지 더 자동화할 수 있는 것 같다.(보안을 생각 안 해도 된다기보다 제어할 수 있는 부분이 훨씬 많고 영향 범위도 적기 때문에...)

GitHub Actions로 npm 모듈 Packages에 발행하기

GitHub 문서에 npm 레지스트리에 발행하기와 GitHub Packages에 발행하는 방법이 잘 나와 있지만 일단 publish.yaml을 만들어 보자.

name: Publish

on:
  push:
    tags:
      - 'v*'

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v2
      # Setup .npmrc file to publish to GitHub Packages
      - uses: actions/setup-node@v2
        with:
          node-version: '16.x'
          registry-url: 'https://npm.pkg.github.com'
      - run: npm install
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

약간 설명을 하자면 on 에서 v로 시작하는 태그가 푸시될 때 워크플로우가 실행되도록 했다. 배포할 때는 보통 버전을 붙이니까 태그를 이용해서 배포되도록 한 것이고 이렇게 하면 실행된 태그를 활용해서 버저닝 등에도 활용할 수 있다.

그리고 actions/setup-node.npmrc 파일을 설정해 주므로 공식 레지스트리가 아니라 GitHub Packages에 배포할 수 있게 설정해 준다. 워크플로우에서 시크릿에 GITHUB_TOKEN을 자동으로 설정해 주므로 permissions를 이용해서 이 토큰의 권한을 추가로 설정해서 packages에 쓰기 권한을 줬다.

GitHub Packages를 쓰는 경우 사용자가 org 아래 배포되므로 npm의 scopepackage.jsonname@username/module-name 형식으로 지정해 주어야 한다. 추가로 Packages를 저장소와 연결해서 권한을 확인하기 때문인지 repository 필드도 저장소를 가리키도록 설정해야 한다. 이 설정이 없으면 npm publish 할 때 404 오류가 발생한다. 404 Not Found - PUT https://npm.pkg.github.com/@outsideris%2fexample-module - The expected resource was not found., '@outsideris/example-module@0.3.3' is not in the npm registry의 오류가 발생한다.

{
  "name": "@outsideris/example-module",
  "repository": {
    "type": "git",
    "url": "https://github.com/outsideris/example-module.git"
  }
}

계정 밑의 Packages 메뉴에 가면 아래처럼 잘 배포된 것을 볼 수 있다.

GitHub Packages에 배포된 npm 모듈

이제 릴리스가 필요할 때 package.json에서 버전을 올리고 Git 태그를 붙여서 푸시하면 자동으로 퍼블리싱된다.

버전 업데이트와 태깅 자동화

보통 이렇게 사용하지만 여기서 좀 더 편하게 하고 싶었다. Docker 이미지 배포 같은 경우는 이런 워크플로우로 충분하지만 npm 모듈 같은 경우에는 package.json에 버전이 있으므로(package-lock.json에도 있다) 릴리스할 때 이 버전을 반드시 올려주어야 한다. Pull Request를 중심으로 작업하는 경우 번거롭게 버전을 올리는 커밋을 따로 올려야 하는 데다가 버전을 올린 뒤에 Git 태그도 붙여주어야 하지만 Git 태그는 Pull Request로 할 수 없기 때문에 결국 릴리스는 푸시로 하게 되곤 한다. 릴리스 자체는 결국 개발자가 개입해야 하지만 이 부분까지 더 편하게 하고 싶었다.

npm에는 버전을 올려주는 npm version 명령어가 있다. npm version patch를 실행하면 패치 버전을 올려준다. 즉, 현재 버전이 0.3.4일 때 실행하면 0.3.5가 되고 상황에 따라 patch 대신 minormajor를 지정하면 된다.

$ npm version patch
v0.3.5

버전만 올려주는 게 아니라 Git 커밋도 해주고 태그도 붙여준다. 버전을 올린 후 최신 커밋을 확인해 보면 package.jsonpackage-lock.json에서 버전을 올려준 뒤 0.3.5라는 새 커밋을 만들고 여기에 v0.3.5 태그를 추가한 것을 알 수 있다.

$ git show HEAD
commit 68427365956a55386b51a9cbf5ddee4c6bd46b88 (HEAD -> master, tag: v0.3.5)
Author: Outsider <outsideris@gmail.com>
Date:   Fri Aug 13 22:35:49 2021 +0900

    0.3.5

diff --git a/package-lock.json b/package-lock.json
index d6719e0..f025c67 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@outsideris/example-module",
-  "version": "0.3.4",
+  "version": "0.3.5",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "@outsideris/example-module",
-      "version": "0.3.1",
+      "version": "0.3.5",
       "dependencies": {
         "debug": "^4.3.2"
       }
diff --git a/package.json b/package.json
index d6ef099..6520c99 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@outsideris/example-module",
-  "version": "0.3.4",
+  "version": "0.3.5",
   "repository": {
     "type": "git",
     "url": "https://github.com/outsideris/example-module.git"

간단히 말하면 npm version 명령어를 실행한 뒤에 git push만 해주면 앞에서 만든 트리거가 실행된다. 하지만 이때 npm version의 사용법을 개발자가 모두 잘 알고 있어야 하고 여전히 직접 push 해주어야 하는 문제가 있다. 너무 감쪽같이 커밋을 만들어 주기 때문에 이전에 실수로 버전을 잘못 올리고 배포해 버린 적도 있다.

bump-version.yaml 파일을 만들어서 새로운 워크플로우를 하나 추가했다.

name: Bump Version

on: workflow_dispatch

jobs:
  bump-version:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - run: git config --global user.email "github-actions@example.com"
      - run: git config --global user.name "GitHub Actions"
      - uses: actions/setup-node@v1
        with:
          node-version: 16
      - run: npm version patch
      - run: git push origin master --tags
      - uses: actions/upload-artifact@v2
        with:
          name: src
          path: ./

이 워크플로우는 workflow_dispatch로 지정했으므로 Action 메뉴에서 수동으로 실행해 주어야 한다. 배포가 필요할 때 직접 누르겠다는 의미다.

workflow_dispatch 워크플로우

여기서 "Run workflow"를 클릭하면 "Bump Version" 워크플로우가 실행되고 여기서 하는 일은 패치 버전을 올리고 생성된 커밋을 저장소에 다시 커밋한다. 커밋을 하기 위해 임의의 이름으로 Git 사용자 정보를 등록했다. 그리고 이어진 작업을 하기 위해 현재 디렉터리 전체를 아티팩트로 등록했다.

등록된 아티팩트

즉, 이 워크플로우가 버전을 올리고 릴리스 커밋과 태그를 등록해 준다. 다만 워크플로우에서 올린 푸시는 이벤트를 발생시키지 않으므로 앞에서 만든 publish.yaml 워크플로우가 트리거 되지 않는다. 그래서 publish.yaml을 다음과 같이 workflow_run을 이용하도록 수정했다.

name: Publish

on:
  workflow_run:
    workflows: ["Bump Version"]
    types: [completed]

jobs:
  publish:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      actions: read
    steps:
      - name: 'Download artifact'
        uses: actions/github-script@v3.1.0
        with:
          script: |
            var artifacts = await github.actions.listWorkflowRunArtifacts({
               owner: context.repo.owner,
               repo: context.repo.repo,
               run_id: ${{github.event.workflow_run.id }},
            });
            var matchArtifact = artifacts.data.artifacts.filter((artifact) => {
              return artifact.name == "src"
            })[0];
            var download = await github.actions.downloadArtifact({
               owner: context.repo.owner,
               repo: context.repo.repo,
               artifact_id: matchArtifact.id,
               archive_format: 'zip',
            });
            var fs = require('fs');
            fs.writeFileSync('${{github.workspace}}/src.zip', Buffer.from(download.data));
      - run: unzip src.zip
      # 중략
      - run: npm publish

아래 생략한 부분은 앞부분과 동일하므로 npm publish만 남기고 생략했다.

on:
  workflow_run:
    workflows: ["Bump Version"]
    types: [completed]

workflow_run을 지정했으므로 "Bump Version" 워크플로우가 완료되면 이 워크플로우가 실행된다. 성공/실패 여부는 여기서 조건을 걸 수 없으므로 if: ${{ github.event.workflow_run.conclusion == 'success' }}로 성공했을 때만 실행되도록 했다.

"Download artifact" 부분에서 앞에서 올린 아티팩트를 다운받게 했는데 사실상 다른 워크플로우의 아티팩트이므로 이미 만들어진 액션 대신 API로 조회해서 직접 아티팩트를 찾아서 다운받게 했고 이 코드는 GitHub의 글에서 참고해서 가져왔다. 권한 오류를 해결하기 위해 permissionsactions: read를 추가했다.(없으면 API 오류가 발생한다.) 체크아웃하지 않고 아티팩트를 받아온 이유는 앞에서 실행한 커밋의 소스를 그대로 이용하기 위함이다. 사실상 별도의 워크플로우이기 때문에 다시 실행하면 다른 커밋이 들어올 수도 있을 것 같다.

이제 배포가 필요할 때 "Bump Version"을 실행하면 버전업 커밋이 생기고 "Publish" 워크플로우가 실행되면서 GitHub Packages에 새 버전의 모듈이 배포된다.

이렇게 설정은 처음 해봤는데 배포에 실수할 여지가 줄어들어서 괜찮아 보인다. patch 버전만 올릴 수 있지만, input으로 입력 받거나(셀렉트 박스는 안되어서 실수의 여지가 있다.) Bump Version 워크플로우를 종류별로 만들어도 될 것 같다.

2021/08/13 23:39 2021/08/13 23:39