WinForms / WPF 앱의 CI/CD 실전 ── GitHub Actions로 빌드부터 서명·배포까지 자동화하기

· · CI/CD, GitHub Actions, WinForms, WPF, C#, .NET, 코드 서명, MSIX, Deployment, Windows 개발, 판단표

“릴리스는 그 사람 PC가 아니면 빌드할 수 없어요” ── WinForms나 WPF 업무 앱 상담에서 정말 자주 듣는 말입니다. 손에 익은 Visual Studio에서 Release 빌드를 하고, zip으로 묶어 공유 폴더에 둔다. 동작은 하고 있지만, 그 개발자가 쉬는 날 버그 수정판을 내놓을 수 있을지 아무도 답할 수 없습니다.

웹 앱의 CI/CD는 정보가 넘쳐나는데, 데스크톱 앱이 되면 순식간에 빈약해집니다. “배포 대상이 서버가 아니라 고객사 PC”라는 근본적인 차이가 있으니, 웹 관련 글을 그대로 따라 할 수 없는 것도 사실입니다. 하지만 빌드와 테스트 자동화까지라면, 데스크톱 앱도 웹과 거의 같은 수고로 구성할 수 있습니다. 벽이 되는 것은 그다음의 서명과 배포이며, 이 부분은 배포 방식마다 현실적인 해법이 다릅니다.

당 블로그에서는 “Windows 앱의 배포 방식을 어떻게 고를까“에서 배포 방식을 고르는 법을, “SmartScreen과 코드 서명“에서 서명의 사고방식을 정리했습니다. 이 글은 그 두 편을 전제로, GitHub Actions를 이용해 WinForms / WPF 앱의 빌드·테스트·버전 번호 부여·서명·배포물 작성을 어디까지 자동화할지를 실무 관점에서 정리합니다.

대상 독자와 전제

자신의 상황에 대입해 읽을 수 있도록, 이 글이 전제로 삼는 조건을 먼저 짚어 두겠습니다.

  • 대상: WinForms / WPF로 작성된 Windows 데스크톱 앱을, 손에 익은 Visual Studio로 빌드해 배포하고 있는 개발자·팀. CI/CD 경험 유무는 묻지 않습니다.
  • 리포지토리: 소스가 GitHub 리포지토리에 있을 것(공개·비공개는 무관합니다). 퍼블릭 리포지토리에서는 표준 러너가 무료이고, 프라이빗 리포지토리에서는 실행 시간이 분 단위로 과금됩니다.1
  • 프로젝트 형식: 본문의 YAML은 .NET SDK 형식 csproj(net8.0-windows 등)를 전제로 합니다. .NET Framework 4.x의 구형 csproj여도 사고방식은 같으며, dotnet build 대신 MSBuild와 NuGet CLI를 사용합니다(3장).
  • 서명: 5장은 “앞으로 증명서를 어떻게 준비할지”부터 다룹니다. 이미 PFX 파일이나 사내 CA가 있는지, 앞으로 공인 증명서를 취득할지에 따라 결론이 달라지므로, 현재 상황을 확인하며 읽어 주십시오.
  • 목표: 전면 자동 배포가 아니라, 우선 “누구의 PC에서도 빌드·테스트가 재현되고, 산출물을 꺼낼 수 있는” 상태(3장)입니다. 거기서부터 서명·배포로 단계적으로 확장해 나갑니다.

파이프라인 전체의 형태는 다음과 같습니다. 어디까지를 자동화 범위로 삼을지는 2장에서 다룹니다.

[일상] main으로의 push / 풀 리퀘스트
    └→ checkout → setup-dotnet → build → test → publish → upload-artifact (3장)

[릴리스] v1.2.3 태그의 push
    └→ checkout → setup-dotnet → test
         → 태그에서 버전을 주입해 publish (4장)
         → 서명(signtool / 클라우드 서명 서비스)(5장)
         → 배포물 작성(zip / MSI / MSIX / ClickOnce)(6장)
         → GitHub 릴리스에 첨부(장기 보관)(4장)

1. 먼저 결론

  • 가장 큰 리스크는 “개발자 PC에서만 빌드할 수 있다”는 상태입니다. CI/CD의 첫 번째 목표는 배포의 전면 자동화가 아니라, 누구의 PC에도 의존하지 않고 빌드가 재현되는 것입니다.
  • 최소 구성은 빌드+테스트 자동화만으로도 충분히 가치가 있습니다. windows-latest+actions/checkout+actions/setup-dotnet+dotnet build / test+actions/upload-artifact, YAML 1개 파일로 구성할 수 있습니다.12
  • WinForms / WPF는 Windows 러너를 전제로 합니다. net8.0-windows와 같은 Windows 전용 TFM을 대상으로 하므로3, 테스트 실행까지 CI에서 하려면 Windows 환경이 필요합니다.
  • 버전 번호 부여는 태그 기반이 현실적인 결론입니다. v1.2.3 태그의 push로 릴리스 빌드를 기동하고, MSBuild의 Version 속성에 태그 값을 주입합니다.4
  • 서명이 자동화의 가장 큰 벽입니다. 2023년 6월 이후, 공인 OV 증명서의 비밀키는 HSM 보관이 필수가 되어, “PFX를 시크릿에 두고 signtool”이라는 종래의 정석은 그대로는 사용할 수 없습니다. CI 연계가 쉬운 Azure Artifact Signing(구 Trusted Signing)은 일본이 대상 지역에 포함되지 않기 때문에, 일본 개발자의 현실적인 해법은 CA의 클라우드 HSM 옵션이거나, 서명 공정만 로컬에 남기는 구성입니다(5.2절).5
  • 배포 형식에 따라 CI에 올리기 쉬운 정도가 크게 다릅니다. xcopy(zip)가 가장 간단하고, MSIX는 서명이 필수6, MSI는 WiX 등의 CLI 연계, ClickOnce는 msbuild /target:publish가 필요해 특성이 강하다는 순서입니다.7
  • UI 자동 테스트를 CI의 필수 관문으로 삼지 말 것. 유닛 테스트는 CI 필수, UI 테스트는 스모크 테스트로 좁혀 별도 잡으로 돌리는 것이 현실적인 해법입니다.

