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 1f4d8c9..6cf951b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,15 +21,16 @@ jobs: # 공개 저장소이므로 모든 작업은 GitHub 호스팅 러너에서 돈다. # self-hosted 러너에서 PR 코드를 실행하면 (postinstall, 테스트, 빌드) # 누구든 PR 하나로 우리 인프라에서 임의 코드를 돌릴 수 있다. - # 배포는 이 워크플로에 두지 않는다. check: 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 @@ -47,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 @@ -75,8 +76,138 @@ jobs: needs: - check steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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: 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - name: Install Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # 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 desktop bundles + id: tauri + uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 + env: + APPLE_SIGNING_IDENTITY: ${{ matrix.platform == 'macos-latest' && '-' || '' }} + with: + args: ${{ matrix.args }} + includeUpdaterJson: false + projectPath: apps/desktop + tauriScript: bun tauri + - 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 + - upload-release + 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/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..950428f Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 1c3e445..cdc66cd 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", @@ -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/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/scripts/ci/collect-release-assets.ts b/scripts/ci/collect-release-assets.ts new file mode 100644 index 0000000..8f59ed2 --- /dev/null +++ b/scripts/ci/collect-release-assets.ts @@ -0,0 +1,124 @@ +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.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 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..c5f2d3a --- /dev/null +++ b/tests/ci/collect-release-assets.test.ts @@ -0,0 +1,79 @@ +import { + mkdirSync, + 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') + }) + + 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`, + ]) + }) +}) diff --git a/tests/ci/release-workflow.test.ts b/tests/ci/release-workflow.test.ts new file mode 100644 index 0000000..5322038 --- /dev/null +++ b/tests/ci/release-workflow.test.ts @@ -0,0 +1,155 @@ +import { readFileSync } from 'node:fs' +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 +} + +type Job = { + if?: string + needs?: string | string[] + permissions?: Record + 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 { + bundle: { icon: string[]; windows: { wix: { language: string } } } + 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', () => { + 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', () => { + 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 }}', + ) + }) + + 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', + 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('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(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}$/) + } + } + } + }) +})