Search
Duplicate
👨🏻‍💻

[NPM 패키지 배포] Github Action과 Docker Hub를 사용한 자동배포 Tool 개발기

간단소개
팔만코딩경 컨트리뷰터
ContributorNotionAccount
주제 / 분류
Javascript
태그
Scrap
8 more properties
학교 팀 프로젝트 주제가 iOS 어플리케이션으로 결정되면서 백엔드와 배포관련 작업을 맡게 되었다. 프론트 위주로만 공부하다보니 Docker, nginx 등에 대한 이해가 부족해서 CICD 파이프라인을 하나 만드는 데에도 이틀이 넘게 걸렸다.
주변 프론트엔드 엔지니어들도 나와 비슷한 어려움을 겪고 있는 것 같아서 프로젝트에 Github Action과 Docker, Docker hub를 활용한 자동배포 툴을 개발해 보려고 한다!
핵심 아이디어는, 내가 선택한 프레임워크, 설정에 따라서 github workflow를 만들어주고, Dockerfile혹은 Docker-compose.yml을 만들어 주는 도구이다.

구조 그려보기

우선 생각한 구조는 아래와 같다. 패키지를 실행하면, 사용자로부터 입력을 받을 수 있고, 해당 입력을 활용해서 workflow 파일을 만들어주는 간략한 구조이다.

NPX 선택!

NPX is a package runner that allows you to run CLI tools or executables hosted on NPM without the need to install them with NPM first.
어떤 방식을 사용할지 많은 고민을 하다가 npm에 배포한 뒤, npx로 실행하면 될 것 같다고 생각했다. 우선 javascript로 개발하는 것이 가장 편하기도 했고(shellscript나 python을 활용한 실행프로그램 보단..) npx라는 좋은 도구가 있어서 설치 없이 실행만 하고 끝낼 수 있기 때문이다.

프로젝트 세팅

개발전 node, npm은 설치되어 있어야 한다. 간혹 버전이 맞지 않아 npx가 없을 수 있으니 npm, node 버전을 확인하고 npx가 정상적으로 동작하는지 확인해야 한다.

프로젝트 생성

프로젝트를 개발할 디렉토리 내부에서 npm init 명령을 사용한다.
# cd yourproject npm init
Shell
npm init을 하고나면 package name, author 등등 몇가지 질문을 하고 package.json 파일을 생성해 준다. 추후 package.json 파일에서 직접 수정할 수 있으니 편하게 기본 세팅으로 만들도록 한다!

Bin File 생성하기

CLI tool 혹은 실행파일을 만들려면 bin 디렉토리 안에 파일을 넣어야 한다. 아래와 같은 구조로 디렉토리를 만들고 index.js를 생성한다.
project ㄴ package.json ㄴ bin ㄴ index.js
Shell
테스트를 위해서 index.js안에 아래와 같이 입력해보자!
#! /usr/bin/env node console.log("Hello, World!");
JavaScript
파일의 가장 위에 있는 #! /usr/bin/env node 는 꼭 포함시켜야 한다. 자세한 정보는 여기

package.json 수정

실행할 명령어를 추가해주어야 하는데, deplate라고 위치한 부분에 본인이 만들 패키지 이름을 작성하면 된다.
"bin": { "deplate": "bin/index.js" }
JSON

패키지 로컬에 설치 및 실행

이제 로컬에 방금 만든 패키지를 설치해보자. 설치가 되었다면 npm ls -g 명령으로 잘 설치 되었는지 확인할 수 있다.
npm i -g
Shell
이제 npx명령으로 프로젝트를 실행해보자. 이전에 설정한 프로젝트 이름을 활용하면 된다.
# npx "yourprojectname" npx deplate
Shell
정상적으로 출력이 되는 것을 확인할 수 있다.

javascript fs 모듈 활용하기