2. 데스크톱 앱의 CI/CD는 웹과 무엇이 다른가

웹 앱 CI/CD의 형태(push→빌드→테스트→서버로 배포)를 그대로 가져올 수 없는 이유를 먼저 정리합니다.

관점 웹 앱 WinForms / WPF 데스크톱 앱
배포 대상 자사가 관리하는 서버 고객사·현장의 PC(관리 밖)
배포 단위 서버상에서 일제히 전환 MSI / MSIX / ClickOnce / zip 등 다양. 전개 시점은 상대방에게 달림
롤백 서버 쪽에서 되돌릴 수 있음 배포가 끝난 PC에서는 쉽게 되돌릴 수 없음. 구버전 인스톨러 보관이 필수
서명 보통 불필요(TLS는 인프라 쪽) 실행 파일·패키지에 대한 코드 서명이 사실상 필수
빌드 환경 Linux 러너로 완결하기 쉬움 Windows 러너가 전제
테스트 헤드리스로 완결하기 쉬움 유닛 테스트는 동일. UI 테스트는 데스크톱 세션이 필요
“배포”의 의미 프로덕션 반영까지 CI의 관할 범위는 “배포물의 완성”까지. 설치는 별도 공정

중요한 것은 마지막 행입니다. 데스크톱 앱에서는 CI/CD 파이프라인의 출구가 “프로덕션 반영”이 아니라 “서명이 끝난 배포물이 언제든 꺼낼 수 있는 곳에 놓여 있는 것”입니다. 그 이후(고객사로의 전개, 자동 업데이트)는 배포 방식 설계의 이야기로, “배포 방식 판단표“에서 다룬 영역입니다. 반대로 말하면, 출구를 그렇게 구분해 두면 데스크톱 앱의 CI/CD는 웹과 같은 도구로 구성할 수 있습니다.

3. 최소 구성 ── GitHub Actions로 빌드+테스트

가장 먼저 넣어야 하는 것은 이것뿐입니다. GitHub 호스팅 러너는 잡마다 새 VM이 할당되므로1, push할 때마다 깨끗한 Windows에서 빌드와 테스트가 실행되어, “그 사람 PC에만 들어 있는 SDK”에 대한 의존이 그 자리에서 드러납니다.

name: build-and-test

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: windows-latest   # WinForms / WPF는 Windows 러너 필수
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --configuration Release --no-restore

      - name: Test
        run: dotnet test --configuration Release --no-build

      - name: Publish
        run: dotnet publish src/MyApp/MyApp.csproj -c Release -o publish

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: MyApp
          path: publish

이 YAML을 .github/workflows/build-and-test.yml로 두면 그대로 push로 동작합니다. 다만 src/MyApp/MyApp.csproj 부분은 자신의 리포지토리의 프로젝트 경로로 바꿔 주십시오. 이후의 YAML에서도 같은 경로를 예시로 사용합니다. 리포지토리 바로 아래에 솔루션과 csproj 하나만 있는 구성이라면, dotnet publish -c Release -o publish처럼 경로를 생략해도 동작합니다.

포인트를 세 가지 덧붙입니다.

첫째, runs-on: windows-latest가 기본입니다. WinForms / WPF 프로젝트는 TargetFrameworknet8.0-windows와 같은 Windows 전용 TFM이며, UseWindowsForms 또는 UseWPF를 활성화한 .NET 데스크톱 SDK 프로젝트입니다.3 엄밀히는 컴파일만이라면 Linux 러너에서도 EnableWindowsTargeting을 활성화하면 빌드할 수 있지만, dotnet test실행을 수반하는 단계에는 Windows 환경이 필요하므로, 테스트까지 1개 잡으로 돌리는 이 구성에서는 순순히 Windows 러너를 사용합니다. .NET Framework 4.x(구형 csproj)의 경우 dotnet build가 아니라 MSBuild와 NuGet CLI를 사용하지만, 둘 다 Windows 러너에 사전 설치되어 있으며 사고방식은 같습니다.

둘째, actions/upload-artifact로 산출물을 반드시 남깁니다. “그 빌드의 산출물 일체를 GitHub에서 꺼낼 수 있다”는 것이, 특정 인력 의존 PC에서 벗어났다는 실체입니다. 급한 동작 확인용 빌드도 Actions 화면에서 zip을 내려받기만 하면 됩니다.

셋째, 이 단계에서는 서명도 배포도 아직 하지 않습니다. 이 최소 구성만으로 “main이 항상 빌드·테스트 가능하다”, “누구나 같은 산출물을 꺼낼 수 있다”는 두 가지 보증이 확보되며, 필자의 경험상 소규모 팀 고민의 대부분은 이것으로 해소됩니다.

4. 버전 번호 자동 부여 ── 태그 기반 릴리스

