From 6f091ba60926b518e7d24f65a7146e01281074e6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 28 Aug 2026 01:24:40 +0900 Subject: [PATCH 1/5] test(ci): define desktop release contract --- tests/ci/release-workflow.test.ts | 89 +++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/ci/release-workflow.test.ts diff --git a/tests/ci/release-workflow.test.ts b/tests/ci/release-workflow.test.ts new file mode 100644 index 0000000..f406ed1 --- /dev/null +++ b/tests/ci/release-workflow.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +type Step = { + id?: string; + uses?: string; + with?: Record; +}; + +type Job = { + if?: string; + needs?: string | string[]; + strategy?: { + matrix?: { + include?: Array>; + }; + }; + steps?: Step[]; +}; + +type Workflow = { + jobs: Record }>; +}; + +const repositoryRoot = resolve(import.meta.dir, "../.."); +const workflow = Bun.YAML.parse( + readFileSync(resolve(repositoryRoot, ".github/workflows/ci.yml"), "utf8"), +) as Workflow; +const tauriConfig = JSON.parse( + readFileSync( + resolve(repositoryRoot, "apps/desktop/src-tauri/tauri.conf.json"), + "utf8", + ), +) as { version: string }; + +describe("desktop release workflow", () => { + test("uses the changepacks package version for Tauri bundles", () => { + expect(tauriConfig.version).toBe("../../../package.json"); + }); + + test("exports the draft release receipt", () => { + expect(workflow.jobs.changepacks.outputs?.pending_releases).toBe( + "${{ steps.changepacks.outputs.pending_releases }}", + ); + }); + + test("builds only the approved desktop bundles", () => { + const releaseJob = workflow.jobs["release-desktop"]; + const matrix = releaseJob.strategy?.matrix?.include; + + expect(releaseJob.if).toContain("pending_releases"); + expect(matrix).toEqual([ + { + args: "--bundles nsis,msi", + platform: "windows-latest", + }, + { + args: "--target universal-apple-darwin --bundles dmg", + platform: "macos-latest", + targets: "aarch64-apple-darwin,x86_64-apple-darwin", + }, + { + args: "--bundles appimage,deb", + platform: "ubuntu-22.04", + }, + ]); + expect(JSON.stringify(matrix)).not.toContain("android"); + }); + + test("uploads into the existing draft and finalizes after all builds", () => { + const releaseJob = workflow.jobs["release-desktop"]; + const tauriStep = releaseJob.steps?.find((step) => + step.uses?.startsWith("tauri-apps/tauri-action@"), + ); + const finalizeJob = workflow.jobs["finalize-release"]; + const finalizeStep = finalizeJob.steps?.find((step) => + step.uses?.startsWith("changepacks/action@"), + ); + + expect(tauriStep?.uses).toMatch(/^tauri-apps\/tauri-action@[0-9a-f]{40}$/); + expect(tauriStep?.with?.releaseId).toContain("pending_releases"); + expect(tauriStep?.with?.releaseDraft).toBe(true); + expect(finalizeJob.needs).toEqual(["changepacks", "release-desktop"]); + expect(finalizeStep?.with?.finalize_releases).toBe( + "${{ needs.changepacks.outputs.pending_releases }}", + ); + }); +}); From 08d14101a819c66f7d5d17af10931646b47dd3ca Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 28 Aug 2026 01:28:22 +0900 Subject: [PATCH 2/5] feat(ci): publish desktop release bundles --- .github/workflows/ci.yml | 82 +++++++++++++++++++++++++- apps/desktop/src-tauri/tauri.conf.json | 2 +- package.json | 1 + tests/ci/release-workflow.test.ts | 9 +++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f4d8c9..46e1c3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,6 @@ jobs: # 공개 저장소이므로 모든 작업은 GitHub 호스팅 러너에서 돈다. # self-hosted 러너에서 PR 코드를 실행하면 (postinstall, 테스트, 빌드) # 누구든 PR 하나로 우리 인프라에서 임의 코드를 돌릴 수 있다. - # 배포는 이 워크플로에 두지 않는다. check: runs-on: ubuntu-latest steps: @@ -78,5 +77,86 @@ jobs: - uses: actions/checkout@v7 - uses: changepacks/action@ff3d7d0ddbce5dd21c4db0fc5932493b320cf85e # main id: changepacks + with: + # 한번은 레지스트리에 배포할 패키지가 아닌 private 앱이다. + # publish 모드는 GitHub Release를 draft로 남기고 데스크톱 빌드에 + # 최종 공개를 위임하기 위해 사용한다. + publish: true outputs: changepacks: ${{ steps.changepacks.outputs.changepacks }} + pending_releases: ${{ steps.changepacks.outputs.pending_releases }} + + release-desktop: + name: release (${{ matrix.platform }}) + needs: + - changepacks + if: ${{ contains(needs.changepacks.outputs.pending_releases, '"package.json"') }} + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - platform: windows-latest + args: --bundles nsis,msi + - platform: macos-latest + args: --target universal-apple-darwin --bundles dmg + targets: aarch64-apple-darwin,x86_64-apple-darwin + - platform: ubuntu-22.04 + args: --bundles appimage,deb + runs-on: ${{ matrix.platform }} + steps: + - name: Checkout code + uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.targets || '' }} + - name: Install Linux desktop dependencies + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev \ + build-essential \ + libssl-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libxdo-dev \ + libasound2-dev \ + libudev-dev \ + patchelf \ + pkg-config + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Build and upload desktop bundles + uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 + env: + APPLE_SIGNING_IDENTITY: ${{ matrix.platform == 'macos-latest' && '-' || '' }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: ${{ matrix.args }} + projectPath: apps/desktop + releaseAssetNamePattern: hanbeon-[version]-[platform]-[arch]-[bundle][ext] + releaseDraft: true + releaseId: ${{ fromJSON(needs.changepacks.outputs.pending_releases)['package.json'].releaseId }} + tagName: ${{ fromJSON(needs.changepacks.outputs.pending_releases)['package.json'].tagName }} + tauriScript: bun tauri + uploadUpdaterJson: false + + finalize-release: + name: finalize release + needs: + - changepacks + - release-desktop + if: ${{ contains(needs.changepacks.outputs.pending_releases, '"package.json"') }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Publish completed release + uses: changepacks/action@ff3d7d0ddbce5dd21c4db0fc5932493b320cf85e # main + with: + finalize_releases: ${{ needs.changepacks.outputs.pending_releases }} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 1c3e445..482b534 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "한번", "mainBinaryName": "hanbeon", - "version": "0.1.0", + "version": "../../../package.json", "identifier": "kr.devfive.hanbeon", "build": { "frontendDist": "../out", diff --git a/package.json b/package.json index c720779..641b633 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "description": "한번 - 상황적응형 싱글스위치 접근성 소프트웨어", "license": "MIT", + "private": true, "type": "module", "devDependencies": { "eslint-plugin-devup": "^2.1.0", diff --git a/tests/ci/release-workflow.test.ts b/tests/ci/release-workflow.test.ts index f406ed1..ca5c187 100644 --- a/tests/ci/release-workflow.test.ts +++ b/tests/ci/release-workflow.test.ts @@ -33,6 +33,9 @@ const tauriConfig = JSON.parse( "utf8", ), ) as { version: string }; +const rootPackage = JSON.parse( + readFileSync(resolve(repositoryRoot, "package.json"), "utf8"), +) as { private?: boolean }; describe("desktop release workflow", () => { test("uses the changepacks package version for Tauri bundles", () => { @@ -40,6 +43,12 @@ describe("desktop release workflow", () => { }); test("exports the draft release receipt", () => { + const changepacksStep = workflow.jobs.changepacks.steps?.find((step) => + step.uses?.startsWith("changepacks/action@"), + ); + + expect(rootPackage.private).toBe(true); + expect(changepacksStep?.with?.publish).toBe(true); expect(workflow.jobs.changepacks.outputs?.pending_releases).toBe( "${{ steps.changepacks.outputs.pending_releases }}", ); From 7f5e9ea0e194aba2ff502499e5d4f0021103078a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 28 Aug 2026 01:42:56 +0900 Subject: [PATCH 3/5] fix(release): produce localized native bundles --- .../changepack_log_1Iu3QWcglBMq3jWzaRWYO.json | 7 + .github/workflows/ci.yml | 4 +- apps/desktop/src-tauri/icons/icon.icns | Bin 0 -> 12657 bytes apps/desktop/src-tauri/tauri.conf.json | 7 +- tests/ci/release-workflow.test.ts | 140 ++++++++++-------- 5 files changed, 90 insertions(+), 68 deletions(-) create mode 100644 .changepacks/changepack_log_1Iu3QWcglBMq3jWzaRWYO.json create mode 100644 apps/desktop/src-tauri/icons/icon.icns diff --git a/.changepacks/changepack_log_1Iu3QWcglBMq3jWzaRWYO.json b/.changepacks/changepack_log_1Iu3QWcglBMq3jWzaRWYO.json new file mode 100644 index 0000000..8c90cd1 --- /dev/null +++ b/.changepacks/changepack_log_1Iu3QWcglBMq3jWzaRWYO.json @@ -0,0 +1,7 @@ +{ + "changes": { + "package.json": "Patch" + }, + "note": "윈도우, macOS, Linux 설치 파일을 GitHub Release에 자동으로 첨부합니다.", + "date": "2026-08-27T16:28:45.939578800Z" +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46e1c3e..a1ad3a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,12 +98,12 @@ jobs: matrix: include: - platform: windows-latest - args: --bundles nsis,msi + args: --bundles nsis msi - platform: macos-latest args: --target universal-apple-darwin --bundles dmg targets: aarch64-apple-darwin,x86_64-apple-darwin - platform: ubuntu-22.04 - args: --bundles appimage,deb + args: --bundles appimage deb runs-on: ${{ matrix.platform }} steps: - name: Checkout code diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..950428f712153a51f5e60211b6817369966e4789 GIT binary patch literal 12657 zcmeHNT}TvB6h5=NuC9NihKS@J1qFi6YLTLag{Hn(ST9k~hNLWQp}RyTVQYV8fgpqt z^^m?87O6e-5L63E5<-MnM3^83^|0X&!ct3ZXJ)tUx--t~)twUd-huJn>zVJI``vTT zxifdI4W}Id<`%1?xCj6?`VzbezBX^0&+fH5N=}^w;NpByUaU5en-X6FdCehD%xMGP`> z3`7nI?r<~JF`n=JqlM^kP+L`3`Dm}@qP4+X%=~k$%PE4>^k;!b9psAs$pfVClg3X2 zda=PDKQCnEUqYdnK>Yk*PQmyIBgwWiI!=1XUB%zvG~N@^i;efxmYrUn`+e} zNMUfpVi9gi)$kDgD#Jr0RB8mSN`ORw!g9&83WFeq8eUZ^a0MIXRfcD--*ITbDTvk- zTz=F_y%d%!!Gw}UL$oNX6?mv&s(NTFrvaxRdfm8)1!@2bh3TsF5&~K=EzXKo;1pb} zn`!iOPGLDQNvc&EqD5I%L_&ZDoMtgH7Xd5;(f%rPkq}z6@;@q~bA>%HjdxR_3nLB> zoQ4chR^nd@dT78EVB}S51x`UUO9rxJ$@5eWeW7|9@HN3GZ*o&U4pg48uz;pY3_={*IX9|+&?3C7IZ0b+Bc zjOkEH=RY1;YMuOnp?NyM*!w>J@v3R+dDUWhdB^6`zQKijF9!pc4Pw63i?1#&1VI19 z(+9!z68))k<$qC%Ll7w}^uOYSO+g5vPt{HViDFpG6({0`#e%%7N;6{!RLEltF@|t$ zvQ?%Qq8LLV3=KEI)ohdJ)9ayb49`S*sDurXfMR#4bLG9O|- z^sjtq#q?-4W43ITzCA)N_UDT4+m5!jC2SN1-X7oJk8{BQeCPfn1B;{cHdF78XA9eA XZ#$P27dJ7bJA3fgs~CJVn2vt~`{K{x literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 482b534..cdc66cd 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -60,7 +60,9 @@ "icon": [ "icons/32x32.png", "icons/128x128.png", - "icons/128x128@2x.png" + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" ], "macOS": { "minimumSystemVersion": "10.15" @@ -68,6 +70,9 @@ "windows": { "webviewInstallMode": { "type": "downloadBootstrapper" + }, + "wix": { + "language": "ko-KR" } } } diff --git a/tests/ci/release-workflow.test.ts b/tests/ci/release-workflow.test.ts index ca5c187..04a1e09 100644 --- a/tests/ci/release-workflow.test.ts +++ b/tests/ci/release-workflow.test.ts @@ -1,98 +1,108 @@ -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { describe, expect, test } from 'bun:test' type Step = { - id?: string; - uses?: string; - with?: Record; -}; + id?: string + uses?: string + with?: Record +} type Job = { - if?: string; - needs?: string | string[]; + if?: string + needs?: string | string[] strategy?: { matrix?: { - include?: Array>; - }; - }; - steps?: Step[]; -}; + include?: Array> + } + } + steps?: Step[] +} type Workflow = { - jobs: Record }>; -}; + jobs: Record }> +} -const repositoryRoot = resolve(import.meta.dir, "../.."); +const repositoryRoot = resolve(import.meta.dir, '../..') const workflow = Bun.YAML.parse( - readFileSync(resolve(repositoryRoot, ".github/workflows/ci.yml"), "utf8"), -) as Workflow; + readFileSync(resolve(repositoryRoot, '.github/workflows/ci.yml'), 'utf8'), +) as Workflow const tauriConfig = JSON.parse( readFileSync( - resolve(repositoryRoot, "apps/desktop/src-tauri/tauri.conf.json"), - "utf8", + resolve(repositoryRoot, 'apps/desktop/src-tauri/tauri.conf.json'), + 'utf8', ), -) as { version: string }; +) as { + bundle: { icon: string[]; windows: { wix: { language: string } } } + version: string +} const rootPackage = JSON.parse( - readFileSync(resolve(repositoryRoot, "package.json"), "utf8"), -) as { private?: boolean }; + readFileSync(resolve(repositoryRoot, 'package.json'), 'utf8'), +) as { private?: boolean } + +describe('desktop release workflow', () => { + test('uses the changepacks package version for Tauri bundles', () => { + expect(tauriConfig.version).toBe('../../../package.json') + }) -describe("desktop release workflow", () => { - test("uses the changepacks package version for Tauri bundles", () => { - expect(tauriConfig.version).toBe("../../../package.json"); - }); + test('includes native installer icons', () => { + expect(tauriConfig.bundle.icon).toContain('icons/icon.ico') + expect(tauriConfig.bundle.icon).toContain('icons/icon.icns') + expect(tauriConfig.bundle.windows.wix.language).toBe('ko-KR') + }) - test("exports the draft release receipt", () => { + test('exports the draft release receipt', () => { const changepacksStep = workflow.jobs.changepacks.steps?.find((step) => - step.uses?.startsWith("changepacks/action@"), - ); + step.uses?.startsWith('changepacks/action@'), + ) - expect(rootPackage.private).toBe(true); - expect(changepacksStep?.with?.publish).toBe(true); + expect(rootPackage.private).toBe(true) + expect(changepacksStep?.with?.publish).toBe(true) expect(workflow.jobs.changepacks.outputs?.pending_releases).toBe( - "${{ steps.changepacks.outputs.pending_releases }}", - ); - }); + '${{ steps.changepacks.outputs.pending_releases }}', + ) + }) - test("builds only the approved desktop bundles", () => { - const releaseJob = workflow.jobs["release-desktop"]; - const matrix = releaseJob.strategy?.matrix?.include; + test('builds only the approved desktop bundles', () => { + const releaseJob = workflow.jobs['release-desktop'] + const matrix = releaseJob.strategy?.matrix?.include - expect(releaseJob.if).toContain("pending_releases"); + expect(releaseJob.if).toContain('pending_releases') expect(matrix).toEqual([ { - args: "--bundles nsis,msi", - platform: "windows-latest", + args: '--bundles nsis msi', + platform: 'windows-latest', }, { - args: "--target universal-apple-darwin --bundles dmg", - platform: "macos-latest", - targets: "aarch64-apple-darwin,x86_64-apple-darwin", + args: '--target universal-apple-darwin --bundles dmg', + platform: 'macos-latest', + targets: 'aarch64-apple-darwin,x86_64-apple-darwin', }, { - args: "--bundles appimage,deb", - platform: "ubuntu-22.04", + args: '--bundles appimage deb', + platform: 'ubuntu-22.04', }, - ]); - expect(JSON.stringify(matrix)).not.toContain("android"); - }); + ]) + expect(JSON.stringify(matrix)).not.toContain('android') + }) - test("uploads into the existing draft and finalizes after all builds", () => { - const releaseJob = workflow.jobs["release-desktop"]; + test('uploads into the existing draft and finalizes after all builds', () => { + const releaseJob = workflow.jobs['release-desktop'] const tauriStep = releaseJob.steps?.find((step) => - step.uses?.startsWith("tauri-apps/tauri-action@"), - ); - const finalizeJob = workflow.jobs["finalize-release"]; + step.uses?.startsWith('tauri-apps/tauri-action@'), + ) + const finalizeJob = workflow.jobs['finalize-release'] const finalizeStep = finalizeJob.steps?.find((step) => - step.uses?.startsWith("changepacks/action@"), - ); + step.uses?.startsWith('changepacks/action@'), + ) - expect(tauriStep?.uses).toMatch(/^tauri-apps\/tauri-action@[0-9a-f]{40}$/); - expect(tauriStep?.with?.releaseId).toContain("pending_releases"); - expect(tauriStep?.with?.releaseDraft).toBe(true); - expect(finalizeJob.needs).toEqual(["changepacks", "release-desktop"]); + expect(tauriStep?.uses).toMatch(/^tauri-apps\/tauri-action@[0-9a-f]{40}$/) + expect(tauriStep?.with?.releaseId).toContain('pending_releases') + expect(tauriStep?.with?.releaseDraft).toBe(true) + expect(finalizeJob.needs).toEqual(['changepacks', 'release-desktop']) expect(finalizeStep?.with?.finalize_releases).toBe( - "${{ needs.changepacks.outputs.pending_releases }}", - ); - }); -}); + '${{ needs.changepacks.outputs.pending_releases }}', + ) + }) +}) From 562323886d8fd83f8ba3617586ea487886efc62e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 28 Aug 2026 02:06:59 +0900 Subject: [PATCH 4/5] fix(ci): isolate desktop release credentials --- .github/workflows/ci.yml | 85 ++++++++++++++---- scripts/ci/collect-release-assets.ts | 114 ++++++++++++++++++++++++ tests/ci/collect-release-assets.test.ts | 53 +++++++++++ tests/ci/release-workflow.test.ts | 57 ++++++++++-- 4 files changed, 287 insertions(+), 22 deletions(-) create mode 100644 scripts/ci/collect-release-assets.ts create mode 100644 tests/ci/collect-release-assets.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1ad3a1..6cf951b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,10 +25,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: components: clippy, rustfmt - name: Install Linux desktop dependencies @@ -46,7 +48,7 @@ jobs: libudev-dev \ pkg-config - name: Install cargo-tarpaulin - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@fcf5432d9f50d67e37ee6e29bdb7a224ff67b4a7 # v2 with: tool: cargo-tarpaulin - name: Install dependencies @@ -74,7 +76,7 @@ jobs: needs: - check steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: changepacks/action@ff3d7d0ddbce5dd21c4db0fc5932493b320cf85e # main id: changepacks with: @@ -92,25 +94,33 @@ jobs: - changepacks if: ${{ contains(needs.changepacks.outputs.pending_releases, '"package.json"') }} permissions: - contents: write + contents: read strategy: fail-fast: false matrix: include: - platform: windows-latest + release_platform: windows + arch: x64 args: --bundles nsis msi - platform: macos-latest + release_platform: macos + arch: universal args: --target universal-apple-darwin --bundles dmg targets: aarch64-apple-darwin,x86_64-apple-darwin - platform: ubuntu-22.04 + release_platform: linux + arch: x64 args: --bundles appimage deb runs-on: ${{ matrix.platform }} steps: - name: Checkout code - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: targets: ${{ matrix.targets || '' }} - name: Install Linux desktop dependencies @@ -131,26 +141,67 @@ jobs: pkg-config - name: Install dependencies run: bun install --frozen-lockfile - - name: Build and upload desktop bundles + - name: Build desktop bundles + id: tauri uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 env: APPLE_SIGNING_IDENTITY: ${{ matrix.platform == 'macos-latest' && '-' || '' }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: args: ${{ matrix.args }} + includeUpdaterJson: false projectPath: apps/desktop - releaseAssetNamePattern: hanbeon-[version]-[platform]-[arch]-[bundle][ext] - releaseDraft: true - releaseId: ${{ fromJSON(needs.changepacks.outputs.pending_releases)['package.json'].releaseId }} - tagName: ${{ fromJSON(needs.changepacks.outputs.pending_releases)['package.json'].tagName }} tauriScript: bun tauri - uploadUpdaterJson: false + - name: Normalize release assets + env: + RELEASE_ARCH: ${{ matrix.arch }} + RELEASE_OUTPUT_DIR: ${{ runner.temp }}/release-assets + RELEASE_PLATFORM: ${{ matrix.release_platform }} + RELEASE_VERSION: ${{ steps.tauri.outputs.appVersion }} + TAURI_ARTIFACT_PATHS: ${{ steps.tauri.outputs.artifactPaths }} + run: bun scripts/ci/collect-release-assets.ts + - name: Store release assets + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + if-no-files-found: error + name: desktop-${{ matrix.release_platform }} + path: ${{ runner.temp }}/release-assets/* + retention-days: 1 + + upload-release: + name: upload release assets + needs: + - changepacks + - release-desktop + if: ${{ contains(needs.changepacks.outputs.pending_releases, '"package.json"') }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download release assets + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + merge-multiple: true + path: release-assets + pattern: desktop-* + - name: Upload bundles to the draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ fromJSON(needs.changepacks.outputs.pending_releases)['package.json'].tagName }} + shell: bash + run: | + shopt -s nullglob + assets=(release-assets/*) + if [ "${#assets[@]}" -ne 5 ]; then + echo "Expected 5 desktop release assets, found ${#assets[@]}" >&2 + exit 1 + fi + gh release upload "$RELEASE_TAG" "${assets[@]}" --clobber finalize-release: name: finalize release needs: - changepacks - - release-desktop + - upload-release if: ${{ contains(needs.changepacks.outputs.pending_releases, '"package.json"') }} runs-on: ubuntu-latest permissions: diff --git a/scripts/ci/collect-release-assets.ts b/scripts/ci/collect-release-assets.ts new file mode 100644 index 0000000..5e6dbf1 --- /dev/null +++ b/scripts/ci/collect-release-assets.ts @@ -0,0 +1,114 @@ +import { copyFileSync, mkdirSync, statSync } from 'node:fs' +import { extname, join } from 'node:path' + +type ReleasePlatform = 'linux' | 'macos' | 'windows' + +type CollectReleaseAssetsOptions = { + arch: string + artifactPaths: string[] + outputDir: string + platform: ReleasePlatform + version: string +} + +const bundleByExtension: Record> = { + linux: { + '.appimage': 'appimage', + '.deb': 'deb', + }, + macos: { + '.dmg': 'dmg', + }, + windows: { + '.exe': 'nsis', + '.msi': 'msi', + }, +} + +export function collectReleaseAssets({ + arch, + artifactPaths, + outputDir, + platform, + version, +}: CollectReleaseAssetsOptions): string[] { + if (!/^[0-9A-Za-z][0-9A-Za-z.+-]*$/.test(version)) { + throw new Error(`Invalid release version: ${version}`) + } + if (!/^[0-9A-Za-z][0-9A-Za-z_-]*$/.test(arch)) { + throw new Error(`Invalid release architecture: ${arch}`) + } + + const platformBundles = bundleByExtension[platform] + const expectedBundles = Object.values(platformBundles).sort() + const artifacts = artifactPaths.map((sourcePath) => { + if (!statSync(sourcePath).isFile()) { + throw new Error(`Release artifact is not a file: ${sourcePath}`) + } + + const extension = extname(sourcePath) + const bundle = platformBundles[extension.toLowerCase()] + if (!bundle) { + throw new Error(`Unexpected ${platform} release artifact: ${sourcePath}`) + } + + return { bundle, extension, sourcePath } + }) + + const actualBundles = artifacts.map(({ bundle }) => bundle).sort() + if (JSON.stringify(actualBundles) !== JSON.stringify(expectedBundles)) { + throw new Error( + `Expected ${expectedBundles.join(', ')} bundles for ${platform}, received ${actualBundles.join(', ') || 'none'}`, + ) + } + + mkdirSync(outputDir, { recursive: true }) + + return artifacts + .sort((left, right) => left.bundle.localeCompare(right.bundle)) + .map(({ bundle, extension, sourcePath }) => { + const outputPath = join( + outputDir, + `hanbeon-${version}-${platform}-${arch}-${bundle}${extension}`, + ) + copyFileSync(sourcePath, outputPath) + return outputPath + }) +} + +function requiredEnvironmentVariable(name: string): string { + const value = process.env[name] + if (!value) { + throw new Error(`Missing required environment variable: ${name}`) + } + return value +} + +if (import.meta.main) { + const platform = requiredEnvironmentVariable('RELEASE_PLATFORM') + if (!(platform in bundleByExtension)) { + throw new Error(`Unsupported release platform: ${platform}`) + } + + const artifactPaths = JSON.parse( + requiredEnvironmentVariable('TAURI_ARTIFACT_PATHS'), + ) as unknown + if ( + !Array.isArray(artifactPaths) || + artifactPaths.some((path) => typeof path !== 'string') + ) { + throw new Error('TAURI_ARTIFACT_PATHS must be a JSON array of file paths') + } + + const outputPaths = collectReleaseAssets({ + arch: requiredEnvironmentVariable('RELEASE_ARCH'), + artifactPaths, + outputDir: requiredEnvironmentVariable('RELEASE_OUTPUT_DIR'), + platform: platform as ReleasePlatform, + version: requiredEnvironmentVariable('RELEASE_VERSION'), + }) + + for (const outputPath of outputPaths) { + console.info(outputPath) + } +} diff --git a/tests/ci/collect-release-assets.test.ts b/tests/ci/collect-release-assets.test.ts new file mode 100644 index 0000000..6cc6757 --- /dev/null +++ b/tests/ci/collect-release-assets.test.ts @@ -0,0 +1,53 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterAll, describe, expect, test } from 'bun:test' + +import { collectReleaseAssets } from '../../scripts/ci/collect-release-assets' + +const testRoot = mkdtempSync(join(tmpdir(), 'hanbeon-release-assets-')) + +afterAll(() => { + rmSync(testRoot, { force: true, recursive: true }) +}) + +describe('collectReleaseAssets', () => { + test('copies Windows installers to stable ASCII release names', () => { + const nsis = join(testRoot, '한번_0.1.1_x64-setup.exe') + const msi = join(testRoot, '한번_0.1.1_x64_ko-KR.msi') + const outputDir = join(testRoot, 'windows-output') + writeFileSync(nsis, 'nsis') + writeFileSync(msi, 'msi') + + const outputs = collectReleaseAssets({ + arch: 'x64', + artifactPaths: [nsis, msi], + outputDir, + platform: 'windows', + version: '0.1.1', + }) + + expect(outputs.map((path) => path.replaceAll('\\', '/'))).toEqual([ + `${outputDir.replaceAll('\\', '/')}/hanbeon-0.1.1-windows-x64-msi.msi`, + `${outputDir.replaceAll('\\', '/')}/hanbeon-0.1.1-windows-x64-nsis.exe`, + ]) + expect(readFileSync(outputs[0], 'utf8')).toBe('msi') + expect(readFileSync(outputs[1], 'utf8')).toBe('nsis') + }) + + test('rejects an incomplete bundle set', () => { + const appImage = join(testRoot, 'hanbeon.AppImage') + writeFileSync(appImage, 'appimage') + + expect(() => + collectReleaseAssets({ + arch: 'x64', + artifactPaths: [appImage], + outputDir: join(testRoot, 'linux-output'), + platform: 'linux', + version: '0.1.1', + }), + ).toThrow('Expected appimage, deb bundles') + }) +}) diff --git a/tests/ci/release-workflow.test.ts b/tests/ci/release-workflow.test.ts index 04a1e09..5322038 100644 --- a/tests/ci/release-workflow.test.ts +++ b/tests/ci/release-workflow.test.ts @@ -4,7 +4,10 @@ import { resolve } from 'node:path' import { describe, expect, test } from 'bun:test' type Step = { + env?: Record id?: string + name?: string + run?: string uses?: string with?: Record } @@ -12,6 +15,7 @@ type Step = { type Job = { if?: string needs?: string | string[] + permissions?: Record strategy?: { matrix?: { include?: Array> @@ -72,37 +76,80 @@ describe('desktop release workflow', () => { expect(matrix).toEqual([ { args: '--bundles nsis msi', + arch: 'x64', platform: 'windows-latest', + release_platform: 'windows', }, { args: '--target universal-apple-darwin --bundles dmg', + arch: 'universal', platform: 'macos-latest', + release_platform: 'macos', targets: 'aarch64-apple-darwin,x86_64-apple-darwin', }, { args: '--bundles appimage deb', + arch: 'x64', platform: 'ubuntu-22.04', + release_platform: 'linux', }, ]) expect(JSON.stringify(matrix)).not.toContain('android') }) - test('uploads into the existing draft and finalizes after all builds', () => { + test('builds without a write token and hands off normalized artifacts', () => { const releaseJob = workflow.jobs['release-desktop'] + const checkoutStep = releaseJob.steps?.find((step) => + step.uses?.startsWith('actions/checkout@'), + ) const tauriStep = releaseJob.steps?.find((step) => step.uses?.startsWith('tauri-apps/tauri-action@'), ) + const uploadArtifactStep = releaseJob.steps?.find((step) => + step.uses?.startsWith('actions/upload-artifact@'), + ) + + expect(releaseJob.permissions).toEqual({ contents: 'read' }) + expect(checkoutStep?.with?.['persist-credentials']).toBe(false) + expect(tauriStep?.uses).toMatch(/^tauri-apps\/tauri-action@[0-9a-f]{40}$/) + expect(tauriStep?.env?.GITHUB_TOKEN).toBeUndefined() + expect(tauriStep?.with?.releaseId).toBeUndefined() + expect(tauriStep?.with?.tagName).toBeUndefined() + expect(tauriStep?.with?.includeUpdaterJson).toBe(false) + expect(tauriStep?.with?.releaseAssetNamePattern).toBeUndefined() + expect(tauriStep?.with?.uploadUpdaterJson).toBeUndefined() + expect(uploadArtifactStep?.with?.['if-no-files-found']).toBe('error') + }) + + test('uploads into the existing draft and finalizes only afterward', () => { + const uploadJob = workflow.jobs['upload-release'] + const downloadArtifactStep = uploadJob.steps?.find((step) => + step.uses?.startsWith('actions/download-artifact@'), + ) const finalizeJob = workflow.jobs['finalize-release'] const finalizeStep = finalizeJob.steps?.find((step) => step.uses?.startsWith('changepacks/action@'), ) - expect(tauriStep?.uses).toMatch(/^tauri-apps\/tauri-action@[0-9a-f]{40}$/) - expect(tauriStep?.with?.releaseId).toContain('pending_releases') - expect(tauriStep?.with?.releaseDraft).toBe(true) - expect(finalizeJob.needs).toEqual(['changepacks', 'release-desktop']) + expect(uploadJob.needs).toEqual(['changepacks', 'release-desktop']) + expect(uploadJob.permissions).toEqual({ contents: 'write' }) + expect(downloadArtifactStep?.with?.pattern).toBe('desktop-*') + expect( + uploadJob.steps?.some((step) => step.run?.includes('gh release upload')), + ).toBe(true) + expect(finalizeJob.needs).toEqual(['changepacks', 'upload-release']) expect(finalizeStep?.with?.finalize_releases).toBe( '${{ needs.changepacks.outputs.pending_releases }}', ) }) + + test('pins every reusable action to an immutable commit', () => { + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps ?? []) { + if (step.uses && !step.uses.startsWith('./')) { + expect(step.uses).toMatch(/^[^@]+@[0-9a-f]{40}$/) + } + } + } + }) }) From 711161af1086b03a539e37e6de095e0253057a61 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 28 Aug 2026 02:13:42 +0900 Subject: [PATCH 5/5] fix(ci): ignore intermediate macOS app bundle --- scripts/ci/collect-release-assets.ts | 18 ++++++++++++---- tests/ci/collect-release-assets.test.ts | 28 ++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/scripts/ci/collect-release-assets.ts b/scripts/ci/collect-release-assets.ts index 5e6dbf1..8f59ed2 100644 --- a/scripts/ci/collect-release-assets.ts +++ b/scripts/ci/collect-release-assets.ts @@ -41,18 +41,28 @@ export function collectReleaseAssets({ const platformBundles = bundleByExtension[platform] const expectedBundles = Object.values(platformBundles).sort() - const artifacts = artifactPaths.map((sourcePath) => { - if (!statSync(sourcePath).isFile()) { + const artifacts = artifactPaths.flatMap((sourcePath) => { + const extension = extname(sourcePath) + const sourceStat = statSync(sourcePath) + + if ( + platform === 'macos' && + extension.toLowerCase() === '.app' && + sourceStat.isDirectory() + ) { + return [] + } + + if (!sourceStat.isFile()) { throw new Error(`Release artifact is not a file: ${sourcePath}`) } - const extension = extname(sourcePath) const bundle = platformBundles[extension.toLowerCase()] if (!bundle) { throw new Error(`Unexpected ${platform} release artifact: ${sourcePath}`) } - return { bundle, extension, sourcePath } + return [{ bundle, extension, sourcePath }] }) const actualBundles = artifacts.map(({ bundle }) => bundle).sort() diff --git a/tests/ci/collect-release-assets.test.ts b/tests/ci/collect-release-assets.test.ts index 6cc6757..c5f2d3a 100644 --- a/tests/ci/collect-release-assets.test.ts +++ b/tests/ci/collect-release-assets.test.ts @@ -1,4 +1,10 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -50,4 +56,24 @@ describe('collectReleaseAssets', () => { }), ).toThrow('Expected appimage, deb bundles') }) + + test('ignores the intermediate macOS app directory emitted with a DMG', () => { + const app = join(testRoot, '한번.app') + const dmg = join(testRoot, '한번_0.1.1_universal.dmg') + const outputDir = join(testRoot, 'macos-output') + mkdirSync(app) + writeFileSync(dmg, 'dmg') + + const outputs = collectReleaseAssets({ + arch: 'universal', + artifactPaths: [dmg, app], + outputDir, + platform: 'macos', + version: '0.1.1', + }) + + expect(outputs.map((path) => path.replaceAll('\\', '/'))).toEqual([ + `${outputDir.replaceAll('\\', '/')}/hanbeon-0.1.1-macos-universal-dmg.dmg`, + ]) + }) })