首頁

目前文章總數:190 篇

  

最後更新:2025年 07月 26日

0020. 打造穩定的 TypeScript CI/CD 管線:Jenkins 實務整合

日期:2025年 07月 19日

標籤: Jenkins Continuous Integration(CI) Continuous Deployment(CD) Docker Docker-Compose Container Ubuntu Linux Jenkins DotNETSDK TypeScript

摘要:Jenkins


應用所需:1. jenkins 主機
     2. 已安裝 Docker-Compose Ubuntu 主機
解決問題:如何正確的使用 Jenkins 自動化建置 TypeScript - 從專案建立、編輯 .gitIgnore、Jenkins 設定、Pipeline 完整流程
範例專案:範例腳本
基本介紹:本篇分為 4 大部分。
第一部分:為什麼要將 TypeScript 專案自動建置
第二部分:範例專案介紹
第三部分:Visual Studio 執行 TypeScript 編譯
第四部分:Jenkins 自動化部署 - DEMO 成果






第一部分:為什麼要將 TypeScript 專案自動建置

Step 1:TypeScript 解決問題

TypeScript 是由 Microsoft 開發的 JavaScript 的超集(superset),也就是說:

# 所有合法的 JavaScript 程式碼都是合法的 TypeScript。
# 但 TypeScript 增加了靜態型別檢查(Static Type Checking)和一些語法糖(如介面 interface、泛型 generics 等)來提升開發效率與程式碼品質。


簡言之: TypeScript = JavaScript + 型別系統 + 更好的開發工具支援(例如自動補全、錯誤提示)


Step 2:前端開發 - 提供的優點

TypeScript 解決了 .js 不能偵錯的問題,不用部署到環境才能知道是不是執行異常。這也是優勢:

# TypeScript 確實能幫助彌補前端開發中「內部測試(internal testing)」不落實的問題,這是導入 TS 的一大價值。
# TypeScript 雖然不是測試框架,但它在「沒有寫測試的情況下」也能給你最基本、最必要的安全網:


提供前端的安全網的特點:

1. 靜態型別檢查 等於「參數驗證」
2. 編譯時錯誤提示 等於「測試初步輸入合法性」
3. 自動提示/補全 減少「拼錯欄位名」
4. IDE 直接跳轉型別定義 減少「黑箱開發」與誤用




Step 3:前端開發 vs 後端開發 - 權責劃分

既然前端也可以偵錯,那麼簽入 Git 版控時,應該簽入 .ts 而非 .js 檔案

1. 開發者只簽入 .ts 原始碼(不包含編譯後的 .js)
2. CI/CD 自動執行 TypeScript 編譯(tsc),產生 .js
3. CD 階段部署編譯後的 .js 結果 到伺服器或 CDN。




Step 4:解決問題:權責劃分 - 應用於自動化部署

單體市專案:在 TypeScript 導入前,如下 => 無法區分權責(.js 會簽入)



單體式專案:在 TypeScript 導入後,如下 => 可以區分權責(.js 不簽入)


可以有效的做第一層檢核,避免 .js 的常見錯誤,部署到環境上



第二部分:範例專案介紹

Step 1:範例專案架構

打開範例代碼後,架構基本分成以下:
此為最後的版本,第 第四部分:Jenkins 自動化部署 - DEMO 成果 由 Jenkins 抓取 GitHub Source Code 後使用

1. Dockerfile 此專案的容器化基礎配置
2. package.json 自動產生 TypeScript 依賴
3. tsconfig.json 專案對 TypeScript 的基本設定,包含檢查 .ts 路徑,輸出 .js 檔案路徑等
4. wwwroot 網站靜態資源,由 Typescript 建置後會產出 .js 檔案


第三部分會將述建構此範例專案的過程




第三部分:Visual Studio 執行 TypeScript 編譯

Step 1:新建專案 & Nuget 安裝

新建專案 -> Visual Studio 2022 -> WEB 類型 -> Nuget 安裝
輸入 Microsoft.TypeScript.MSBuild 進行安裝


Step 2:新增 tsconfig.json 設定檔案

專案根目錄 -> 滑鼠右鍵 -> 新建 tsconfig.json 檔案


Step 3:設定 tsconfig.json 內容

依照自己所需進行配置,這裡的設定 rootDir 會取得 .ts 檔案建置,然後輸入到 outDir 產生 .js 檔案

{
  "compilerOptions": {
    "noImplicitAny": false,
    "noEmitOnError": true,
    "removeComments": false,
    "sourceMap": true,
    "target": "es5",
    "outDir": "wwwroot/js",
    "rootDir": "wwwroot/ts"
  },
  "exclude": [
    "node_modules",
    "wwwroot/js"
  ]
}




Step 4:編譯後產生內容 - ts 生成 js

這裡建立了 HelloWorld.ts 檔案

function sayHello(name: string): void {
    alert(`Hello, ${name}!`);
}


如果建置成功會產生 HelloWorld.js 檔案

