# Verdaccio

Verdaccio is a **lightweight private npm proxy registry** built in **Node.js**

# Verdaccio

### Tổng quan về Verdaccio.

Verdaccio là một proxy server và private registry cho các package.

#### Tính năng chính.

****Lưu trữ các package****<span style="white-space:pre-wrap;"> mà không cần publish lên các package manager public.</span>

****Proxy và cache các package public****: Verdaccio có thể proxy, cache các package bạn đã tải từ đó giúp:

- Tăng tốc độ cài đặt package
- Giảm phụ thuộc vào internet hoặc registery chính.
- Cải thiện độ ổn định trong CI/CD

****Tạo môi trường phát triển an toàn và kiểm soát hơn****: Verdaccio cho phép bạn cấu hình quyền truy cập cho user, chỉ ai có quyền truy cập mới có thể publish hoặc install package cụ thể.

#### Ưu điểm chính.

****Dễ cài đặt****<span style="white-space:pre-wrap;">: Có thể cài đặt bằng lệnh </span>`<span class="editor-theme-code">npm install</span>`<span style="white-space:pre-wrap;"> hoặc trên Docker</span>

****Không cần cơ sở dữ liệu****: Dữ liệu được lưu dưới dạng file (yaml, json) phù hợp với các dự án nhỏ, trung bình.

****Tích hợp CI/CD tốt****: Hỗ trợ pipeline CI/CD để test, publish các gói nội bộ.

****Hỗ trợ plugins****: Có thể mở rộng chức năng qua plugin.

### Cài đặt Verdaccio.

Trong bài viết này mình sẽ cài Verdaccio bằng Docker trên NAS XPEnology.

#### Step 1: Prepare

<span style="white-space:pre-wrap;">Trong File Station tạo một shared folder tên là docker (có thể sử dụng cho nhiều container) và cấp quyền </span>****Read&amp;Write**** cho ContainerManager (nếu sau này gặp lỗi có thể cấp thêm quyền Read &amp; Write cho Everyone)

Tạo folder Verdaccio bên trong folder docker vừa tạo ở bước trên.

<span style="white-space:pre-wrap;">Trong folder Verdaccio tạo thêm các folder </span>`<span class="editor-theme-code">conf, plugins, storage</span>`<span style="white-space:pre-wrap;"> để mapping trong quá trình cài đặt container </span>****(Việc map folder sẽ giúp tránh mất dữ liệu khi Container gặp vấn đề, hoặc uninstall container)****