npx의 실행결과로 workflow파일을 생성하는 것이 목표이기 때문에, 실제로 파일이 정상적으로 생기는지 확인해보자. fs 모듈을 활용해서 디렉토리 생성 후, 파일을 추가해주기로 한다. es2016+ 방식으로 모듈을 불러오기위해 package.json에 type을 추가해주었다.
"type": "module"
JavaScript
#! /usr/bin/env node import { appendFile, mkdir } from 'fs'; mkdir('./dir/test', { recursive: true }, (error) => { if (error) throw error; appendFile('dir/test/test.txt', 'Hello world!', (error) => { if (error) throw error; console.log('Saved!'); }); });
JavaScript
패키지 재설치 후 다시 실행해보면 아래 사진처럼 파일이 원하는 위치에 생성된 것을 볼 수 있다.
npm i -g && npx deplate
Shell

typescript 적용

이번 프로젝트에서 webpack을 사용하려고 시도했었는데 실패했다. 외부 모듈을 사용하려고 했는데 webpack 5부터는 node에서 polyfill모듈을 사용하는 것을 공식적으로 권하지 않는다고 한다.
작은 규모의 프로젝트여서 번들러는 필요하지 않았지만, typescript는 너무 필요했기 떄문에 우선 tsc를 활용해 컴파일만 적용시키기로 하였다.
npm i typescript --save-dev npx tsc --init
Shell
컴파일 전 코드는 src 디렉토리에 넣어두고 컴파일 한 결과를 bin으로 옮겨서 컴파일만 진행하면 패키지 파일이 정상 동작할 수 있도록 만들어 두었다.
{ "compilerOptions": { ... "outDir": "./bin" } }
JSON

상태의 타입 정의하기

typescript가 필요했던 이유는 이후 프로젝트의 사이즈를 키워서 좀 더 다양한 옵션을 주고 싶은데, 해당 옵션에 대한 type이 정의되어 있으면 추상화시킨 구조 설계를 하는데 큰 도움이 되기 때문이다. 아무래도 타입이 없으면 입력에 대한 예상이 잘 되지 않아서 함수를 추상화 하는데에도 어려움이 느껴지는 것 같다.
우선 가장 먼저 내 프로그램이 어떤 옵션을 제공할지 생각해보면서 mock data를 만들어 보았다. 우선 간단한 구조로 시작했다. 내 프로그램이 제공하는 옵션을 모아둔 WorkflowOption을 가장 먼저 만들었다. 이후, 해당 타입과 질문, validateFunction 등을 모아둔 Question 타입을 생성하였다. 결과는 아래와 같았다.
// type.ts export type WorkflowOption = { fileName: string; packageManager: string; }; // question.ts export type WorkflowOptionKey = keyof WorkflowOption; export type ValidateFunction = (input: string) => boolean; export type Question = { workflowOptionKey: WorkflowOptionKey; message: string; validate: ValidateFunction; errorMeessage: string; }; export const questions: Question[] = [ { workflowOptionKey: 'fileName', message: '📝 Enter the file name of the workflow file : ', validate: validatePackageName, errorMeessage: 'File name must be in the format of "filename.yaml" or "filename.yml".', }, { workflowOptionKey: 'packageManager', message: '📝 Enter the package manager : ', validate: validatePackageManager, errorMeessage: `Package manager must be one of ${ALL_PACKAGE_MANAGER.join(', ')}`, }, ];
TypeScript
프로그램이 출력하는 질문과 유저의 답변은 결국 이 WorkflowOption에 의존하기 때문에 존재하지 않는 key에 대한 질문, 답변은 개발 과정에서 들어가면 안되고, 없어서도 안된다. 그렇기 떄문에 이렇게 구조를 잡아둔다면, 실수할 확률도 줄어들게되고 조금 더 빠르고 효율적으로 개발할 수 있다.
하지만 개발을 진행하다 보니, 하나의 질문에 여러개의 validateFunction이 필요한 경우가 발생했다!!
validateFunction은 또 개별적인 errorMessage를 가지고 있어야 한다. 그래서 구조를 조금 변경해 주었다. Validate타입을 생성해 주었다.
export type WorkflowOptionKey = keyof WorkflowOption; export type ValidateFunction = (input: string) => boolean; export type Validate = { validateFunction: ValidateFunction; validateErrorMessage: string; }; export type Question = { workflowOptionKey: WorkflowOptionKey; message: string; validate: Validate[]; answer?: string; }; export const questions: Question[] = [ { workflowOptionKey: 'pushFileName', message: messageTemplate({ message: `Enter the file name of the workflow file for ${chalk.blue('push Action')}`, type: 'INFO', isInline: true, }), validate: [ { validateFunction: validateFileName, validateErrorMessage: messageTemplate({ message: 'The file name must be in the format of "file-name.yaml"', type: 'ERROR', }), }, { validateFunction: anotherFunction, validateErrorMessage: "some message..." }, ], }, ... ];
TypeScript
Answer 타입도 WorkflowOption 타입과 연관되어 있기 때문에 아래와 같이 정의해 주었다.
export type Answer = { [key in WorkflowOptionKey]: string; };
TypeScript