function sayHello(name) {
    alert("Hello, ".concat(name, "!"));
}
//# sourceMappingURL=HelloWorld.js.map




Step 5:配置 GitIgnore - 簽入不該有 .js 檔案

這時進行 Git 版控簽入時,會發現 HelloWorld.js 檔案會被掃瞄到,這是不對的,正確的做法是由 TypeScript 生成 Javascript
只需要簽入 TypeScript 的檔案才正確




Step 6:配置 GitIgnore - 編輯 .gitignore

將自己專案根目錄下的 .gitignore 打開,要為此專案由 TypeScript 生成的 Javscript 目錄排除簽入
因此貼上以下(依照自己專案設定,這裡是範例說明):

# Our Example Include
wwwroot/js/
*.js
*.js.map




Step 7:配置 GitIgnore - 完成並檢查

設定完成後,進行 Git 版控簽入,可以發現不再出現 wwwroot/js/ 目錄下的 .js 檔案,這是為了實現 TypeScript 自動化建置的所需步驟


補充:簽入後,可以發現 wwwroot/js/ 資料夾沒有簽入,因為裡面沒有檔案,正確的畫面


Step 8:自動生成專案下的 TypeScript 相依檔案 - package.json

開啟專案根目錄,並且執行 Windows Command Line ,輸入以下,產生 package.json :

npm init -y


接著輸入以下:

>npm install --save-dev typescript


最終可以完成相異的設定檔案,並且我們知道 typescript@5.8.3 版本所需的 NodeJs 至少需要 14.17 以上 ※補充: Node 14 已在 2023-04 EOL,安全更新停止;長期來看風險高
這在 Jenkins 自動化建置時,可以知道安裝的 NodeJs 版本需要多少


Step 9:簽入 - package.json - 專案設定完成

最後進行 Git 版控簽入,完成所有專案的配置




第四部分:Jenkins 自動化部署 - DEMO 成果

Step 1:開啟管理 Jenkins 設置 - 安裝 Plugins

進入 Jenkins 主機後 -> 管理Jenkins -> Plugins -> 打開 Available plugins
找到 NodeJS 插件進行安裝,並且重新啟動 Jenkins 讓插件生效


Step 2:開啟管理 Jenkins 設置 - Tools 配置

進入 Jenkins 主機後 -> 管理Jenkins -> Tools
找到 NodeJS 的配置地方,設定名稱,並且安裝 NodeJs 14.17 版本以上


Step 3:新建 Pipeline Job

以下是完整的 groovy 腳本,關鍵在 ‘Frontend Install & Build TypeScript’‘Backend Building’
成功區分出前端的 Typescript 建置與專案的後端代碼建置,解耦問題的 Stage