다음 단계는 “이 zip, 버전이 몇이지?” 문제의 해소입니다. 로컬 빌드 운용에서는 csproj의 Version을 바꾸는 것을 잊어 같은 1.0.0이 여러 세대 존재하는 사고가 단골입니다. 실무적인 결론은 태그 기반 릴리스로, 릴리스하고 싶은 커밋에 v1.2.3 같은 태그를 붙이면 이를 트리거로 워크플로가 실행되고, 태그명에서 가져온 버전을 빌드에 주입합니다.

name: release

on:
  push:
    tags: [ 'v*' ]

jobs:
  release:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      # 태그 push에서는 3장의 빌드+테스트 워크플로가 발화하지 않으므로,
      # 릴리스 산출물을 만들기 전에 여기서도 테스트를 통과시킨다
      - name: Test
        run: dotnet test --configuration Release

      - name: Publish with version from tag
        shell: pwsh
        run: |
          $version = $env:GITHUB_REF_NAME.TrimStart('v')   # v1.2.3 -> 1.2.3
          dotnet publish src/MyApp/MyApp.csproj `
            -c Release -o publish `
            -p:Version=$version

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: MyApp-${{ github.ref_name }}
          path: publish

-p:Version=1.2.3처럼 MSBuild 속성으로 전달하면, .NET SDK 프로젝트에서는 AssemblyVersionFileVersionVersion의 접두부(접미부를 제외한 부분)에서, InformationalVersionVersion 그 자체에서 기본값으로 생성됩니다.4 csproj에는 개발용 임시 값만 두고, 릴리스 시의 정식 버전은 태그만 갖는 일원 관리가 됩니다.

한 가지 주의할 점이 있습니다. actions/upload-artifact의 산출물에는 리포지토리의 보존 기간(기본 90일)이 있어, 기한이 지나면 사라집니다. 데스크톱 앱에서는 롤백용으로 구버전 인스톨러를 장기 보관해야 하므로, 태그 빌드의 산출물은 GitHub 릴리스에 첨부하는 등 영구적인 보관 장소로 발행하고, Actions의 아티팩트는 일시적인 전달 수단으로 구분합니다.

GitHub 릴리스로의 첨부는 전용 액션을 사용하는 방법과, 러너에 처음부터 들어 있는 GitHub CLI(gh)를 사용하는 방법이 있습니다.8 어느 쪽이든 잡에 contents: write 권한이 필요합니다.

jobs:
  release:
    runs-on: windows-latest
    permissions:
      contents: write        # 릴리스 생성·에셋 첨부에 필요
    steps:
      # ...(빌드와 publish는 앞서 나온 대로)

      - name: Zip
        shell: pwsh
        run: Compress-Archive -Path publish\* -DestinationPath MyApp-${{ github.ref_name }}.zip

      # 방법 A: 전용 액션을 사용
      - name: Create GitHub Release
        uses: softprops/action-gh-release@v3
        with:
          files: MyApp-${{ github.ref_name }}.zip

      # 방법 B: 러너에 포함된 GitHub CLI를 사용(위 둘 중 하나만 있으면 됨)
      - name: Create GitHub Release (gh)
        shell: pwsh
        run: gh release create ${{ github.ref_name }} MyApp-${{ github.ref_name }}.zip --generate-notes
        env:
          GH_TOKEN: ${{ github.token }}

gh는 GitHub 호스팅 러너에 사전 설치되어 있지만, 단계마다 GH_TOKEN 환경 변수에 필요한 스코프를 가진 토큰을 전달해야 합니다.8

이점은 운용이 Git 안에서 완결된다는 것입니다. “고객사 환경의 1.2.3은 어느 커밋인가”는 태그로 확정되며, EXE 속성에 표시되는 파일 버전과 Git의 태그가 기계적으로 일치합니다. 나아가 InformationalVersion에는 .NET 8 SDK부터 Git 커밋 해시(SourceRevisionId)가 기본으로 추가되므로4, 버전 표시 화면에 이를 출력해 두면 산출물에서 직접 커밋을 특정할 수 있습니다.

5. 코드 서명을 CI에 넣기 ── 여기가 가장 큰 벽

빌드와 버전까지는 순조롭게 자동화한 팀이 거의 예외 없이 멈춰 서는 것이 서명입니다. Store 밖에서 배포하는 Windows 앱에 코드 서명이 사실상 필수인 이유(SmartScreen, 기업 보안 제품, 변조 탐지)는 “SmartScreen과 코드 서명“에서 정리했으므로, 여기서는 CI의 어디에서, 어떻게 실행할지로 좁혀 다룹니다.

5.1 signtool의 기본형

서명 실행 자체는 명령 하나입니다. signtool은 Windows SDK에 포함되어 있으며, GitHub의 Windows 러너에서도 이용할 수 있습니다. 현행 SDK에서는 /fd(파일 다이제스트)와 /td(타임스탬프 다이제스트) 지정이 필수이며, SHA256이 권장됩니다.9

signtool sign /f MyCert.pfx /p $env:PFX_PASSWORD `
  /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 `
  publish\MyApp.exe

타임스탬프(/tr)는 생략할 수 있지만, 반드시 붙입니다. 타임스탬프가 있으면 증명서의 기한이 지난 뒤에도 “서명 시점에는 유효했다”는 것을 검증할 수 있어, 배포가 끝난 파일의 서명이 계속 유효합니다.96

5.2 증명서 종류와 CI 편입의 현실

문제는 명령이 아니라 비밀키를 어디에 둘 것인가입니다. 증명서의 취득 형태에 따라 CI 편입 방식이 근본적으로 달라집니다.