readline을 활용해 console 입력받기

workflow 파일을 생성할 때, 사용자의 입력을 활용하기 위해 readline모듈을 사용했다. 잘못된 값을 입력하는 상황을 대비해서, 잘못된 값이 들어왔을 때, 무한으로 retry할 수 있는 구조를 만들었다.
판단 조건은 질문의 타입에 따라 validateFunction을 결정할 수 있도록 하였고, 모든 함수를 순수함수 형태로 만들려고 노력했다. 특히 createInterface()가 생성하는 readline interface를 전역적으로 관리하지 않고 필요할 때 생성한 후, 해당 스코프가 끝날 때 closeReadline()을 호출해서 sideeffect가 일어나지 않도록 설계했다.
import { createInterface, Interface as ReadlineInterface } from 'readline'; import { Question } from '../questions.js'; type AsyncQuestion<T> = () => Promise<T>; export async function retry<T>(promiseFunction: () => Promise<T>): Promise<T> { return promiseFunction().catch((error: Error) => { console.log(error.message); return retry(promiseFunction); }); } export function openReadline(): ReadlineInterface { return createInterface({ input: process.stdin, output: process.stdout, }); } export function makeAsyncQuestion(readlineInterface: ReadlineInterface, question: Question): AsyncQuestion<string> { return () => new Promise((resolve, reject) => { readlineInterface.question(question.message, (answer) => { // 위에서 만들어둔 validateFunction을 적용하는 부분이다. for (const validate of question.validate) { if (!validate.validateFunction(answer)) { reject(new Error(validate.validateErrorMessage)); } } resolve(answer); }); }); } export function closeReadline(readlineInterface: ReadlineInterface): void { readlineInterface.close(); }
TypeScript
추가로 질문을 여러번 이어서 할 수 있는 상황을 대비해 다양한 util 함수를 만들었다.
export function makeAsyncQuestions( readlineInterface: ReadlineInterface, questions: Question[] ): AsyncQuestion<string>[] { return questions.map((question) => makeAsyncQuestion(readlineInterface, question)); } export function makeRetryAsyncQuestion( readlineInterface: ReadlineInterface, question: Question ): AsyncQuestion<string> { return () => retry(makeAsyncQuestion(readlineInterface, question)); } export function makeRetryAsyncQuestions( readlineInterface: ReadlineInterface, questions: Question[] ): AsyncQuestion<string>[] { return questions.map((question) => makeRetryAsyncQuestion(readlineInterface, question)); } export async function runAsyncQuestions(questions: AsyncQuestion<string>[]) { for (const question of questions) { await question(); } }
TypeScript

npm에 배포하기

이제 이렇게 만들어둔 패키지를 배포하면 끝이다!

1) npm 회원가입

패키지를 배포하기 위해 회원가입은 필수이다!

1-1) 레포지토리 생성

회원가입을 진행했으면 레포지토리를 만들어 주어야 한다. 원하는 이름으로 만들어준다. package.json에 아래와 같이 패키지이름을 통일시켜 주어야한다.
{ "name": "yourpackagename", ... }
JSON

1 - 2) organization을 만들어서 @scope를 사용한 이름으로 배포하기

만약 아래와 같이 scope를 사용한 배포를 하고싶다면, 개인 레포지토리가 아닌 organization을 만들어 주어야 한다. 프로필 → Add Organization을 눌러서 생성하면 된다.
{ "name": "@scope/yourpackagename", ... }
JSON

2) console에서 로그인하기

이제 console로 이동해서 유저 이름, 비밀번호, 이메일을 활용해 login을 진행한다.
npm login
Shell

3) 패키지 배포하기