[![61447.png](https://knowledge.thanhdv.com/uploads/images/gallery/2025-05/scaled-1680-/61447.png)](https://knowledge.thanhdv.com/uploads/images/gallery/2025-05/61447.png)

<span style="white-space:pre-wrap;">Trong folder </span>****docker/Verdaccio/conf****<span style="white-space:pre-wrap;"> tạo file </span>****config.yaml****<span style="white-space:pre-wrap;"> với nội dung như sau:</span>

```yaml
max_body_size: 143mb

storage: /verdaccio/storage

plugins: /verdaccio/plugins

web:
  enable: true
  title: ThanhDV's Verdaccio

auth:
  htpasswd:
    file: ./htpasswd

packages:
  '@*/*':
    access: $all
    publish: $authenticated
    unpublish: $authenticated
  '**':
    access: $all
    publish: $authenticated
    unpublish: $authenticated

log:
  - { type: stdout, format: pretty, level: info }
```

#### Step 2: Download Image

<span style="white-space:pre-wrap;">Truy cập Container Manager trên NAS tải về image </span>`<span class="editor-theme-code">verdaccio/verdaccio:7x-next</span>`

Sau khi tải về xong chuột phải vào image và chọn Run.

#### Step 3: Setup

##### General Settings

[![37416.png](https://knowledge.thanhdv.com/uploads/images/gallery/2025-05/scaled-1680-/37416.png)](https://knowledge.thanhdv.com/uploads/images/gallery/2025-05/37416.png)

- ****Container Name:****<span style="white-space:pre-wrap;"> Đặt tuỳ ý</span>
- ****Enable Auto-restart:****<span style="white-space:pre-wrap;"> Bật để container tự khởi động lại</span>

<span style="white-space:pre-wrap;">Xong chọn </span>****Next.****

##### Advanced Settings

****Port Settings****<span style="white-space:pre-wrap;"> điền số cổng để truy cập đến Verdaccio (mặc định là 4873)</span>

[![84259.png](https://knowledge.thanhdv.com/uploads/images/gallery/2025-05/scaled-1680-/84259.png)](https://knowledge.thanhdv.com/uploads/images/gallery/2025-05/84259.png)

<span style="white-space:pre-wrap;">Sau đó nhấn </span>****Next****.

Kiểm tra lại cài đặt một lượt rồi nhấn ****Done.****

<span style="white-space:pre-wrap;">Nếu thành công bạn có thể truy cập giao diện web của Verdaccio qua http://localhost:4873 hoặc nếu bạn có domain riêng đã setup thì có thể truy cập qua domain name </span>[http://domain.com:4873](http://domain.com:4873)

[![15225.png](https://knowledge.thanhdv.com/uploads/images/gallery/2025-05/scaled-1680-/15225.png)](https://knowledge.thanhdv.com/uploads/images/gallery/2025-05/15225.png)

#### Step 4: Cài đặt Reverse Proxy (Optional)

<span style="white-space:pre-wrap;">Trên NAS, tạo Reverse Proxy mới, </span>****Control Panel &gt; Login Portal &gt; Advanced &gt; Reverse Proxy &gt; Create****

[![15446.png](https://knowledge.thanhdv.com/uploads/images/gallery/2025-05/scaled-1680-/15446.png)](https://knowledge.thanhdv.com/uploads/images/gallery/2025-05/15446.png)

****Source****

- ****Reverse Proxy Name****: Tuỳ chọn
- ****Protocol:**** HTTPS
- ****Hostname****: Địa chỉ bạn muốn sử dụng để truy cập Verdaccio
- ****Port:****<span style="white-space:pre-wrap;"> 443</span>
- ****Enable HSTS****: true

****Destination****

- ****Protocol****: HTTP (giao thức của Verdaccio khi cài đặt qua Docker)
- ****Hostname****: localhost (hoặc local IP)
- ****Port:****<span style="white-space:pre-wrap;"> 4873 (cổng truy cập khi cài đặt trên Docker)</span>

<span style="white-space:pre-wrap;">Chọn </span>****Save****<span style="white-space:pre-wrap;"> để hoàn tất.</span>

<span style="white-space:pre-wrap;">Đến đây mình đã có thể truy cập Verdaccio qua domain. Nhưng vẫn sẽ nhận được thông báo bảo mật. Cần cài đặt thêm </span>****Certificate****.

<span style="white-space:pre-wrap;">Truy cập </span>****Control Panel &gt; Security &gt; Certificate &gt; Add****<span style="white-space:pre-wrap;"> tạo một Certificate mới và gán cho địa chỉ vừa tạo ở bước trên.</span>

****Doneeeeeeeee!!!!!!!!****

### Sử dụng cơ bản

Create user  
`<span class="editor-theme-code">npm adduser --registry https://your-domain.com</span>`

Login  
`<span class="editor-theme-code">npm login --registry https://your-domain.com</span>`

Publish  
`<span class="editor-theme-code">npm publish --registry https://your-domain.com</span>`

Unpublish  
`<span class="editor-theme-code">npm unpublish package.id --force --registry https://your-domain.com</span>`

# CI/CD cho Verdaccio với Jenkins

<span style="color:rgb(171,184,195);">Thiết lập hệ thống CI/CD cho Verdaccio. Tự động publish package khi có commit mới.</span>

### Chuẩn bị

**Verdaccio Server**

**Jenkins**

- [Jenkins Controller.](https://docs.thanhdv.com/other/ci-cd-trong-unity-part-3/)
- [Jenkins Agent.](https://docs.thanhdv.com/other/ci-cd-trong-unity-part-4/)

**Jenkins Plugins**

- Blue Ocean
- NodeJS (Cài đặt tự động NodeJS lên Agent)
- Credentials
- Credentials Binding
- GitHub (Cần cho Webhook)
- Discord (Thông báo khi pipeline chạy xong)

**Package Repo**<span style="white-space:pre-wrap;"> (Repo trên Git, Github, Gitlab… folder package chứa </span>[Jenkinsfile](#bkmrk-jenkinsfile-demo "Jenkinsfile demo")<span style="white-space:pre-wrap;"> và package.json)</span>

**Jenkins Credentials** ([Cài đặt Credentials](#bkmrk-c%C3%A0i-%C4%91%E1%BA%B7t-credentials "Cài đặt Credentials"))

- Credential cho Verdaccio (chứa username, password đăng nhập Verdaccio)
- Credential cho Github (chứa username và PAT xác thực Github)
- Credential cho Github Secret (chứa Webhook Secret của Github)
- Credential cho Discord Webhook URL (chứa Webhook URL của Discord)

### Tạo và cấu hình Jenkins Jobs

<span style="white-space:pre-wrap;">Tạo item mới với type là </span>**Pipeline**.

Cấu hình Jobs

- **General**<span style="white-space:pre-wrap;"> (tuỳ chọn)</span>
    - Description: mô tả
    - Github project: link đến github repo (không quan trọng)
- **Trigger**
    - <span style="white-space:pre-wrap;">Chọn </span>**GitHub hook trigger for GITScm polling** để trigger từ GitHub.
    - **Poll SCM**<span style="white-space:pre-wrap;"> để kiểm tra bào build định kỳ.</span>
    - <span style="white-space:pre-wrap;">Để </span>**trống**<span style="white-space:pre-wrap;"> nếu muốn chạy thủ công.</span>
- **Pipeline**
    - Definition:
        - **Pipeline script from SCM**<span style="white-space:pre-wrap;"> để sử dụng jenkinsfile trong repo (recommend)</span>
        - **Pipeline script**<span style="white-space:pre-wrap;"> để viết trược tiếp jenkinsfile</span>
    - SCM chọn Git
    - Repositories
        - <span style="white-space:pre-wrap;">Repository URL: URL của GitHub repo (Ex: </span>`<span class="editor-theme-code">https://github.com/ThanhDang143/Backup.UniTask.git</span>`)
        - Credentials: chọn GitHub Credentials đã tạo.
        - Branches to build: nhánh GitHub cần theo dõi
    - <span style="white-space:pre-wrap;">Script Path: Đường dẫn đến Jenkinsfile trong repo (mặc định là </span>`<span class="editor-theme-code">Jenkinsfile</span>`<span style="white-space:pre-wrap;">. Ex: </span>`<span class="editor-theme-code">src/UniTask/Assets/Plugins/UniTask/Jenkinsfile</span>`)

### Chạy và kiểm tra

<span style="white-space:pre-wrap;">Tại đây ta đã có thể chạy </span>**build thủ công.**

Truy cập Blue Ocean. Để theo dõi tiến trình.

Kiểm tra kết quả và debug.

Nếu thành công thì có thể chuyển sang bước tiếp theo.

### Cấu hình Trigger tự động

Tiếp đến là cấu hình để Jenkins tự động chạy job khi có commit mới trên GitHub.

- <span style="white-space:pre-wrap;">Đảm bảo Jenkins Job đã chọn </span>**GitHub hook trigger for GITScm polling**<span style="white-space:pre-wrap;"> </span>[khi tạo Job](#bkmrk-t%E1%BA%A1o-v%C3%A0-c%E1%BA%A5u-h%C3%ACnh-jenk "Tạo và cấu hình Jenkins Jobs").
- Cấu hình webhook trên GitHub Repo
    - <span style="white-space:pre-wrap;">Truy cập </span>**GitHub Repo &gt; Settings &gt; Webhook &gt; Add webhook**
    - **Payload URL**:
        - <span style="white-space:pre-wrap;">Mặc định là URL Jenkins theo sau là </span>`<span class="editor-theme-code">/github-webhook/</span>`<span style="white-space:pre-wrap;"> (Ex: </span>`<span class="editor-theme-code">https://jenkins.thanhdv.com/github-webhook/</span>`)
        - URL Jenkins phải truy cập được từ internet.
    - **Content type**<span style="white-space:pre-wrap;">: chọn </span>`<span class="editor-theme-code">application/json</span>`
    - **Secret**<span style="white-space:pre-wrap;"> là Credential cho Discord Webhook URL đã chuẩn bị</span>
    - **Which events**<span style="white-space:pre-wrap;">? chọn </span>`<span class="editor-theme-code">Just the push event.</span>`
    - <span style="white-space:pre-wrap;">Đảm bảo đã tick chọn </span>**Active**<span style="white-space:pre-wrap;"> &gt; </span>**Add Webhook**

<span style="white-space:pre-wrap;">Sau khi add webhook github sẽ ping tới server. Kiểm tra tab </span>**Recent Deliveries**<span style="white-space:pre-wrap;"> để biết trạng thái ping</span>

- <span style="white-space:pre-wrap;">Nếu không thành công kiểm tra lại các lỗi cơ bản như sai </span>**Payload URL**<span style="white-space:pre-wrap;">, sai </span>**Secret**<span style="white-space:pre-wrap;">, chưa </span>**thêm secret vào Shared secrets**
- Nếu thành công có thể push một commit mới để test.

### Gửi thông báo qua Discord

<span style="white-space:pre-wrap;">Tạo Discord Webhoook, trên Discord truy cập </span>**Server Settings &gt; Integrations &gt; Create Webhook.**

<span style="white-space:pre-wrap;">Copy Webhook URL đã tạo. Truy cập Jenkins Server tạo </span>**Credential cho Discord Webhook URL**.

Trong bước tiếp theo ta cần cập nhật trong Jenkinsfile (**Đã có đầy đủ trong Jenkinsfile demo**)

- <span style="white-space:pre-wrap;">Tạo một biến môi trường </span>`<span class="editor-theme-code">DISCORD_WEBHOOK = "CREDENTIAL_ID_DISCORD_WEBHOOK_URL"</span>`<span style="white-space:pre-wrap;"> để đảm bảo tính bảo mật không sử dụng trực tiếp Discord Webhook URL trong Jenkinsfile.</span>
- <span style="white-space:pre-wrap;">Chỉnh sửa bên các khối sự kiện </span>`<span class="editor-theme-code">success{ }, failure { }, aborted { }</span>`<span style="white-space:pre-wrap;"> bên trong khối </span>`<span class="editor-theme-code">post</span>`<span style="white-space:pre-wrap;"> với nội dung demo như hình dưới. Chỉnh sửa lại nội dung cho phù hợp.</span>

```dart
script {    
  withCredentials([string(credentialsId: "${env.DISCORD_WEBHOOK}", variable: 'DISCORD_WEBHOOK_URL_SECRET')]) {
    discordSend(
      webhookURL: DISCORD_WEBHOOK_URL_SECRET,
      title: "ℹ️ Aborted: ${env.JOB_NAME}",
      description: "Build #${env.BUILD_NUMBER} for job `${env.JOB_NAME}` was aborted.",
      result: "ABORTED",
      link: env.BUILD_URL,
      footer: "Jenkins Build Notification"
    )
  }
}
```

### Phụ lục

#### Cài đặt Credentials

##### Thêm Domain (Tuỳ chọn)

**Jenkins Manager &gt; Credentials &gt; System &gt; Add domain**. Tại đây điền Name và Desc cho domain mới.

Nếu không tạo domain mới có thể sử dụng domain Global.

##### Thêm Credentials

**Jenkins Manager &gt; Credentials &gt; System &gt; Domain &gt; Add Credentials**

- **Kind**
    - Username with password với Credential cho Verdaccio hoặc Credential cho Github…
    - Secret text với Credential cho Discord Webhook URL…
    - …
- **Scope**: global
- **Username**
- **Password**: password hoặc PAT…
- **Secret**: chuỗi text
- **ID**<span style="color:rgb(241,196,15);">: cần để sử dụng trong Jenkinsfile hoặc những nơi cần thiết…</span>
- **Description**: mô tả.

**Chú ý:**

- <span style="white-space:pre-wrap;">Với </span>**Credential cho GitHub Secret**<span style="white-space:pre-wrap;"> sau khi tạo cần thêm vào </span>**Shared secrets**
- **Jenkins Manager &gt; System &gt; GitHub &gt; Advanced &gt; Shared Secrets &gt; Add Shared Secret**<span style="white-space:pre-wrap;"> tại đây điền </span>**ID**<span style="white-space:pre-wrap;"> của Credential.</span>
- <span style="white-space:pre-wrap;">Nếu không thấy </span>**GitHub**<span style="white-space:pre-wrap;"> kiểm tra lại GitHub Plugin</span>

#### Lỗi thường gặp khi chạy Jenkins Job

<span style="white-space:pre-wrap;">Lỗi </span>**`<strong class="editor-theme-bold editor-theme-code">FileNotFoundException</strong>`**: Sai đường dẫn. Kiểm tra lại các path trong Jenkinsfile (PROJECT\_PATH…)

<span style="white-space:pre-wrap;">Lỗi </span>**`<strong class="editor-theme-bold editor-theme-code">Cannot run program "sh"</strong>`**<span style="white-space:pre-wrap;">: sh là lệnh của Linux. Nếu máy Agent là Window thay </span>`<span class="editor-theme-code">sh</span>`<span style="white-space:pre-wrap;"> bằng </span>`<span class="editor-theme-code">bat</span>`.

<span style="white-space:pre-wrap;">Lỗi </span>**`<strong class="editor-theme-bold editor-theme-code">RejectedAccessException</strong>`**<span style="white-space:pre-wrap;"> (</span>`<span class="editor-theme-code">new java.net.URI</span>`<span style="white-space:pre-wrap;">): Bị chặn bởi Script Security sandbox. Phê duyệt tại </span>**Jenkins Manager &gt; In-process Script Approval**

#### Jenkinsfile demo

##### Windows

```c
def packageVersionFromFile = ""
def packageName = ""
def verdaccioPackageUrl = ""

pipeline {
    // Agent with Node.js & Git required
    agent { label "thanhsserver" }

    // Ensure Node.js tool is available (configure in Jenkins Global Tool Config)
    tools {
        nodejs "NodeJS22"
    }

    environment {
        // Path to the directory containing package.json
        PROJECT_PATH = "src/UniTask/Assets/Plugins/UniTask"

        // Verdaccio registry URL
        VERDACCIO_REGISTRY_URL = "https://upm.thanhdv.com"
        // Jenkins Credential ID for Verdaccio auth (e.g., Secret Text or User/Pass with token)
        VERDACCIO_CREDENTIAL_ID = "THANHDV_VERDACCIO_AUTH"

        // Jenkins Credential ID for Git auth (MUST be "Username with password" type)
        // Username: your git username
        // Password: your git access token
        GIT_CREDENTIAL_ID = "THANHDV_GITHUB_JENKINS_CREDENTIAL"

        // Discord webhook create on Discord and add to Jenkins credential
        DISCORD_WEBHOOK = "DISCORD_VERDACCIO_WEBHOOK"

        // Release if commit has this key
        RELEASE_KEYWORD = "Release v"
    }
    // === End Configuration ===

    // Pipeline options
    options {
        timestamps()
        buildDiscarder(logRotator(numToKeepStr: "10"))
        timeout(time: 30, unit: "MINUTES")
        disableConcurrentBuilds()
    }

    stages {
        stage("Checkout") {
            steps {
                // Get source code
                checkout scm
                // git lfs pull // Uncomment if using Git LFS
            }
        }
        
        // stage("Validate Commit Message") {
        //     steps {
        //         dir(env.PROJECT_PATH) {
        //             script {
        //                 def isManualTrigger = false
        //                 currentBuild.getBuildCauses().each{ cause -> 
        //                     if (cause instanceof hudson.model.Cause$UserIdCause || cause.shortDescription.contains("Started by user ")) {
        //                         isManualTrigger = true
        //                     }
        //                 }

        //                 if (isManualTrigger) {
        //                     echo "Build triggered by User! Ignore Validate Commit Message."
        //                 } else {
        //                     def commitMessage = bat(script: 'git log -1 --pretty=%%B', returnStdout: true).trim()
        //                     echo "Checking commit message: ${commitMessage}"
        //                     if (!commitMessage.contains(RELEASE_KEYWORD)) {
        //                         echo "Build condition not met! Aborting pipeline..."
        //                         currentBuild.result = 'ABORTED'
        //                         return
        //                     }
        //                 }

        //                 echo "Commit message is valid for release."
        //             }
        //         }
        //     }
        // }

        stage("Prepare") {
            when {
                expression { return currentBuild.result != "ABORTED" }
            }

            steps {
                // Operate within the project directory
                dir(env.PROJECT_PATH) {
                    script {
                        // Read version directly from package.json
                        def pkg = readJSON file: "package.json" // Assumes package.json is at PROJECT_PATH root
                        if (!pkg || !pkg.version || !pkg.name) {
                             error "Could not read version from package.json"
                        }
                        
                        // Store the version in the script-level variable
                        packageVersionFromFile = pkg.version
                        echo "Package version: ${packageVersionFromFile}"

                        packageName = pkg.name
                        echo "Package name: ${packageName}"

                        verdaccioPackageUrl = "${env.VERDACCIO_REGISTRY_URL}/-/web/detail/${packageName}"
                        echo "Package URL: ${verdaccioPackageUrl}"

                        if (!packageVersionFromFile || !packageName || !verdaccioPackageUrl) {
                             error "Failed to set variable."
                        }

                        withCredentials([usernamePassword(credentialsId: env.VERDACCIO_CREDENTIAL_ID, usernameVariable: "NPM_USER", passwordVariable: "NPM_PASS")]) {
                            echo "Configuring npm for Verdaccio using Username/Password..."
                            // Encrypt username:password to Base64
                            def userPass = "${NPM_USER}:${NPM_PASS}"
                            def encodedAuth = java.util.Base64.getEncoder().encodeToString(userPass.getBytes("UTF-8"))

                            def registryUri = new URI(env.VERDACCIO_REGISTRY_URL)
                            def registryAuthority = registryUri.getAuthority()
                            def registryHostPath = "//${registryAuthority}/"

                            // Configure .npmrc for Verdaccio registry & auth token
                            bat "echo registry=${env.VERDACCIO_REGISTRY_URL} > .npmrc"
                            bat "echo ${registryHostPath}:_auth=\"${encodedAuth}\" >> .npmrc"
                            echo ".npmrc configured."
                        }

                        // Install dependencies (might run prepublish scripts)
                        // echo "Running npm install..."
                        // bat "npm install"
                    }
                }
            }
        }

        stage("Publish") {
            when {
                expression { return currentBuild.result != "ABORTED" }
            }

            steps {
                // Operate within the project directory
                dir(env.PROJECT_PATH) {
                    script {                        
                        // Use the version read from package.json
                        echo "Publishing package version ${packageVersionFromFile} to ${env.VERDACCIO_REGISTRY_URL}"
                        try {
                            // Publish using npm (reads package.json for name/version, uses .npmrc for auth/registry)
                            bat "npm publish --registry ${env.VERDACCIO_REGISTRY_URL}"
                            echo "Package version ${packageVersionFromFile} published successfully!"
                        } catch (err) {
                            echo "ERROR: Failed to publish package!"
                            error "Publish failed: ${err.getMessage()}"
                        }
                    }
                }
            }
        }

        // // (Optional) Tag commit with the existing version from package.json
        // stage("Tag Existing Version") {
        //     // Only run on overall success so far
        //     when { expression { currentBuild.result == null || currentBuild.result == "SUCCESS" } }
        //     steps {
        //         // Operate within the project directory
        //         dir(env.PROJECT_PATH) {
        //             script {
        //                  echo "Tagging commit with existing version v${packageVersionFromFile}..."

        //                  // Inject Git credentials (Username = Git user, Password = Access Token)
        //                 withCredentials([usernamePassword(credentialsId: env.GIT_CREDENTIAL_ID, usernameVariable: "GIT_USERNAME", passwordVariable: "GIT_ACCESS_TOKEN")]) {

        //                     // Configure Git user (may not be needed if just tagging)
        //                     bat "git config user.email \"vanthanh1998@gmail.com\"" // EDIT Or use a specific user
        //                     bat "git config user.name \"ThanhDVs Jenkins\""

        //                     // NO commit needed here as we didn"t change package.json version via npm version

        //                     // Create annotated tag using the version from package.json
        //                     bat "git tag -a v${packageVersionFromFile} -m \"Release v${packageVersionFromFile}\"" // Use the read version

        //                     // Push tag using HTTPS URL with embedded token
        //                     def repoUrl = scm.userRemoteConfigs[0].url
        //                     if (!repoUrl || !repoUrl.startsWith("https://")) {
        //                         error "Could not determine HTTPS repository URL from SCM configuration."
        //                     }
        //                     def repoUrlClean = repoUrl.replaceAll(/https?:\/\/[^\/]+@/, "https://")
        //                     def pushUrl = repoUrlClean.replaceFirst("https://", "https://${GIT_USERNAME}:${GIT_ACCESS_TOKEN}@")

        //                     // Push only the tag
        //                     bat "git push ${pushUrl} refs/tags/v${packageVersionFromFile}:refs/tags/v${packageVersionFromFile}"

        //                     echo "Version tag v${packageVersionFromFile} pushed successfully."
        //                 }
        //             }
        //         }
        //     }
        // }
    }

    // Post-build actions
    post {
        // Always run cleanup
        always {
            echo "Build finished. Cleaning up..."
            // Clean up sensitive .npmrc file
            dir(env.PROJECT_PATH) {
                script {
                    try {
                        bat "del /F /Q .npmrc"
                    } catch (err) {
                        echo "Could not delete .npmrc (maybe it doesn't exist): ${err.getMessage()}"
                    }
                }
            }
        }
        
        // On success
        success {
            echo "Pipeline successful!"

            // Save artifacts if need
            // archiveArtifacts artifacts: '**/*.tgz', allowEmptyArchive: true
            
            echo "Cleaning up workspace..."
            deleteDir()

            echo "Sending success notification to Discord..."
            script {
                // Send
                withCredentials([string(credentialsId: "${env.DISCORD_WEBHOOK}", variable: 'DISCORD_WEBHOOK_URL_SECRET')]) {
                    discordSend(
                        webhookURL: DISCORD_WEBHOOK_URL_SECRET,
                        title: "✅ Success: ${env.JOB_NAME}", 
                        description: "Job `${env.JOB_NAME}` build #${env.BUILD_NUMBER} published package `${packageName}@${packageVersionFromFile}` successfully.\nBuild Log: ${env.BUILD_URL}.\nPackage URL: ${verdaccioPackageUrl}",
                        result: "SUCCESS",
                        link: env.BUILD_URL,
                        footer: "Jenkins Build Notification"
                    )
                }
            }
        }
        
        // On failure
        failure {
            echo "Pipeline failed!"

            echo "Sending failure notification to Discord..."
            script {    
                // Send
                withCredentials([string(credentialsId: "${env.DISCORD_WEBHOOK}", variable: 'DISCORD_WEBHOOK_URL_SECRET')]) {
                    discordSend(
                        webhookURL: DISCORD_WEBHOOK_URL_SECRET,
                        title: "❌ Failure: ${env.JOB_NAME}",
                        description: "Job `${env.JOB_NAME}` build #${env.BUILD_NUMBER} failed to publish package `${packageName}`.\nBuild Log: ${env.BUILD_URL}",
                        result: "FAILURE",
                        link: env.BUILD_URL,
                        footer: "Jenkins Build Notification"
                    )
                }
            }
        }

        aborted {
            echo "Pipeline aborted!"
            echo "Sending abort notification to Discord..."
            script {    
                // Send
                withCredentials([string(credentialsId: "${env.DISCORD_WEBHOOK}", variable: 'DISCORD_WEBHOOK_URL_SECRET')]) {
                    discordSend(
                        webhookURL: DISCORD_WEBHOOK_URL_SECRET,
                        title: "ℹ️ Aborted: ${env.JOB_NAME}",
                        description: "Build #${env.BUILD_NUMBER} for job `${env.JOB_NAME}` was aborted.",
                        result: "ABORTED",
                        link: env.BUILD_URL,
                        footer: "Jenkins Build Notification"
                    )
                }
            }
        }

        unstable {
            echo "Pipeline unstable!"
            
            echo "Sending unstable notification to Discord..."
            script {    
                withCredentials([string(credentialsId: "${env.DISCORD_WEBHOOK}", variable: 'DISCORD_WEBHOOK_URL_SECRET')]) {
                    discordSend(
                        webhookURL: DISCORD_WEBHOOK_URL_SECRET,
                        title: "⚠️ Unstable: ${env.JOB_NAME}",
                        description: "Job `${env.JOB_NAME}` build #${env.BUILD_NUMBER} finished with unstable status during processing of package `${packageName}`.\nBuild Log: ${env.BUILD_URL}",
                        result: "UNSTABLE",
                        link: env.BUILD_URL,
                        footer: "Jenkins Build Notification"
                    )
                }
            }
        }
    }
}
```

##### Linux

```c#
def packageVersionFromFile = ""
def packageName = ""
def verdaccioPackageUrl = ""

pipeline {
    // Agent with Node.js & Git required
    agent { label "verdaccio-publisher" }

    // Ensure Node.js tool is available (configure in Jenkins Global Tool Config)
    tools {
        nodejs "NodeJS22"
    }

    environment {
        // Path to the directory containing package.json
        PROJECT_PATH = "Assets/Packages/DeviceDebugger"

        // Verdaccio registry URL
        VERDACCIO_REGISTRY_URL = "https://upm.thanhdv.com"
        // Jenkins Credential ID for Verdaccio auth (e.g., Secret Text or User/Pass with token)
        VERDACCIO_CREDENTIAL_ID = "THANHDV_VERDACCIO_AUTH"

        // Jenkins Credential ID for Git auth (MUST be "Username with password" type)
        // Username: your git username
        // Password: your git access token
        GIT_CREDENTIAL_ID = "THANHDV_GITHUB_JENKINS_CREDENTIAL"

        // Discord webhook create on Discord and add to Jenkins credential
        DISCORD_WEBHOOK = "DISCORD_VERDACCIO_WEBHOOK"

        // Release if commit has this key
        RELEASE_KEYWORD = "Release v"
    }
    // === End Configuration ===

    // Pipeline options
    options {
        timestamps()
        buildDiscarder(logRotator(numToKeepStr: "10"))
        timeout(time: 30, unit: "MINUTES")
        disableConcurrentBuilds()
    }

    stages {
        stage("Checkout") {
            steps {
                // Get source code
                checkout scm
                // git lfs pull // Uncomment if using Git LFS
            }
        }
        
        // stage("Validate Commit Message") {
        //     steps {
        //         dir(env.PROJECT_PATH) {
        //             script {
        //                 def isManualTrigger = false
        //                 currentBuild.getBuildCauses().each{ cause -> 
        //                     if (cause instanceof hudson.model.Cause$UserIdCause || cause.shortDescription.contains("Started by user ")) {
        //                         isManualTrigger = true
        //                     }
        //                 }

        //                 if (isManualTrigger) {
        //                     echo "Build triggered by User! Ignore Validate Commit Message."
        //                 } else {
        //                     def commitMessage = sh(script: 'git log -1 --pretty=%%B', returnStdout: true).trim()
        //                     echo "Checking commit message: ${commitMessage}"
        //                     if (!commitMessage.contains(RELEASE_KEYWORD)) {
        //                         echo "Build condition not met! Aborting pipeline..."
        //                         currentBuild.result = 'ABORTED'
        //                         return
        //                     }
        //                 }

        //                 echo "Commit message is valid for release."
        //             }
        //         }
        //     }
        // }

        stage("Prepare") {
            when {
                expression { return currentBuild.result != "ABORTED" }
            }

            steps {
                // Operate within the project directory
                dir(env.PROJECT_PATH) {
                    script {
                        // Read version directly from package.json
                        def pkg = readJSON file: "package.json" // Assumes package.json is at PROJECT_PATH root
                        if (!pkg || !pkg.version || !pkg.name) {
                             error "Could not read version from package.json"
                        }
                        
                        // Store the version in the script-level variable
                        packageVersionFromFile = pkg.version
                        echo "Package version: ${packageVersionFromFile}"

                        packageName = pkg.name
                        echo "Package name: ${packageName}"

                        verdaccioPackageUrl = "${env.VERDACCIO_REGISTRY_URL}/-/web/detail/${packageName}"
                        echo "Package URL: ${verdaccioPackageUrl}"

                        if (!packageVersionFromFile || !packageName || !verdaccioPackageUrl) {
                             error "Failed to set variable."
                        }

                        withCredentials([usernamePassword(credentialsId: env.VERDACCIO_CREDENTIAL_ID, usernameVariable: "NPM_USER", passwordVariable: "NPM_PASS")]) {
                            echo "Configuring npm for Verdaccio using Username/Password..."
                            // Encrypt username:password to Base64
                            def userPass = "${NPM_USER}:${NPM_PASS}"
                            def encodedAuth = java.util.Base64.getEncoder().encodeToString(userPass.getBytes("UTF-8"))

                            def registryUri = new URI(env.VERDACCIO_REGISTRY_URL)
                            def registryAuthority = registryUri.getAuthority()
                            def registryHostPath = "//${registryAuthority}/"

                            // Configure .npmrc for Verdaccio registry & auth token
                            sh "echo ${registryHostPath}:_auth=\"${encodedAuth}\" > .npmrc"
                            sh "echo registry=${env.VERDACCIO_REGISTRY_URL} >> .npmrc"
                            echo ".npmrc configured."
                        }

                        // Install dependencies (might run prepublish scripts)
                        echo "Running npm install..."
                        sh "npm install"
                    }
                }
            }
        }

        stage("Publish") {
            when {
                expression { return currentBuild.result != "ABORTED" }
            }

            steps {
                // Operate within the project directory
                dir(env.PROJECT_PATH) {
                    script {                        
                        // Use the version read from package.json
                        echo "Publishing package version ${packageVersionFromFile} to ${env.VERDACCIO_REGISTRY_URL}"
                        try {
                            // Publish using npm (reads package.json for name/version, uses .npmrc for auth/registry)
                            sh "npm publish --registry ${env.VERDACCIO_REGISTRY_URL}"
                            echo "Package version ${packageVersionFromFile} published successfully!"
                        } catch (err) {
                            echo "ERROR: Failed to publish package!"
                            error "Publish failed: ${err.getMessage()}"
                        }
                    }
                }
            }
        }

        // // (Optional) Tag commit with the existing version from package.json
        // stage("Tag Existing Version") {
        //     // Only run on overall success so far
        //     when { expression { currentBuild.result == null || currentBuild.result == "SUCCESS" } }
        //     steps {
        //         // Operate within the project directory
        //         dir(env.PROJECT_PATH) {
        //             script {
        //                  echo "Tagging commit with existing version v${packageVersionFromFile}..."

        //                  // Inject Git credentials (Username = Git user, Password = Access Token)
        //                 withCredentials([usernamePassword(credentialsId: env.GIT_CREDENTIAL_ID, usernameVariable: "GIT_USERNAME", passwordVariable: "GIT_ACCESS_TOKEN")]) {

        //                     // Configure Git user (may not be needed if just tagging)
        //                     sh "git config user.email \"vanthanh1998@gmail.com\"" // EDIT Or use a specific user
        //                     sh "git config user.name \"ThanhDVs Jenkins\""

        //                     // NO commit needed here as we didn"t change package.json version via npm version

        //                     // Create annotated tag using the version from package.json
        //                     sh "git tag -a v${packageVersionFromFile} -m \"Release v${packageVersionFromFile}\"" // Use the read version

        //                     // Push tag using HTTPS URL with embedded token
        //                     def repoUrl = scm.userRemoteConfigs[0].url
        //                     if (!repoUrl || !repoUrl.startsWith("https://")) {
        //                         error "Could not determine HTTPS repository URL from SCM configuration."
        //                     }
        //                     def repoUrlClean = repoUrl.replaceAll(/https?:\/\/[^\/]+@/, "https://")
        //                     def pushUrl = repoUrlClean.replaceFirst("https://", "https://${GIT_USERNAME}:${GIT_ACCESS_TOKEN}@")

        //                     // Push only the tag
        //                     sh "git push ${pushUrl} refs/tags/v${packageVersionFromFile}:refs/tags/v${packageVersionFromFile}"

        //                     echo "Version tag v${packageVersionFromFile} pushed successfully."
        //                 }
        //             }
        //         }
        //     }
        // }
    }

    // Post-build actions
    post {
        // Always run cleanup
        always {
            echo "Build finished. Cleaning up..."
            // Clean up sensitive .npmrc file
            dir(env.PROJECT_PATH) {
                script {
                    try {
                        sh "rm -f .npmrc"
                    } catch (err) {
                        echo "Could not delete .npmrc (maybe it doesn't exist): ${err.getMessage()}"
                    }
                }
            }
        }
        
        // On success
        success {
            echo "Pipeline successful!"

            // Save artifacts if need
            // archiveArtifacts artifacts: '**/*.tgz', allowEmptyArchive: true
            
            echo "Cleaning up workspace..."
            deleteDir()

            echo "Sending success notification to Discord..."
            script {
                // Send
                withCredentials([string(credentialsId: "${env.DISCORD_WEBHOOK}", variable: 'DISCORD_WEBHOOK_URL_SECRET')]) {
                    discordSend(
                        webhookURL: DISCORD_WEBHOOK_URL_SECRET,
                        title: "✅ Success: ${env.JOB_NAME}", 
                        description: "Job `${env.JOB_NAME}` build #${env.BUILD_NUMBER} published package `${packageName}@${packageVersionFromFile}` successfully.\nBuild Log: ${env.BUILD_URL}.\nPackage URL: ${verdaccioPackageUrl}",
                        result: "SUCCESS",
                        link: env.BUILD_URL,
                        footer: "Jenkins Build Notification"
                    )
                }
            }
        }
        
        // On failure
        failure {
            echo "Pipeline failed!"

            echo "Sending failure notification to Discord..."
            script {    
                // Send
                withCredentials([string(credentialsId: "${env.DISCORD_WEBHOOK}", variable: 'DISCORD_WEBHOOK_URL_SECRET')]) {
                    discordSend(
                        webhookURL: DISCORD_WEBHOOK_URL_SECRET,
                        title: "❌ Failure: ${env.JOB_NAME}",
                        description: "Job `${env.JOB_NAME}` build #${env.BUILD_NUMBER} failed to publish package `${packageName}`.\nBuild Log: ${env.BUILD_URL}",
                        result: "FAILURE",
                        link: env.BUILD_URL,
                        footer: "Jenkins Build Notification"
                    )
                }
            }
        }

        aborted {
            echo "Pipeline aborted!"
            echo "Sending abort notification to Discord..."
            script {    
                // Send
                withCredentials([string(credentialsId: "${env.DISCORD_WEBHOOK}", variable: 'DISCORD_WEBHOOK_URL_SECRET')]) {
                    discordSend(
                        webhookURL: DISCORD_WEBHOOK_URL_SECRET,
                        title: "ℹ️ Aborted: ${env.JOB_NAME}",
                        description: "Build #${env.BUILD_NUMBER} for job `${env.JOB_NAME}` was aborted.",
                        result: "ABORTED",
                        link: env.BUILD_URL,
                        footer: "Jenkins Build Notification"
                    )
                }
            }
        }

        unstable {
            echo "Pipeline unstable!"
            
            echo "Sending unstable notification to Discord..."
            script {    
                withCredentials([string(credentialsId: "${env.DISCORD_WEBHOOK}", variable: 'DISCORD_WEBHOOK_URL_SECRET')]) {
                    discordSend(
                        webhookURL: DISCORD_WEBHOOK_URL_SECRET,
                        title: "⚠️ Unstable: ${env.JOB_NAME}",
                        description: "Job `${env.JOB_NAME}` build #${env.BUILD_NUMBER} finished with unstable status during processing of package `${packageName}`.\nBuild Log: ${env.BUILD_URL}",
                        result: "UNSTABLE",
                        link: env.BUILD_URL,
                        footer: "Jenkins Build Notification"
                    )
                }
            }
        }
    }
}
```

# CI/CD cho Verdaccio với GitHub Actions

### Lý do chuyển từ Jenkins sang GitHub Actions

#### Đơn giản hóa cài đặt và bảo trì

Jenkins là một hệ thống lâu đời với rất nhiều plug-in phức tạp. Khi cài đặt và bảo trì rất dễ gặp vấn đề với các plug-in.

Với GitHub Actions. GitHub đã lo phần quản lý máy chủ và bạn có thể tùy chọn triển khai Runner (tương tự Agent) hoặc chọn sử dụng Runner của GitHub (miễn phí có giới hạn).

#### Đơn giản hóa quy trình

Khi code được lưu trữ trên GitHub bạn sẽ dễ dàng cài đặt và kích hoạt quy trình. Không cần phải config webhook để nối từ GitHub sang Jenkins.

#### Cộng đồng mạnh mẽ

Với GitHub Actions bạn có thể tận dụng sức mạnh của cộng đồng GameCI đã tạo sẵn các Action cực kỳ mạnh được nhiều người kiểm chứng. Giúp bạn tiết kiệm được cả đống thời gian viết và sửa lỗi kịch bản.

#### Tính ổn định cao và gần như miễn phí

So với self-host mình đang có thì hạ tầng của GitHub vượt trội hơn về mọi mặt. Từ sức mạnh xử lý, tính ổn định...

Với người dùng miễn phí GitHub Actions giới hạn 2000 phút với các repo private. Các repo của mình chủ yếu là public, và tốc độ publish là rất nhanh (chỉ khoảng 15s) thì GitHub Actions có thể coi là miễn phí đối với mình

### Các bước thực hiện

#### Tạo biến môi trường

Chúng ta cần chuẩn bị 2 biến môi trường để lưu trữ token đăng nhập Verdaccio (`<span class="editor-theme-code">VERDACCIO_TOKEN</span>`) và Discord Webhook (`<span class="editor-theme-code">DISCORD_WEBHOOK</span>`)

<details id="bkmrk-l%E1%BA%A5y-verdaccio-token1"><summary>Lấy Verdaccio token</summary>

<span style="white-space:pre-wrap;">1. Chạy lệnh </span>`<span class="editor-theme-code">npm login --registry https://youraddress.com</span>`  
2\. Tiến hành đăng nhập  
3.1 Trên Windows  
<span style="white-space:pre-wrap;"> Mở hộp thoại Run (Win + R) và điền </span>`<span class="editor-theme-code">%USERPROFILE%\.npmrc</span>`<span style="white-space:pre-wrap;"> và Enter</span>  
3.2 Trên MacOS/Linux  
<span style="white-space:pre-wrap;"> Dùng lệnh </span>`<span class="editor-theme-code">cat ~/.npmrc</span>`  
4\. Bạn thấy dòng có dạng sau  
<span style="white-space:pre-wrap;"> </span>`<span class="editor-theme-code">//youraddress.com/:_authToken="npm_xxxxxxxxxxxxxxxxx_xxxxxxxxxxxx"</span>`  
<span style="white-space:pre-wrap;"> Phần nằm trong dấu nháy kép chính là token bạn cần.</span>

</details><details id="bkmrk-l%E1%BA%A5y-discord-webhook1"><summary>Lấy Discord webhook</summary>

1\. Truy cập server của bạn  
2\. Mở settings của channel bạn muốn nhận tin nhắn  
3\. Chọn mục Integrations (tích hợp) &gt; Webhook  
4\. Tại đây thêm mới hoặc sao lấy URL Webhook đã có.

</details>#### Viết kịch bản cho GitHub Actions

<span style="white-space:pre-wrap;">Tạo file </span>`<span class="editor-theme-code">.github/workflows/publish-verdaccio.yml</span>`<span style="white-space:pre-wrap;"> ngay trong thư mục gốc của repo.</span>

<span style="white-space:pre-wrap;">Về nội dung file </span>`<span class="editor-theme-code">publish-verdaccio.yml</span>`<span style="white-space:pre-wrap;"> thì tham khảo đoạn code dưới đây.</span>  
<span style="white-space:pre-wrap;">Nhớ thay đổi </span>`<span class="editor-theme-code">working-directory</span>`<span style="white-space:pre-wrap;"> cho phù hợp với dự án đang làm việc</span>

Kịch bản dưới được kích hoạt khi tag mới được push lên bắt đầu bằng chữ "v" (ví dụ: v1.1.1)  
Vậy nên sau khi push commit nhớ tạo và push tag để workflow này được kích hoạt.

```yml
name: Publish UPM Package
run-name: 📦 ${{ github.event.repository.name }}@${{ github.ref_name }} → Verdaccio

# Triggered when a Git Tag matching v* is pushed
on:
  push:
    tags:
      - 'v*'

jobs:
  publish-package:
    runs-on: ubuntu-latest

    # ====================================================================
    # 🔧 PER-PROJECT CONFIG — change this ONE line when copying to a new project
    # ====================================================================
    defaults:
      run:
        working-directory: Assets/CustomPackages/Utilities

    # ====================================================================
    # 🌐 SHARED CONFIG — same across all your projects, rarely changes
    # ====================================================================
    env:
      REGISTRY_URL: https://upm.thanhdv.com
      NODE_VERSION: '22.x'

    steps:
      # Step 1: Checkout the source code at the pushed tag
      - name: Checkout source
        uses: actions/checkout@v4

      # Step 2: Set up Node.js and point to your Verdaccio registry
      - name: Setup NodeJS & Registry
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          registry-url: ${{ env.REGISTRY_URL }}

      # Step 3: Extract package info from package.json for Discord notification.
      # - name:        technical id (e.g. "com.tayx.graphy") — used in URL & registry
      # - version:     semver string
      # - displayName: human-friendly name (e.g. "Graphy") — used in Discord title.
      #                Falls back to `name` if displayName is not set in package.json.
      - name: Extract Package Info
        id: pkg-info
        run: |
          echo "name=$(node -p "require('./package.json').name")" >> $GITHUB_OUTPUT
          echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
          echo "display_name=$(node -p "require('./package.json').displayName || require('./package.json').name")" >> $GITHUB_OUTPUT

      # Step 4: Pack and publish to Verdaccio using the secure token.
      - name: Publish to Verdaccio
        run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.VERDACCIO_TOKEN }}

      # Step 5: Send a Discord notification based on job result

      # 1. Notify on SUCCESS
      - name: Discord Notification (Success)
        if: success()
        uses: sarisia/actions-status-discord@v1
        with:
          webhook: ${{ secrets.DISCORD_WEBHOOK }}
          username: "GitHub Actions CI/CD"
          status: success
          nodetail: true # Removes extra detail lines (author, branch, commit) from the notification
          title: "✅ Success: ${{ steps.pkg-info.outputs.display_name }} v${{ steps.pkg-info.outputs.version }}"
          description: |
            Build **#${{ github.run_number }}** published package `${{ steps.pkg-info.outputs.name }}@${{ steps.pkg-info.outputs.version }}` successfully.
            **Build Log:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
            **Package URL:** ${{ env.REGISTRY_URL }}/-/web/detail/${{ steps.pkg-info.outputs.name }}

      # 2. Notify on FAILURE
      - name: Discord Notification (Failure)
        if: failure()
        uses: sarisia/actions-status-discord@v1
        with:
          webhook: ${{ secrets.DISCORD_WEBHOOK }}
          username: "GitHub Actions CI/CD"
          status: failure
          nodetail: true
          title: "❌ Failure: ${{ steps.pkg-info.outputs.display_name || github.event.repository.name }} ${{ github.ref_name }}"
          description: |
            Build **#${{ github.run_number }}** failed for package `${{ steps.pkg-info.outputs.name || github.event.repository.name }}` at tag `${{ github.ref_name }}`.
            **Reason:** Publish step failed.
            **Build Log:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

      # 3. Notify on CANCELLED
      - name: Discord Notification (Cancelled)
        if: cancelled()
        uses: sarisia/actions-status-discord@v1
        with:
          webhook: ${{ secrets.DISCORD_WEBHOOK }}
          username: "GitHub Actions CI/CD"
          status: cancelled
          nodetail: true
          title: "ℹ️ Cancelled: ${{ steps.pkg-info.outputs.display_name || github.event.repository.name }} ${{ github.ref_name }}"
          description: |
            Build **#${{ github.run_number }}** for job `${{ github.workflow }}` at tag `${{ github.ref_name }}` was aborted manually.
            **Build Log:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
```

#### Kiểm tra

Sau khi push bạn có thể truy cập repo trên GitHub để kiểm tra.

Truy cập Menu Actions

Nếu thấy bên side tab có hiện workflow bạn vừa tạo tức là đã không có lỗi cú pháp và GitHub đã nhận dược workflow.

Hiển thị như hình dưới tức là đã publish thành công.

[![image.png](https://knowledge.thanhdv.com/uploads/images/gallery/2026-05/scaled-1680-/xDPimage.png)](https://knowledge.thanhdv.com/uploads/images/gallery/2026-05/xDPimage.png)