증명서 형태 비밀키의 위치 CI 편입 비고
클라우드 서명 서비스(Azure Artifact Signing = 구 Trusted Signing 등) 클라우드 쪽 편입하기 쉬움. GitHub Actions 등과의 연계를 전제로 한 설계 이용 가능한 국가·지역에 제한이 있고, 일본은 대상 밖(법인은 미국·캐나다·EU·영국, 개인은 미국·캐나다만). 자세한 내용은 아래5
OV 증명서(2023년 6월 이후 신규 발급) HSM / USB 토큰 필수 토큰을 러너에 꽂을 수 없어 그대로는 불가. CA의 클라우드 HSM 옵션이라면 가능 CA/Browser Forum 요건에 따름5
EV 증명서 HSM / USB 토큰 위와 동일 SmartScreen의 즉시 신뢰 효과는 2024년에 폐지됨. 서명 운용상은 OV와 동일하게 취급5
종래의 PFX 파일(과거에 발급된 것·사내 CA·자체 서명) 파일 시크릿에 Base64로 저장 후 복원(아래 참조) 공개 배포용 신규 취득으로는 이 형태를 원칙적으로 더 이상 얻을 수 없음

즉, 검색하면 자주 나오는 “PFX를 GitHub 시크릿에 두고 signtool로 서명”이라는 구성은, 사내 CA나 기존 PFX에서는 지금도 유효하지만, 앞으로 공인 증명서를 취득하는 경우에는 전제가 무너져 있습니다. 새로 구성한다면, CI 연계를 처음부터 지원하는 클라우드 서명 서비스를 축으로 검토하는 것이 현실적입니다.5 USB 토큰으로 운용 중이라면, 서명 공정만 로컬 PC나 토큰을 꽂은 셀프 호스티드 러너에 남기는 절충 구성이 됩니다.

일본 개발자에게 있어서의 본론 ── Azure Artifact Signing은 쓸 수 있는가

표의 첫 번째 줄에 “이용 가능한 국가·지역에 제한 있음”이라고 썼는데, 일본 독자에게는 이 부분이 가장 중요한 판단 근거이므로 독립적으로 정리합니다.

Microsoft의 문서는 Azure Artifact Signing(구 Trusted Signing)을 이용할 수 있는 것은, 법인은 미국·캐나다·EU·영국, 개인 개발자는 미국·캐나다에 한정된다고 명시하고 있습니다.5일본 법인·일본 거주 개인 개발자는 현시점에서는 대상 밖입니다. “클라우드 서명이 정답”이라는 일반론을 그대로 가져오면 계정 생성 단계에서 막힙니다. 이 제한을 전제로 한 선택지는 다음과 같습니다.

상황 현실적인 선택
앞으로 공인 증명서를 취득한다(일본 법인) OV 증명서+CA의 클라우드 HSM 옵션. 2023년 6월 이후 OV 증명서의 비밀키는 HSM 또는 하드웨어 토큰 보관이 필수이지만, 많은 CA가 USB 토큰에 더해 클라우드 HSM 선택지를 마련해 두고 있어, 이쪽이라면 CI에서 서명을 호출할 수 있습니다.5 CI에 올릴 계획이 있다면, 증명서를 고르는 단계에서 클라우드 HSM 지원 여부를 CA에 확인해 주십시오. 토큰을 구매한 뒤에는 바꿀 수 없습니다
이미 USB 토큰으로 운용 중이다 서명 공정만 토큰을 꽂은 로컬 PC나 셀프 호스티드 러너에 남깁니다. 빌드·테스트·버전 번호 부여까지는 GitHub 호스팅 러너로 자동화하고, 마지막 서명만 사람의 손을 남기는 구성입니다
Microsoft Store(MSIX)로 배포할 수 있다 Store 쪽에서 Microsoft가 재서명하므로, 자체 증명서가 필요 없어집니다.5 배포 방식을 다시 고를 수 있다면, 서명 고민이 통째로 사라지는 가장 짧은 경로입니다(다만 MSI/EXE 인스톨러로 Store에 내는 경우는 publisher 쪽의 서명이 필요합니다)
오픈소스 프로젝트 SignPath Foundation이 조건을 충족하는 OSS 프로젝트에 무상 코드 서명을 제공하고 있습니다.5
사내 배포만 한다 사내 CA로 발급한 증명서와 기존 PFX 방식으로 충분합니다(5.3절). 증명서를 그룹 정책이나 Intune으로 신뢰된 루트로 배포할 수 있는 환경이라면 공인 증명서는 필요 없습니다

Azure Artifact Signing의 대상 지역은 앞으로 확대될 가능성이 있으므로, CI/CD의 서명 설계를 확정할 때는 그 시점의 대상 지역을 1차 정보로 확인해 주십시오.5

5.3 시크릿 관리 시 주의점

PFX 방식(사내 CA·기존 증명서)을 CI에 올릴 때의 정석입니다.

  • PFX는 Base64 문자열로 만들어 GitHub 시크릿에 저장하고, 잡 안에서 파일로 복원합니다. 바이너리를 시크릿으로 다루는 방법으로 GitHub Docs가 안내하는 절차입니다.10
  • 비밀번호는 별도 시크릿으로 둡니다. 시크릿 값은 로그에서 자동으로 마스킹되지만10, 가공한 파생 값까지는 보호되지 않습니다. 서명 단계 이외에는 환경 변수를 전달하지 않도록 합니다.
  • 포크에서 온 풀 리퀘스트에는(GITHUB_TOKEN을 제외하고) 시크릿이 전달되지 않습니다.10 다만 잡 자체는 빈 시크릿으로 실행되기 때문에, 위의 복원 단계는 빈 문자열의 Base64 디코딩으로 실패합니다. 서명 단계는 4장과 같은 태그 기동 릴리스 워크플로(포크 PR에서는 발화하지 않음)로 분리하거나, if: github.event_name != 'pull_request' 같은 조건을 붙여 명시적으로 건너뛰게 합니다.
      - name: Restore signing certificate
        shell: pwsh
        run: |
          $bytes = [Convert]::FromBase64String($env:PFX_BASE64)
          [IO.File]::WriteAllBytes("$env:RUNNER_TEMP\sign.pfx", $bytes)
        env:
          PFX_BASE64: ${{ secrets.SIGNING_PFX_BASE64 }}