pipeline {
  agent any
  
  // 環境變數
  environment {       		
        PROJECT_NAME = "TypeScriptDeployExample"// 專案名稱
		PROJECT_NAME_FOR_DOCKER = "typescriptdeployexample"// DockerName 強制小寫
		GIT_SOURCE_REPOSITORY = "https://github.com/gotoa1234/MyBlogExample.git"// 專案來源
		TARGET_MACHINE_IP = "192.168.51.93"// 對應的部署機器IP
		TARGET_MACHINE_CREDENTIAL = "DeployMechineUbuntu"// 對應部署機器的SSH Server Name
  }  
  
  // 定義單一建置時可異動參數
  parameters {
        string(name: 'GIT_HASH_TAG', defaultValue: '', description: '指定發布的GIT Hash 標籤(雜湊版號),預設 head 表示更新最新代碼')
  }
  
  stages {
      
    // step 1. start
    stage('Checkout') {
       steps {
            checkout([$class: 'GitSCM', 
                branches: [[name: "remotes/origin/main"]],
                userRemoteConfigs: [[url: "${env.GIT_SOURCE_REPOSITORY}"]]
            ])

            sh """
                git pull origin main
            """

            sh """
                git checkout ${params.GIT_HASH_TAG}
            """
        }
     }
    // step 1. end

    // step 2-1. start
    stage('Frontend Install & Build TypeScript') {
      steps {
        dir("${PROJECT_NAME}") {            // <-- 進入含 package.json 的資料夾
          nodejs('NodeJS24.3.0') {          // 由 NodeJS 外掛掛好 PATH
            sh 'npm ci'                     // 讀 package-lock.json
            sh 'npx tsc'                    // 編譯 .ts → .js
          }
        }
      }
	}
    // step 2-1. end

    // step 2-2. start
    stage('Backend Building') {
      steps {
        script {
            nodejs('NodeJS24.3.0') {
                   sh """
                      dotnet publish ${PROJECT_NAME}/${PROJECT_NAME}.csproj -c Release -o publish/${PROJECT_NAME} --disable-build-servers
                    """
          }
                   
                }
      }
    }
    // step 2-2. end
    
    // step 3. start
    stage('Publish Main Host') {
	  steps {
	      sshPublisher(publishers: 
	             [sshPublisherDesc(configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
	                                transfers: [
	                                    sshTransfer(cleanRemote: true, 
	                                                   excludes: '', 
	                                                execCommand: '', 
	                                                execTimeout: 120000, 
	                                                    flatten: false, 
	                                              makeEmptyDirs: false, 
	                                          noDefaultExcludes: false, 
	                                           patternSeparator: '[, ]+', 
	                                            remoteDirectory: "var\\dockerbuildimage\\${PROJECT_NAME}\\publish", 
	                                         remoteDirectorySDF: false, 
	                                               removePrefix: "publish\\${PROJECT_NAME}", 
	                                                sourceFiles: "publish\\${PROJECT_NAME}\\**")],
	                     usePromotionTimestamp: false, 
	                   useWorkspaceInPromotion: false, 
	                                   verbose: false)
	             ])
	  }
    }
    // step 3. end
    
	// step 4. start
    stage('Publish DockerFile') {
	  steps {
	      sshPublisher(publishers: 
		         [sshPublisherDesc(configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
				                    transfers: [
									    sshTransfer(cleanRemote: false, 
										               excludes: '', 
													execCommand: '', 
													execTimeout: 120000, 
													    flatten: false, 
												  makeEmptyDirs: false, 
											  noDefaultExcludes: false, 
											   patternSeparator: '[, ]+', 
											    remoteDirectory: "var\\dockerbuildimage\\${PROJECT_NAME}", 
											 remoteDirectorySDF: false, 
											       removePrefix: "${PROJECT_NAME}", 
												    sourceFiles: "${PROJECT_NAME}\\Dockerfile")], 
					   usePromotionTimestamp: false, 
					 useWorkspaceInPromotion: false, 
					                 verbose: false)
				])
	  }
    }
    // step 4. end
	
	// step 5. start
    stage('Build Image Remotely') {
      steps {
	    sh """
		   echo cd /var/dockerbuildimage/${env.PROJECT_NAME} 
		   echo docker build --no-cache -t ${env.PROJECT_NAME_FOR_DOCKER} .
		   echo docker tag ${env.PROJECT_NAME_FOR_DOCKER}:latest ${env.PROJECT_NAME_FOR_DOCKER}:hash_${params.GIT_HASH_TAG}
		   """
	  
        sshPublisher(
            failOnError: true,
            publishers: [sshPublisherDesc(
            configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
            transfers: [sshTransfer(
                excludes: '', 
                execCommand: "cd /var/dockerbuildimage/${env.PROJECT_NAME} && \
                              docker build --no-cache -t ${env.PROJECT_NAME_FOR_DOCKER} . && \
                              docker tag ${env.PROJECT_NAME_FOR_DOCKER}:latest ${env.PROJECT_NAME_FOR_DOCKER}:hash_${params.GIT_HASH_TAG}", 
                execTimeout: 120000, 
                patternSeparator: '[, ]+')], 
            verbose: false)])
      }
    }
	// step 5. end
	
	// step 6. start
    stage('ReConstruct Container') {
      steps {
        sshPublisher(
            failOnError: true,
            publishers: [sshPublisherDesc(
            configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
            transfers: [sshTransfer(
                excludes: '', 
                execCommand: "sudo docker stop ${env.PROJECT_NAME_FOR_DOCKER} && \
                                  docker rm ${env.PROJECT_NAME_FOR_DOCKER} || true && \
                                  docker run --name ${env.PROJECT_NAME_FOR_DOCKER} -d -p 8095:8080 -p 8195:8081 \
                                  --mount type=bind,source=/var/dockervolumes/${env.PROJECT_NAME}/appsettings.json,target=/app/appsettings.json \
                                  --mount type=bind,source=/var/dockervolumes/${env.PROJECT_NAME}/appsettings.Development.json,target=/app/appsettings.Development.json \
                                  ${env.PROJECT_NAME_FOR_DOCKER}:latest", 
                execTimeout: 120000, 
                patternSeparator: '[, ]+')], 
            verbose: false)])
      }
    }   
	// step 6. end
	
	// step 7. start
    stage('Image Purne') {
      steps {
         sshPublisher(
             failOnError: true,
             publishers: [sshPublisherDesc(
             configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
             transfers: [sshTransfer(
                 excludes: '', 
                 execCommand: "docker image prune -f", 
                 execTimeout: 120000, 
                 patternSeparator: '[, ]+')], 
             verbose: false)])
      }
    }
	// step 7. end
  }
}





Step 4:Demo 完成

執行 Jenkins 建置此 Job 後,可以觀察到 TypeScript 生成 Js 檔案,是一個獨立的 Stage
需要排查問題時,前端的 TypeScript 代碼撰寫編譯失敗時,就會在 ‘Frontend Install & Build TypeScript’ 報錯。

Potainer 觀察專案部署成功,成功運行網站 (若 TypeScript 編譯失敗,網站會無法成功運行)