首頁

目前文章總數:255 篇

  

最後更新:2026年 10月 24日

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 編譯失敗,網站會無法成功運行)