5.4 PFX 방식의 완성형 워크플로

지금까지의 조각들(태그 기반 버전 주입·PFX 복원·signtool 실행·산출물 발행)을 하나로 이어붙인 형태를 보여드립니다. 사내 CA나 기존 PFX를 갖고 있다는 전제의 구성으로, 이를 그대로 리포지토리의 .github/workflows/release.yml에 두면 동작합니다. 프로젝트 경로와 타임스탬프 서버 URL은 자신의 환경에 맞게 바꿔 주십시오.

name: release

on:
  push:
    tags: [ 'v*' ]

jobs:
  release:
    runs-on: windows-latest
    # 서명 키에 접근하는 잡은 승인자가 있는 Environment에 묶어 둔다(5.5절).
    # 이를 생략하고 리포지토리 시크릿 그대로 두면, 쓰기 권한을 가진 사람
    # (또는 탈취된 계정)이 워크플로를 고쳐 쓰고 태그를 하나 push하는
    # 것만으로 서명 키를 꺼낼 수 있습니다. 이 environment를 지정하고,
    # SIGNING_PFX_BASE64와 SIGNING_PFX_PASSWORD는 Environment 쪽에 둡니다
    environment: release-signing
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Test
        run: dotnet test --configuration Release

      - name: Publish with version from tag
        shell: pwsh
        run: |
          $version = $env:GITHUB_REF_NAME.TrimStart('v')
          dotnet publish src/MyApp/MyApp.csproj `
            -c Release -o publish `
            -p:Version=$version

      # --- 여기부터 서명 ---
      - name: Restore signing certificate
        shell: pwsh
        run: |
          $bytes = [Convert]::FromBase64String($env:PFX_BASE64)
          [IO.File]::WriteAllBytes("$env:RUNNER_TEMP\sign.pfx", $bytes)
        env:
          PFX_BASE64: ${{ secrets.SIGNING_PFX_BASE64 }}

      - name: Sign
        shell: pwsh
        run: |
          # signtool은 Windows SDK에 포함됨. 경로를 고정하지 않고 탐색한다
          $signtool = Get-ChildItem `
            "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" |
            Sort-Object FullName | Select-Object -Last 1

          # exe뿐 아니라, 함께 배포되는 DLL도 서명한다.
          # 배포 대상이 App Control / AppLocker의 발행자 규칙으로 DLL도
          # 확인하는 경우, 서명이 없는 DLL이 하나라도 있으면 거기서 막힌다.
          #
          # 다만 대상은 자사 빌드만으로 한정한다. publish에는 NuGet 유래나
          # 프레임워크의 DLL도 들어 있으므로, 와일드카드로 전부 잡으면
          # 벤더가 서명한 DLL에 자사 증명서를 덮어쓰게 되고(/as를 붙이지
          # 않은 sign은 기존 서명을 교체한다), 제3자의 미서명 DLL도
          # "자사 산출물"로 세상에 내보내게 된다. 발행자 기반 허용 규칙이나
          # 출처 확인이 무너지므로, 이름으로 명시적으로 나열한다
          $ownAssemblies = @('MyApp', 'MyApp.Core', 'MyApp.Plugins')
          $targets = Get-ChildItem publish -Recurse -Include *.exe, *.dll |
            Where-Object { $ownAssemblies -contains $_.BaseName } |
            Select-Object -ExpandProperty FullName
          if (-not $targets) { throw '서명 대상을 찾을 수 없습니다. publish의 내용을 확인해 주십시오.' }

          & $signtool.FullName sign `
            /f "$env:RUNNER_TEMP\sign.pfx" /p $env:PFX_PASSWORD `
            /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 `
            $targets
          if ($LASTEXITCODE -ne 0) { throw "서명에 실패했습니다 (exit $LASTEXITCODE)" }
        env:
          PFX_PASSWORD: ${{ secrets.SIGNING_PFX_PASSWORD }}

      - name: Remove certificate
        if: always()
        shell: pwsh
        run: Remove-Item "$env:RUNNER_TEMP\sign.pfx" -ErrorAction SilentlyContinue

      # --- 여기부터 산출물 ---
      - name: Zip
        shell: pwsh
        run: Compress-Archive -Path publish\* -DestinationPath MyApp-${{ github.ref_name }}.zip

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v3
        with:
          files: MyApp-${{ github.ref_name }}.zip