배포할 프로젝트의 package.json이 있는 위치에서 publish를 진행한다.
npm publish
Shell

4) 설치해서 테스트하기

배포한 패키지를 설치해서 테스트 해보자!
npm install yourpackagename # or npx yourpackagename
Shell

패키지와 레포지토리 관리를 편하게 해주는 라이브러리들

1. all-contributors

all-contributors는 간편하게 컨트리뷰터를 추가할 수 있는 라이브러리이다. 몇몇 레포지토리에서 아래 사진과 같은 표를 본 적이 있다면 이 라이브러리를 사용해서 만들었을 가능성이 크다!!\
사용법은 아주 간단하다. 나는 cli 를 사용해서 추가해주었다.
cli.git
all-contributors
아래의 명령어를 사용해서 설치한다.
# 설치 yarn add --dev all-contributors-cli # or npm i -D all-contributors-cli # 초기화 yarn all-contributors init # or npx all-contributors init
Shell
이후 아래와 같은 형태로 contributor를 추가할 수 있다.
yarn all-contributors add jfmengels doc yarn all-contributors generate
Shell
chnagesets는 multi-package를 위한 version관리 도구이다. changelog와 npm 패키지 배포, github release까지 아주 손쉽게 관리할 수 있다.
나는 single package이지만, single package도 쉽게 관리할 수 있도록 되어있어서 한번 사용해보기로 하였다. github action과 github bot 등 다양한 도구들이 제작되어있다.

changeset만들기

우선 해당 라이브러리를 활용해 changeset을 만들어 주어야 한다. cli를 통해 아래와 같이 사용하면 된다.
# 설치 npm install @changesets/cli && npx changeset init ## or yarn add @changesets/cli && yarn changeset init # changeset 추가하기 npx changeset ## or yarn changeset
Shell
이후 과정인 versioning과 publishing은 cli로도 충분히 가능하지만 자동화 도구들을 사용하기로 하였다. github action document에 보면 publish까지 한번에 할 수 있는 액션 예시가 나와있다. 이 액션을 적용하기만 하면 된다.
name: Release on: push: branches: - main jobs: release: name: Release runs-on: ubuntu-latest steps: - name: Checkout Repo uses: actions/checkout@master with: # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits fetch-depth: 0 - name: Setup Node.js 14.x uses: actions/setup-node@master with: node-version: 14.x - name: Install Dependencies run: yarn - name: Create Release Pull Request or Publish to npm id: changesets uses: changesets/action@v1 with: # This expects you to have a script called release which does a build for your packages and calls changeset publish publish: yarn release version: yarn version-packages env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
YAML
이 과정을 진행하기전에 몇가지 설정이 되어있는지 확인해야한다. 1) 패키지가 2FA 없이 배포 가능한지 확인한다. (패키지 → setting → publish option)
2) NPM_TOKEN을 npmjs.com에서 생성해서 Github Secrets에 등록했는지 확인한다. 3) pacage.json에 아래의 코드를 넣어준다.
"publishConfig": { "access": "public" },
JSON
이제 changelog, npm, github release를 한번에 관리할 수 있는 준비가 끝났다.

Reference