읽을 때의 요점은 다섯 가지입니다.

  • environment: release-signing을 빼지 않는다. 서명 키를 리포지토리 시크릿에 그대로 두면, 리포지토리 쓰기 권한을 가진 사람은 누구든 워크플로를 고쳐 쓰고 태그를 하나 push하는 것만으로 키를 꺼낼 수 있습니다. 승인자가 있는 Environment에 시크릿을 두고, 이 잡만이 그것을 참조하는 형태로 만듭니다(5.5절). 이 줄이 없는 워크플로를 “완성형”으로 운용하지 마십시오.
  • 서명 대상은 자사 빌드만으로 한정한다. publish에는 의존 패키지의 DLL도 들어 있습니다. 한꺼번에 서명하면 벤더의 서명을 자사 증명서로 교체하는 데다, 제3자의 미서명 DLL을 자사 산출물로 배포하게 됩니다. 아무래도 제3자 바이너리에 서명을 추가해야 한다면, 교체가 아니라 /as로 추가합니다.
  • 서명은 dotnet publish 이후, 압축 이전에. zip으로 묶은 뒤 서명해도 안의 EXE는 서명되지 않습니다. MSI나 MSIX를 만드는 경우도, 먼저 내부의 EXE/DLL에 서명하고, 그다음 패키지를 만들고, 마지막에 패키지 자체에 서명하는 순서가 됩니다.
  • PFX는 다 쓰면 삭제한다. if: always()를 붙여, 서명이 실패해도 삭제 단계가 실행되도록 합니다. GitHub 호스팅 러너는 잡마다 폐기되므로1 필수는 아니지만, 셀프 호스티드 러너로 옮겼을 때 사고가 됩니다.
  • 이 워크플로는 태그 push에서만 실행됩니다. 포크에서 온 풀 리퀘스트에서는 발화하지 않으므로, 5.3절에서 언급한 시크릿 공백 문제를 구조적으로 피할 수 있습니다.

클라우드 서명 서비스나 클라우드 HSM을 사용하는 경우, “Restore signing certificate”와 “Sign” 두 단계가 서비스 쪽이 제공하는 액션이나 CLI 호출로 바뀔 뿐, 전후 형태는 달라지지 않습니다.

5.5 서명 키에 접근하는 워크플로를 제한하기

시크릿 다루는 법(5.3절)과 나란히 있는 또 하나의 급소가, 서명 키에 접근할 수 있는 워크플로를 제한하는 것입니다. 리포지토리 쓰기 권한을 가진 사람은 워크플로를 고쳐 쓸 수 있으므로, 서명용 시크릿은 승인자가 있는 Environment에 두고 릴리스 워크플로만 참조할 수 있도록 합니다. 서명된 바이너리는 “자사가 만들었다”는 증명 그 자체이므로, 키를 다루는 방식은 자동 업데이트 배포 기반과 같은 수준의 신뢰 경계로 설계합니다(이 관점은 “자동 업데이트의 보안” 참조).

6. 배포 형식별 CI/CD 통합 판단표

서명까지 왔다면, 마지막은 배포물의 형태입니다. 방식 선정 그 자체는 “배포 방식 판단표“에 맡기고, 여기서는 CI/CD 관점만으로 비교합니다.

배포 형식 CI에서의 만들기 쉬움 CI에서의 작성 수단 서명 요건 자동 업데이트
xcopy(zip 배포) 가장 간단 dotnet publish+압축뿐 EXE/DLL에 대한 서명(권장) 없음(수동 전개)
xcopy+자체 updater 간단(빌드는). 업데이트 배포 설계는 별도로 무거움 dotnet publish+매니페스트 생성 EXE 서명+업데이트 파일 검증 설계가 필수 자체(신뢰 경계 설계가 필요)
MSI 중간 정도 WiX 등의 도구를 CLI에서 실행 MSI 파일에 대한 서명(권장~사실상 필수) 없음(별도 배포 체계가 필요)
MSIX 중간 정도 MSBuild / MakeAppx+signtool 패키지 서명이 필수(미서명은 설치 불가)6 App Installer 등으로 대응 가능
ClickOnce 특성이 강함 msbuild /target:publish+발행 프로필(dotnet CLI 비대응)7 매니페스트 서명+EXE 서명 내장(방식의 주된 목적)

보충하겠습니다.

  • xcopy(zip): 3장·4장의 워크플로가 거의 그대로 완성형입니다. 배포 방식이 최종적으로 무엇이 되든, 우선 한 번 이 형태를 거치는 것이 지름길입니다.
  • MSI: 인스톨러 정의(WiX 등)를 리포지토리에 포함시켜 CLI로 빌드합니다. 생성 자체보다 “MSI에 무엇을 담을지(서비스 등록, per-machine/per-user)”의 설계가 본체입니다.
  • MSIX: Windows는 서명되지 않은 MSIX의 설치를 허용하지 않으므로, 서명 자동화와 짝을 이루지 않으면 CI화가 완결되지 않습니다.6 반대로 서명 기반이 갖춰져 있다면 CI에 올리기 쉬운 형식입니다. Microsoft Store 배포라면 Store 쪽에서 재서명되므로, 자체 증명서가 필요 없어지는 대안도 있습니다.5
  • ClickOnce: dotnet CLI로는 발행할 수 없고, 발행 프로필(.pubxml)을 지정한 msbuild /target:publish /p:PublishProfile=...을 사용합니다. IDE에서는 발행할 때마다 자동으로 증가하는 리비전 번호(ApplicationRevision)가 커맨드라인에서는 증가하지 않으므로7, 4장의 태그 기반으로 버전을 명시적으로 전달하는 설계가 필수입니다. 이때의 주의점으로, ClickOnce의 업데이트 판정은 -p:Version(어셈블리 정보)이 아니라 배포 쪽의 버전(ApplicationVersion / ApplicationRevision)으로 이루어지므로, 태그로 만든 4부분 형식의 값을 /p:ApplicationVersion=1.2.3.0처럼 별도로 전달하지 않으면 새 릴리스가 업데이트로 인식되지 않습니다. 구조와 적합·부적합은 “ClickOnce란 무엇인가“에서 해설하고 있습니다.

CI/CD 관점만으로 말하면, “zip으로 시작해서, 배포 요건이 확정되면 MSIX나 MSI를 잡으로 추가한다”가 증분이 적은 진행 방식입니다. 전 단계(빌드·테스트·버전)는 모든 형식에서 공통이므로, 나중에 배포 형식 단계를 바꿔도 자산은 낭비되지 않습니다.

7. 테스트 자동화를 어디까지 할 것인가

마지막으로, CI의 관문(필수 체크)으로서 테스트를 어디까지 요구할지의 경계선입니다.

테스트 계층 CI에서의 취급 이유
유닛 테스트(로직) 필수 관문. 풀 리퀘스트마다 실행 빠르고·안정적이며·Windows 러너에서 그대로 동작
화면 없는 통합 테스트(DB·파일 I/O) 원칙적으로 필수. 느리면 야간 실행으로 분리 외부 의존성 초기화에 궁리가 필요하지만 자동화 가치가 높음
UI 자동 테스트(스모크) 별도 잡에서 소수만. 기동~주요 화면 전환 정도 데스크톱 세션 필수로 불안정 요인이 많음
UI 자동 테스트(전수) CI의 관문으로 삼지 않음 유지 비용이 이익을 웃돌기 쉬움

데스크톱 앱에서 자동화 가치가 가장 높은 것은 UI가 아니라 그 아래쪽입니다. 코드비하인드에 업무 로직이 묻혀 있으면 유닛 테스트를 작성할 수 없으므로, 로직을 화면에서 분리하는 것 자체가 CI/CD의 전제 투자가 됩니다. UI 자동 테스트는 “기동해서, 로그인하고, 주요 화면이 열린다”는 스모크로 좁히고, 야간 등 별도 트리거로 돌리는 것이 현실적인 해법입니다. 러너상의 UI 테스트에는 화면 세션·해상도·타이밍의 함정이 많아, 이 영역은 “Windows 데스크톱 앱의 UI 자동 테스트“에서 CI·무인 실행의 함정까지 포함해 다루고 있습니다.

8. 정리

  • 데스크톱 앱의 CI/CD는 출구를 “서명이 끝난 배포물의 완성”으로 정의하면, 웹과 같은 도구로 구성할 수 있습니다.
  • 최소 구성은 windows-latest+actions/checkout+actions/setup-dotnet+dotnet build / test+actions/upload-artifact. 이것만으로 “개발자 PC에서만 빌드할 수 있다”는 리스크가 사라집니다.12
  • WinForms / WPF는 net8.0-windows 등 Windows 전용 TFM을 사용하므로, Windows 러너가 전제입니다.3
  • 버전은 v1.2.3 태그→-p:Version 주입의 태그 기반으로 일원화합니다.4
  • 서명은 CI 자동화의 가장 큰 벽입니다. OV 증명서도 HSM 보관이 필수가 된 지금, Azure Artifact Signing은 일본이 대상 지역 밖이므로, CA의 클라우드 HSM 옵션을 증명서 선정 단계에서 확인하는 것이 실무의 입구입니다. PFX+시크릿 방식은 사내 CA·기존 증명서용이며, 5.4절에 완성형 워크플로를 실었습니다.510
  • 태그 빌드의 산출물은 softprops/action-gh-release나 러너에 포함된 gh release create로 GitHub 릴리스에 첨부해 장기 보관합니다(잡에 contents: write가 필요).8
  • 배포 형식이 CI에 올리기 쉬운 순서는 xcopy(zip)→MSI / MSIX→ClickOnce입니다. MSIX는 서명 필수6, ClickOnce는 msbuild /target:publish와 리비전 자동 미증가에 주의가 필요합니다.7
  • 유닛 테스트를 CI의 필수 관문으로, UI 테스트는 스모크로 좁혀 별도 잡으로 돌립니다. 로직을 화면에서 분리하는 것이 전제 투자입니다.

관련 글

관련 상담 영역

합동회사 코무라소프트에서는 WinForms / WPF 앱 개발과 더불어, 로컬 빌드 운용에서 CI/CD로의 이행, GitHub Actions를 이용한 빌드·서명·배포 파이프라인 설계, 기존 데스크톱 앱의 테스트 가능화(로직 분리)를 다루고 있습니다.

참고 링크

  1. GitHub Docs, GitHub-hosted runners reference. windows-latest 등의 러너 라벨, 잡마다 새 가상 머신이 할당된다는 점, 퍼블릭 리포지토리에서는 표준 러너가 무료라는 점에 대해.  2 3 4 5

  2. Microsoft Learn, GitHub Actions and .NET. GitHub Actions를 이용한 .NET의 CI/CD, actions/checkout·actions/setup-dotnet의 역할, 워크플로 안에서의 dotnet restore / build / test / publish 이용에 대해.  2

  3. Microsoft Learn, MSBuild reference for .NET Desktop SDK projects. WinForms / WPF 프로젝트는 net8.0-windows와 같은 Windows 고유의 TFM을 지정하며, UseWindowsForms / UseWPF로 .NET 데스크톱 SDK를 활성화한다는 점에 대해.  2 3

  4. Microsoft Learn, Set assembly attributes in a project file. Version 속성에서 AssemblyVersion / FileVersion(접미부 제외)과 InformationalVersion이 기본값으로 생성된다는 점, .NET 8 SDK부터 SourceRevisionId(커밋 해시)가 InformationalVersion에 추가된다는 점에 대해.  2 3 4

  5. Microsoft Learn, Code signing options for Windows app developers. 2023년 6월 이후 CA/Browser Forum 요건에 따라 OV 증명서의 비밀키는 HSM/하드웨어 토큰 보관이 필수라는 점, EV 증명서의 최초 SmartScreen 회피가 2024년에 폐지되었다는 점, Azure Artifact Signing(구 Trusted Signing)은 토큰이 필요 없고 GitHub Actions 등과 통합할 수 있으며 이용 지역에 제한이 있다는 점, Store의 MSIX 배포에서는 Microsoft가 재서명한다는 점에 대해.  2 3 4 5 6 7 8 9 10 11 12

  6. Microsoft Learn, Sign an MSIX package. Windows는 MSIX 패키지에 대한 유효한 코드 서명을 필수로 한다는 점, 타임스탬프로 인해 증명서 기한 이후에도 서명 검증이 유효하게 유지된다는 점에 대해.  2 3 4 5

  7. Microsoft Learn, Build .NET ClickOnce applications from the command line. .NET의 ClickOnce 발행에는 발행 프로필을 지정한 msbuild /target:publish가 필요하다는 점, ApplicationRevision이 커맨드라인 빌드에서는 자동으로 증가하지 않는다는 점에 대해.  2 3 4

  8. GitHub Docs, Using GitHub CLI in workflows. GitHub CLI(gh)가 모든 GitHub 호스팅 러너에 사전 설치되어 있다는 점, gh를 사용하는 각 단계에서 필요한 스코프를 가진 토큰을 GH_TOKEN 환경 변수에 설정해야 한다는 점에 대해.  2 3

  9. Microsoft Learn, SignTool. SignTool은 Windows SDK에 포함된다는 점, 현행 빌드에서는 /fd/td 지정이 필수이며 SHA256이 권장된다는 점, /tr을 이용한 RFC 3161 타임스탬프 지정에 대해.  2

  10. GitHub Docs, Using secrets in GitHub Actions. 시크릿 값이 로그에서 자동으로 비공개 처리된다는 점, 증명서 등의 바이너리를 Base64로 시크릿에 저장해 잡 안에서 복원하는 절차, 포크에서 기동된 워크플로에는 시크릿이 전달되지 않는다는 점에 대해.  2 3 4

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

WinForms / WPF 앱의 빌드는 GitHub Actions의 어떤 러너에서 돌려야 하나요?
windows-latest 같은 Windows 러너를 사용합니다. WinForms / WPF 프로젝트는 net8.0-windows처럼 Windows 전용 타깃 프레임워크를 대상으로 하며, 빌드 산출물의 동작 확인이나 dotnet test에 의한 테스트 실행에는 Windows 환경이 필요합니다. GitHub 호스팅 러너는 잡마다 새 가상 머신이 할당되므로, 개발자 PC에 의존하지 않는 재현 가능한 빌드를 얻을 수 있습니다. 퍼블릭 리포지토리에서는 표준 러너가 무료이고, 프라이빗 리포지토리에서는 실행 시간이 분 단위로 과금됩니다.
코드 서명은 CI에서 완전히 자동화할 수 있나요?
증명서를 어떤 형태로 갖고 있는지에 달려 있습니다. 예전처럼 PFX 파일을 시크릿에 두고 signtool로 서명하는 방식은, 2023년 6월 이후 CA/Browser Forum 요건에 따라 공인 OV 증명서의 비밀키가 HSM(하드웨어) 보관 필수가 되었기 때문에 신규 발급 증명서에서는 원칙적으로 쓸 수 없습니다. USB 토큰형 증명서는 클라우드 러너에 꽂을 수 없으므로, CI에서 완전 자동화하려면 Azure Artifact Signing(구 Trusted Signing) 같은 클라우드 서명 서비스나, CA가 제공하는 클라우드 HSM을 거치는 것이 현실적입니다. 토큰 운용을 그대로 유지한다면, 서명 공정만 로컬 또는 셀프 호스티드 러너에 남기는 구성이 됩니다.
우선 어디부터 자동화를 시작해야 하나요?
빌드+테스트 자동화만 먼저 넣어야 합니다. push할 때마다 windows-latest 러너에서 dotnet build / dotnet test가 실행되는 상태를 만드는 것만으로, '개발자 PC에서만 빌드할 수 있다', '머지로 빌드가 깨진 것을 배포 직전까지 알아채지 못한다'는 최대 리스크가 사라집니다. 서명·인스톨러 작성·배포의 자동화는 그 다음 단계적으로 추가하면 되며, 처음부터 전부를 한 번에 구성하려고 하면 서명 부분에서 막히기 쉽습니다.
배포 형식(MSI / MSIX / ClickOnce / xcopy)에 따라 CI/CD를 구성하는 난이도가 달라지나요?
크게 달라집니다. xcopy 배포(zip)는 dotnet publish의 출력을 압축하기만 하면 되므로 가장 간단합니다. MSIX는 MSBuild와 signtool로 CI에 올릴 수 있지만, 패키지 서명이 필수입니다. MSI는 WiX 같은 도구를 CI에서 호출하는 형태로 자동화할 수 있습니다. ClickOnce는 dotnet CLI로는 발행할 수 없고 msbuild /target:publish와 발행 프로필의 조합이 필요하며, 커맨드라인에서는 리비전 번호가 자동으로 증가하지 않는다는 점에도 주의가 필요합니다. 배포 방식을 정하는 단계에서 CI에 올리기 쉬운지도 판단 기준에 포함해야 합니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기