chore(release): prepare 0.15.11-1 release #72
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| push: | |
| tags: | |
| - "v*.*.*" | |
| env: | |
| RUST_BACKTRACE: short | |
| HUSKY: 0 | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| create-draft-release: | |
| runs-on: ubuntu-22.04 | |
| permissions: | |
| contents: write | |
| outputs: | |
| release_id: ${{ steps.release.outputs.release_id }} | |
| release_url: ${{ steps.release.outputs.release_url }} | |
| prerelease: ${{ steps.meta.outputs.prerelease }} | |
| release_body: ${{ steps.release.outputs.release_body }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - id: meta | |
| name: Resolve release channel | |
| shell: bash | |
| run: | | |
| if [[ "${GITHUB_REF_NAME}" == *"-rc"* ]]; then | |
| echo "prerelease=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "prerelease=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Check tag commit belongs to release branch | |
| shell: bash | |
| run: | | |
| RELEASE_BRANCH="release" | |
| RELEASE_REF="origin/${RELEASE_BRANCH}" | |
| git fetch origin "+refs/heads/${RELEASE_BRANCH}:refs/remotes/${RELEASE_REF}" | |
| TAG_COMMIT=$(git rev-list -n 1 "${GITHUB_REF_NAME}") | |
| if git merge-base --is-ancestor "${TAG_COMMIT}" "${RELEASE_REF}"; then | |
| echo "Tag ${GITHUB_REF_NAME} is contained in ${RELEASE_REF}" | |
| else | |
| echo "Tag ${GITHUB_REF_NAME} is not contained in ${RELEASE_REF}" | |
| exit 1 | |
| fi | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| - name: Verify tag matches app version | |
| shell: bash | |
| run: | | |
| APP_VERSION=$(node -p "require('./src-tauri/tauri.conf.json').version") | |
| if [[ "v$APP_VERSION" != "${GITHUB_REF_NAME}" ]]; then | |
| echo "Tag ${GITHUB_REF_NAME} does not match app version v$APP_VERSION" | |
| exit 1 | |
| fi | |
| - id: release | |
| name: Create or reuse draft release | |
| uses: actions/github-script@v7 | |
| env: | |
| PRERELEASE: ${{ steps.meta.outputs.prerelease }} | |
| with: | |
| script: | | |
| const tag = context.ref.replace("refs/tags/", ""); | |
| const { owner, repo } = context.repo; | |
| const prerelease = process.env.PRERELEASE === "true"; | |
| const releaseName = `codeg ${tag}`; | |
| const { data: tagRef } = await github.rest.git.getRef({ | |
| owner, | |
| repo, | |
| ref: `tags/${tag}`, | |
| }); | |
| let commitSha = tagRef.object.sha; | |
| if (tagRef.object.type === "tag") { | |
| const { data: annotatedTag } = await github.rest.git.getTag({ | |
| owner, | |
| repo, | |
| tag_sha: commitSha, | |
| }); | |
| if (annotatedTag.object.type !== "commit") { | |
| core.setFailed( | |
| `Tag ${tag} points to ${annotatedTag.object.type}, not a commit.`, | |
| ); | |
| return; | |
| } | |
| commitSha = annotatedTag.object.sha; | |
| } | |
| const { execFileSync } = require("node:child_process"); | |
| const { existsSync, readFileSync } = require("node:fs"); | |
| const semverTagPattern = | |
| /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; | |
| const runGit = (args, { allowFailure = false } = {}) => { | |
| try { | |
| return execFileSync("git", args, { | |
| encoding: "utf8", | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }).trim(); | |
| } catch (error) { | |
| if (allowFailure) { | |
| return ""; | |
| } | |
| throw error; | |
| } | |
| }; | |
| const resolveTagCommit = async (tagName) => { | |
| const localSha = runGit( | |
| ["rev-list", "-n", "1", `refs/tags/${tagName}`], | |
| { allowFailure: true }, | |
| ); | |
| if (localSha) { | |
| return localSha; | |
| } | |
| try { | |
| const { data: ref } = await github.rest.git.getRef({ | |
| owner, | |
| repo, | |
| ref: `tags/${tagName}`, | |
| }); | |
| if (ref.object.type === "tag") { | |
| const { data: annotated } = await github.rest.git.getTag({ | |
| owner, | |
| repo, | |
| tag_sha: ref.object.sha, | |
| }); | |
| return annotated.object.type === "commit" | |
| ? annotated.object.sha | |
| : ""; | |
| } | |
| return ref.object.type === "commit" ? ref.object.sha : ""; | |
| } catch (error) { | |
| core.debug( | |
| `Unable to resolve tag ${tagName}: ${error.message}`, | |
| ); | |
| return ""; | |
| } | |
| }; | |
| const isReachableBeforeCurrent = (candidateSha) => { | |
| if (!candidateSha || candidateSha === commitSha) { | |
| return false; | |
| } | |
| try { | |
| execFileSync( | |
| "git", | |
| ["merge-base", "--is-ancestor", candidateSha, commitSha], | |
| { stdio: "ignore" }, | |
| ); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| }; | |
| const findPreviousReleaseBase = async () => { | |
| const releases = await github.paginate( | |
| github.rest.repos.listReleases, | |
| { | |
| owner, | |
| repo, | |
| per_page: 100, | |
| }, | |
| ); | |
| for (const candidate of releases) { | |
| if ( | |
| candidate.draft || | |
| candidate.tag_name === tag || | |
| !semverTagPattern.test(candidate.tag_name) | |
| ) { | |
| continue; | |
| } | |
| const sha = await resolveTagCommit(candidate.tag_name); | |
| if (isReachableBeforeCurrent(sha)) { | |
| return { | |
| source: "GitHub release", | |
| tag: candidate.tag_name, | |
| sha, | |
| }; | |
| } | |
| } | |
| return null; | |
| }; | |
| const findPreviousSemverTagBase = async () => { | |
| const tags = runGit( | |
| ["tag", "--list", "v*.*.*", "--sort=-version:refname"], | |
| { allowFailure: true }, | |
| ) | |
| .split("\n") | |
| .map((candidate) => candidate.trim()) | |
| .filter( | |
| (candidate) => | |
| candidate && | |
| candidate !== tag && | |
| semverTagPattern.test(candidate), | |
| ); | |
| for (const candidate of tags) { | |
| const sha = await resolveTagCommit(candidate); | |
| if (isReachableBeforeCurrent(sha)) { | |
| return { | |
| source: "git tag", | |
| tag: candidate, | |
| sha, | |
| }; | |
| } | |
| } | |
| return null; | |
| }; | |
| const releaseBase = | |
| (await findPreviousReleaseBase()) || | |
| (await findPreviousSemverTagBase()); | |
| if (releaseBase) { | |
| core.info( | |
| `Generating release notes from ${releaseBase.source} ${releaseBase.tag} to ${tag}`, | |
| ); | |
| } else { | |
| core.info( | |
| `No previous release or semver tag found; using commits reachable from ${tag}`, | |
| ); | |
| } | |
| const range = releaseBase?.sha | |
| ? `${releaseBase.sha}..${commitSha}` | |
| : commitSha; | |
| const commitLog = runGit( | |
| ["log", "--reverse", "--format=%H%x00%s", range], | |
| { allowFailure: true }, | |
| ); | |
| const commits = commitLog | |
| ? commitLog.split("\n").flatMap((line) => { | |
| const separatorIndex = line.indexOf("\0"); | |
| if (separatorIndex < 0) { | |
| return []; | |
| } | |
| return [ | |
| { | |
| sha: line.slice(0, separatorIndex), | |
| subject: line.slice(separatorIndex + 1).trim(), | |
| }, | |
| ]; | |
| }) | |
| : []; | |
| const featurePattern = /^feat(?:\([^)]+\))?!?:\s+/i; | |
| const fixPattern = /^fix(?:\([^)]+\))?!?:\s+/i; | |
| const escapeMarkdown = (value) => | |
| value.replace(/\\/g, "\\\\").replace(/`/g, "\\`"); | |
| const formatCommitLine = (commit) => | |
| `- ${escapeMarkdown(commit.subject)} (${commit.sha.slice(0, 7)})`; | |
| const features = []; | |
| const bugFixes = []; | |
| for (const commit of commits) { | |
| if (featurePattern.test(commit.subject)) { | |
| features.push(formatCommitLine(commit)); | |
| } else if (fixPattern.test(commit.subject)) { | |
| bugFixes.push(formatCommitLine(commit)); | |
| } | |
| } | |
| const sections = []; | |
| if (features.length > 0) { | |
| sections.push(["## Features", ...features].join("\n")); | |
| } | |
| if (bugFixes.length > 0) { | |
| sections.push(["## Bug Fixes", ...bugFixes].join("\n")); | |
| } | |
| const releaseBody = sections.length | |
| ? [ | |
| releaseBase | |
| ? `_Changes since ${releaseBase.tag}._` | |
| : `_Changes included in ${tag}._`, | |
| ...sections, | |
| ].join("\n\n") | |
| : releaseBase | |
| ? `No categorized feature or bug fix commits were found since ${releaseBase.tag}.` | |
| : `No categorized feature or bug fix commits were found for ${tag}.`; | |
| const chineseNotesPath = `.github/release-notes/${tag}.zh.md`; | |
| if (!existsSync(chineseNotesPath)) { | |
| core.setFailed( | |
| `Missing Simplified Chinese release notes file: ${chineseNotesPath}`, | |
| ); | |
| return; | |
| } | |
| const chineseNotes = readFileSync(chineseNotesPath, "utf8").trim(); | |
| if (!chineseNotes) { | |
| core.setFailed( | |
| `Simplified Chinese release notes file is empty: ${chineseNotesPath}`, | |
| ); | |
| return; | |
| } | |
| const finalReleaseBody = `${releaseBody}\n\n## 中文说明\n\n${chineseNotes}`; | |
| let release; | |
| try { | |
| const existing = await github.rest.repos.getReleaseByTag({ | |
| owner, | |
| repo, | |
| tag, | |
| }); | |
| if (!existing.data.draft) { | |
| core.setFailed( | |
| `Release for tag ${tag} already exists and is not a draft.`, | |
| ); | |
| return; | |
| } | |
| release = existing.data; | |
| if ( | |
| release.prerelease !== prerelease || | |
| release.name !== releaseName || | |
| (release.body ?? "").trim() !== finalReleaseBody | |
| ) { | |
| const updated = await github.rest.repos.updateRelease({ | |
| owner, | |
| repo, | |
| release_id: release.id, | |
| name: releaseName, | |
| prerelease, | |
| body: finalReleaseBody, | |
| }); | |
| release = updated.data; | |
| } | |
| core.info(`Reusing existing draft release #${release.id}`); | |
| } catch (error) { | |
| if (error.status !== 404) { | |
| throw error; | |
| } | |
| const created = await github.rest.repos.createRelease({ | |
| owner, | |
| repo, | |
| tag_name: tag, | |
| name: releaseName, | |
| body: finalReleaseBody, | |
| draft: true, | |
| prerelease, | |
| }); | |
| release = created.data; | |
| core.info(`Created draft release #${release.id}`); | |
| } | |
| core.setOutput("release_id", String(release.id)); | |
| core.setOutput("release_url", release.html_url); | |
| core.setOutput("release_body", finalReleaseBody); | |
| build-tauri: | |
| needs: create-draft-release | |
| name: Build ${{ matrix.name }} | |
| permissions: | |
| contents: write | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # - name: "macOS x64" | |
| # runner: "macos-latest" | |
| # target: "x86_64-apple-darwin" | |
| - name: "macOS arm64" | |
| runner: "macos-latest" | |
| target: "aarch64-apple-darwin" | |
| # - name: "Linux x64" | |
| # runner: "ubuntu-22.04" | |
| # target: "x86_64-unknown-linux-gnu" | |
| # - name: "Linux arm64" | |
| # runner: "ubuntu-22.04" | |
| # target: "aarch64-unknown-linux-gnu" | |
| # - name: "Windows x64" | |
| # runner: "windows-2022" | |
| # target: "x86_64-pc-windows-msvc" | |
| # - name: "Windows arm64" | |
| # runner: "windows-latest" | |
| # target: "aarch64-pc-windows-msvc" | |
| runs-on: ${{ matrix.runner }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v4 | |
| with: | |
| version: 10 | |
| run_install: false | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| cache: "pnpm" | |
| - name: Install Rust stable | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.target }} | |
| - name: Rust cache | |
| uses: swatinem/rust-cache@v2 | |
| with: | |
| workspaces: "./src-tauri -> target" | |
| shared-key: ${{ matrix.target }} | |
| - name: Install Linux x64 dependencies | |
| if: matrix.target == 'x86_64-unknown-linux-gnu' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| libwebkit2gtk-4.1-dev \ | |
| libayatana-appindicator3-dev \ | |
| librsvg2-dev \ | |
| patchelf | |
| - name: Install Linux arm64 cross dependencies | |
| if: matrix.target == 'aarch64-unknown-linux-gnu' | |
| run: | | |
| cat > /tmp/sources.list << 'EOF' | |
| deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy main restricted universe multiverse | |
| deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-updates main restricted universe multiverse | |
| deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-backports main restricted universe multiverse | |
| deb [arch=amd64] http://security.ubuntu.com/ubuntu jammy-security main restricted universe multiverse | |
| deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted universe multiverse | |
| deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted universe multiverse | |
| deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse | |
| deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted universe multiverse | |
| EOF | |
| sudo mv /etc/apt/sources.list /etc/apt/sources.list.default | |
| sudo mv /tmp/sources.list /etc/apt/sources.list | |
| sudo dpkg --add-architecture arm64 | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| gcc-aarch64-linux-gnu \ | |
| g++-aarch64-linux-gnu \ | |
| libwebkit2gtk-4.1-dev:arm64 \ | |
| libayatana-appindicator3-dev:arm64 \ | |
| librsvg2-dev:arm64 \ | |
| libssl-dev:arm64 \ | |
| patchelf | |
| - name: Configure Linux arm64 cross env | |
| if: matrix.target == 'aarch64-unknown-linux-gnu' | |
| shell: bash | |
| run: | | |
| echo "PKG_CONFIG_ALLOW_CROSS=1" >> "$GITHUB_ENV" | |
| echo "PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig" >> "$GITHUB_ENV" | |
| echo "PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig" >> "$GITHUB_ENV" | |
| echo "PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu" >> "$GITHUB_ENV" | |
| echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" | |
| echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_AR=aarch64-linux-gnu-gcc-ar" >> "$GITHUB_ENV" | |
| echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=-Clinker=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" | |
| echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" | |
| echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++" >> "$GITHUB_ENV" | |
| echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc-ar" >> "$GITHUB_ENV" | |
| - name: Verify Linux arm64 cross toolchain | |
| if: matrix.target == 'aarch64-unknown-linux-gnu' | |
| shell: bash | |
| run: | | |
| which aarch64-linux-gnu-gcc | |
| aarch64-linux-gnu-gcc -v | |
| rustup target list --installed | |
| echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=${CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER}" | |
| echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_AR=${CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_AR}" | |
| echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=${CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS}" | |
| - name: Install frontend dependencies | |
| run: pnpm install --frozen-lockfile | |
| # Build the `codeg-mcp` companion binary for the matrix target and | |
| # stage it as a Tauri sidecar at | |
| # `src-tauri/binaries/codeg-mcp-<triple>{.exe}`. `tauri-action` (below) | |
| # then bundles it via the `bundle.externalBin` entry in | |
| # `tauri.conf.json` — Tauri installs it next to the main executable | |
| # (Contents/MacOS on macOS, install root on Linux/Windows) where the | |
| # runtime `locate_codeg_mcp_binary()` finds it via `current_exe` | |
| # sibling lookup. The Linux arm64 cross env vars set above also | |
| # apply to this cargo invocation. | |
| - name: Stage codeg-mcp sidecar for Tauri bundle | |
| shell: bash | |
| run: pnpm tauri:prepare-sidecars --target ${{ matrix.target }} | |
| - name: Verify codeg-mcp sidecar landed | |
| shell: bash | |
| run: | | |
| ext="" | |
| case "${{ matrix.target }}" in | |
| *windows*) ext=".exe" ;; | |
| esac | |
| file="src-tauri/binaries/codeg-mcp-${{ matrix.target }}${ext}" | |
| if [ ! -f "$file" ]; then | |
| echo "FATAL: sidecar $file missing after prepare-sidecars" | |
| exit 1 | |
| fi | |
| ls -la "$file" | |
| - name: Build and upload to draft release (Linux arm64) | |
| if: matrix.target == 'aarch64-unknown-linux-gnu' | |
| uses: tauri-apps/tauri-action@v0.6.1 | |
| env: | |
| # The matrix-target sidecar is already staged and verified above. | |
| # Skip the duplicate beforeBuildCommand sidecar pass so cross builds | |
| # do not fall back to the runner host triple. | |
| CODEG_SKIP_SIDECAR: "1" | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| PKG_CONFIG_ALLOW_CROSS: 1 | |
| PKG_CONFIG_PATH: /usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig | |
| PKG_CONFIG_LIBDIR: /usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig | |
| PKG_CONFIG_SYSROOT_DIR: /usr/aarch64-linux-gnu | |
| CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc | |
| CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_AR: aarch64-linux-gnu-gcc-ar | |
| CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -Clinker=aarch64-linux-gnu-gcc | |
| CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc | |
| CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++ | |
| AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc-ar | |
| with: | |
| releaseId: ${{ needs.create-draft-release.outputs.release_id }} | |
| tagName: ${{ github.ref_name }} | |
| releaseBody: ${{ needs.create-draft-release.outputs.release_body }} | |
| releaseDraft: true | |
| prerelease: ${{ needs.create-draft-release.outputs.prerelease }} | |
| tauriScript: pnpm tauri | |
| args: --target ${{ matrix.target }} --bundles deb,rpm | |
| includeUpdaterJson: false | |
| retryAttempts: 2 | |
| - name: Build and upload to draft release (Windows) | |
| if: contains(matrix.target, 'windows') | |
| uses: tauri-apps/tauri-action@v0.6.1 | |
| env: | |
| # The matrix-target sidecar is already staged and verified above. | |
| # Skip the duplicate beforeBuildCommand sidecar pass so cross builds | |
| # do not fall back to the runner host triple. | |
| CODEG_SKIP_SIDECAR: "1" | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| with: | |
| releaseId: ${{ needs.create-draft-release.outputs.release_id }} | |
| tagName: ${{ github.ref_name }} | |
| releaseBody: ${{ needs.create-draft-release.outputs.release_body }} | |
| releaseDraft: true | |
| prerelease: ${{ needs.create-draft-release.outputs.prerelease }} | |
| tauriScript: pnpm tauri | |
| # Skip MSI bundling on Windows: tauri-apps/tauri#14681 — light.exe | |
| # fails when externalBin is configured. NSIS installer + updater | |
| # artifacts cover the same distribution channel. | |
| args: --target ${{ matrix.target }} --bundles nsis,updater | |
| includeUpdaterJson: true | |
| retryAttempts: 2 | |
| - name: Build and upload to draft release (Non-Linux arm64) | |
| if: matrix.target != 'aarch64-unknown-linux-gnu' && !contains(matrix.target, 'windows') | |
| uses: tauri-apps/tauri-action@v0.6.1 | |
| env: | |
| # The matrix-target sidecar is already staged and verified above. | |
| # Skip the duplicate beforeBuildCommand sidecar pass so cross builds | |
| # do not fall back to the runner host triple. | |
| CODEG_SKIP_SIDECAR: "1" | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| with: | |
| releaseId: ${{ needs.create-draft-release.outputs.release_id }} | |
| tagName: ${{ github.ref_name }} | |
| releaseBody: ${{ needs.create-draft-release.outputs.release_body }} | |
| releaseDraft: true | |
| prerelease: ${{ needs.create-draft-release.outputs.prerelease }} | |
| tauriScript: pnpm tauri | |
| args: --target ${{ matrix.target }} | |
| includeUpdaterJson: true | |
| retryAttempts: 2 | |
| # Verify the freshly-signed updater archives against the public key | |
| # committed in `src-tauri/tauri.conf.json`. This guards against the | |
| # 0.13.4-x failure mode where the GitHub `TAURI_SIGNING_PRIVATE_KEY` | |
| # secret was rotated to a key whose KeyId no longer matched the | |
| # `plugins.updater.pubkey` shipped to clients — in that mode every | |
| # signed asset uploads cleanly but no installed client can verify the | |
| # signature, so in-app updates silently fail (Error::SignatureFailed). | |
| # | |
| # Scoped to macOS because that is the only platform in the active | |
| # matrix that publishes updater-signed bundles (Linux entry runs with | |
| # `includeUpdaterJson: false`). The sanity check runs *after* | |
| # tauri-action has uploaded to the draft release, but failing this | |
| # step fails the matrix job, which blocks `publish-release` from | |
| # flipping the draft flag (gated on `build-tauri.result == 'success'`). | |
| - name: Verify updater signatures match committed pubkey | |
| if: matrix.target != 'aarch64-unknown-linux-gnu' && runner.os == 'macOS' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| brew install minisign | |
| # `plugins.updater.pubkey` in tauri.conf.json is itself a base64 | |
| # encoding of the two-line minisign public key file (untrusted | |
| # comment + base64 key body). One decode round-trips us back to | |
| # the file format minisign -p expects. | |
| PUBKEY_B64=$(python3 -c ' | |
| import json, sys | |
| with open("src-tauri/tauri.conf.json", "r", encoding="utf-8") as fh: | |
| cfg = json.load(fh) | |
| pubkey = cfg["plugins"]["updater"]["pubkey"] | |
| if not pubkey: | |
| sys.exit("plugins.updater.pubkey is empty") | |
| sys.stdout.write(pubkey) | |
| ') | |
| printf '%s' "$PUBKEY_B64" | base64 --decode > pubkey.pub | |
| BUNDLE_ROOT="src-tauri/target/${{ matrix.target }}/release/bundle" | |
| if [[ ! -d "$BUNDLE_ROOT" ]]; then | |
| echo "Expected bundle directory not found: $BUNDLE_ROOT" >&2 | |
| exit 1 | |
| fi | |
| # Collect every signed updater archive tauri-action produced for | |
| # this target. macOS bundles `.app.tar.gz`; the `.sig` sibling is | |
| # what `tauri-plugin-updater` verifies on the client. | |
| # NUL-delimited read keeps us safe against spaces in bundle names | |
| # and stays portable across bash 3.2 (system /bin/bash) and the | |
| # newer homebrew bash that GitHub runners typically resolve to. | |
| declare -a SIG_FILES=() | |
| while IFS= read -r -d '' f; do | |
| SIG_FILES+=("$f") | |
| done < <(find "$BUNDLE_ROOT" -type f -name "*.sig" -print0) | |
| if [[ ${#SIG_FILES[@]} -eq 0 ]]; then | |
| echo "No updater signature files found under $BUNDLE_ROOT" >&2 | |
| exit 1 | |
| fi | |
| FAILED=0 | |
| VERIFIED=0 | |
| for sig in "${SIG_FILES[@]}"; do | |
| archive="${sig%.sig}" | |
| if [[ ! -f "$archive" ]]; then | |
| echo "Signature has no matching archive: $sig" >&2 | |
| FAILED=1 | |
| continue | |
| fi | |
| # tauri-bundler emits `.sig` as base64-wrapped minisign text: | |
| # the file body is a single base64 blob that decodes to the | |
| # canonical 4-line minisign signature (untrusted comment + | |
| # signature + trusted comment + global signature). Feeding the | |
| # wrapped form straight into `minisign -V` fails with | |
| # "Untrusted signature comment too long" because minisign reads | |
| # the entire base64 string as the comment line. Decode once | |
| # before verification so future readers do not re-trip this. | |
| if ! base64 --decode < "$sig" > "${sig}.minisig"; then | |
| echo "::error file=$sig::Failed to base64-decode tauri-bundler signature file $sig before minisign verification." | |
| FAILED=1 | |
| continue | |
| fi | |
| echo "Verifying $archive against committed pubkey" | |
| if ! minisign -Vm "$archive" -p pubkey.pub -x "${sig}.minisig"; then | |
| echo "::error file=src-tauri/tauri.conf.json::Updater signature verification failed for $archive. The signing key in CI no longer matches plugins.updater.pubkey shipped to clients." | |
| FAILED=1 | |
| else | |
| VERIFIED=$((VERIFIED + 1)) | |
| fi | |
| rm -f "${sig}.minisig" | |
| done | |
| rm -f pubkey.pub | |
| if [[ $FAILED -ne 0 ]]; then | |
| exit 1 | |
| fi | |
| echo "All ${VERIFIED} updater signatures verified against committed pubkey (KeyId from tauri.conf.json)" | |
| build-server: | |
| needs: create-draft-release | |
| name: Server ${{ matrix.name }} | |
| permissions: | |
| contents: write | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - name: "Linux x64" | |
| runner: "ubuntu-22.04" | |
| target: "x86_64-unknown-linux-gnu" | |
| artifact: "codeg-server-linux-x64" | |
| runs-on: ${{ matrix.runner }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install Rust stable | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.target }} | |
| - name: Rust cache | |
| uses: swatinem/rust-cache@v2 | |
| with: | |
| workspaces: "./src-tauri -> target" | |
| shared-key: server-${{ matrix.target }} | |
| - name: Install Linux arm64 cross compiler | |
| if: matrix.target == 'aarch64-unknown-linux-gnu' | |
| run: | | |
| cat > /tmp/sources.list << 'EOF' | |
| deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy main restricted universe multiverse | |
| deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-updates main restricted universe multiverse | |
| deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-backports main restricted universe multiverse | |
| deb [arch=amd64] http://security.ubuntu.com/ubuntu jammy-security main restricted universe multiverse | |
| deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted universe multiverse | |
| deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted universe multiverse | |
| deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse | |
| deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted universe multiverse | |
| EOF | |
| sudo mv /etc/apt/sources.list /etc/apt/sources.list.default | |
| sudo mv /tmp/sources.list /etc/apt/sources.list | |
| sudo dpkg --add-architecture arm64 | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| gcc-aarch64-linux-gnu \ | |
| g++-aarch64-linux-gnu \ | |
| libssl-dev:arm64 | |
| echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" | |
| echo "PKG_CONFIG_ALLOW_CROSS=1" >> "$GITHUB_ENV" | |
| echo "PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig" >> "$GITHUB_ENV" | |
| echo "PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig" >> "$GITHUB_ENV" | |
| echo "PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu" >> "$GITHUB_ENV" | |
| echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" | |
| echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++" >> "$GITHUB_ENV" | |
| echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc-ar" >> "$GITHUB_ENV" | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v4 | |
| with: | |
| version: 10 | |
| run_install: false | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| cache: "pnpm" | |
| - name: Build frontend | |
| run: | | |
| pnpm install --frozen-lockfile | |
| pnpm build | |
| - name: Build server + codeg-mcp companion | |
| working-directory: src-tauri | |
| # `codeg-mcp` is the stdio MCP companion the runtime injects per | |
| # session (see acp/delegation/companion.rs). Built with the same | |
| # `--no-default-features --target` flags as the server so it shares | |
| # the cross-compile env (Linux arm64) without dragging in tauri | |
| # runtime deps. | |
| run: | | |
| cargo build --release --bin codeg-server --no-default-features --target ${{ matrix.target }} | |
| cargo build --release --bin codeg-mcp --no-default-features --target ${{ matrix.target }} | |
| - name: Package (Unix) | |
| if: runner.os != 'Windows' | |
| run: | | |
| mkdir -p dist/${{ matrix.artifact }} | |
| cp src-tauri/target/${{ matrix.target }}/release/codeg-server dist/${{ matrix.artifact }}/ | |
| cp src-tauri/target/${{ matrix.target }}/release/codeg-mcp dist/${{ matrix.artifact }}/ | |
| chmod +x dist/${{ matrix.artifact }}/codeg-server dist/${{ matrix.artifact }}/codeg-mcp | |
| cp -r out dist/${{ matrix.artifact }}/web | |
| cd dist && tar czf ${{ matrix.artifact }}.tar.gz ${{ matrix.artifact }} | |
| - name: Package (Windows) | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| New-Item -ItemType Directory -Force -Path dist/${{ matrix.artifact }} | |
| Copy-Item src-tauri/target/${{ matrix.target }}/release/codeg-server.exe dist/${{ matrix.artifact }}/ | |
| Copy-Item src-tauri/target/${{ matrix.target }}/release/codeg-mcp.exe dist/${{ matrix.artifact }}/ | |
| Copy-Item -Recurse out dist/${{ matrix.artifact }}/web | |
| Compress-Archive -Path dist/${{ matrix.artifact }} -DestinationPath dist/${{ matrix.artifact }}.zip | |
| - name: Smoke test packaged artifact (Unix) | |
| if: runner.os != 'Windows' | |
| run: | | |
| set -euo pipefail | |
| test -x "dist/${{ matrix.artifact }}/codeg-server" | |
| test -x "dist/${{ matrix.artifact }}/codeg-mcp" | |
| # `codeg-mcp --help` exits 0 without flags and prints the usage | |
| # line. Only run it for native targets; cross-compiled arm64 | |
| # binaries on x64 runners can't execute. | |
| if [ "${{ matrix.target }}" = "x86_64-unknown-linux-gnu" ] || \ | |
| [ "${{ matrix.target }}" = "x86_64-apple-darwin" ] || \ | |
| [ "${{ matrix.target }}" = "aarch64-apple-darwin" ]; then | |
| "dist/${{ matrix.artifact }}/codeg-mcp" --help | grep -F 'codeg-mcp --parent-connection-id' | |
| else | |
| echo "skipping codeg-mcp --help on cross-target ${{ matrix.target }}" | |
| fi | |
| - name: Smoke test packaged artifact (Windows) | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| if (-not (Test-Path "dist/${{ matrix.artifact }}/codeg-server.exe")) { | |
| throw "codeg-server.exe missing from packaged artifact" | |
| } | |
| if (-not (Test-Path "dist/${{ matrix.artifact }}/codeg-mcp.exe")) { | |
| throw "codeg-mcp.exe missing from packaged artifact" | |
| } | |
| $help = & "dist/${{ matrix.artifact }}/codeg-mcp.exe" --help | |
| if ($help -notlike "*codeg-mcp --parent-connection-id*") { | |
| throw "codeg-mcp --help output unexpected: $help" | |
| } | |
| - name: Upload artifact for Docker build (Linux only) | |
| if: startsWith(matrix.target, 'x86_64-unknown-linux') || startsWith(matrix.target, 'aarch64-unknown-linux') | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ matrix.artifact }} | |
| path: dist/${{ matrix.artifact }} | |
| retention-days: 1 | |
| # Sign the server bundle with the SAME minisign key the desktop | |
| # updater uses (`tauri signer sign` writes `<file>.sig`). The server's | |
| # `update::verify` module verifies this signature against the embedded | |
| # public key before installing — see src-tauri/src/update/verify.rs. | |
| - name: Sign and checksum bundle (Unix) | |
| if: runner.os != 'Windows' | |
| env: | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| pnpm tauri signer sign "dist/${{ matrix.artifact }}.tar.gz" | |
| test -f "dist/${{ matrix.artifact }}.tar.gz.sig" | |
| ( cd dist && shasum -a 256 "${{ matrix.artifact }}.tar.gz" > "${{ matrix.artifact }}.tar.gz.sha256" ) | |
| - name: Sign and checksum bundle (Windows) | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| env: | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| run: | | |
| pnpm tauri signer sign "dist/${{ matrix.artifact }}.zip" | |
| if (-not (Test-Path "dist/${{ matrix.artifact }}.zip.sig")) { | |
| throw "signature not produced for ${{ matrix.artifact }}.zip" | |
| } | |
| $hash = (Get-FileHash -Algorithm SHA256 "dist/${{ matrix.artifact }}.zip").Hash.ToLower() | |
| "$hash ${{ matrix.artifact }}.zip" | Out-File -Encoding ascii "dist/${{ matrix.artifact }}.zip.sha256" | |
| - name: Upload to release (Unix) | |
| if: runner.os != 'Windows' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh release upload "${{ github.ref_name }}" \ | |
| "dist/${{ matrix.artifact }}.tar.gz" \ | |
| "dist/${{ matrix.artifact }}.tar.gz.sig" \ | |
| "dist/${{ matrix.artifact }}.tar.gz.sha256" \ | |
| --clobber | |
| - name: Upload to release (Windows) | |
| if: runner.os == 'Windows' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: gh release upload "${{ github.ref_name }}" "dist/${{ matrix.artifact }}.zip" "dist/${{ matrix.artifact }}.zip.sig" "dist/${{ matrix.artifact }}.zip.sha256" --clobber | |
| build-docker: | |
| # Disabled while server artifacts are not being built for release publishing. | |
| if: ${{ false }} | |
| needs: | |
| - create-draft-release | |
| - build-server | |
| name: Build Docker image | |
| runs-on: ubuntu-22.04 | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Download Linux x64 artifact | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: codeg-server-linux-x64 | |
| path: artifacts/linux-x64 | |
| - name: Download Linux arm64 artifact | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: codeg-server-linux-arm64 | |
| path: artifacts/linux-arm64 | |
| - name: Prepare Docker build context | |
| run: | | |
| set -euo pipefail | |
| mkdir -p dist/amd64 dist/arm64 | |
| cp artifacts/linux-x64/codeg-server dist/amd64/codeg-server | |
| cp artifacts/linux-x64/codeg-mcp dist/amd64/codeg-mcp | |
| cp artifacts/linux-arm64/codeg-server dist/arm64/codeg-server | |
| cp artifacts/linux-arm64/codeg-mcp dist/arm64/codeg-mcp | |
| cp -r artifacts/linux-x64/web dist/web | |
| chmod +x dist/amd64/codeg-server dist/amd64/codeg-mcp \ | |
| dist/arm64/codeg-server dist/arm64/codeg-mcp | |
| # Fail fast if any companion went missing — Docker would otherwise | |
| # produce an image where delegation silently degrades. | |
| for f in dist/amd64/codeg-mcp dist/arm64/codeg-mcp; do | |
| test -x "$f" || { echo "FATAL: $f missing or non-exec"; exit 1; } | |
| done | |
| - name: Set up QEMU (for multi-arch manifest) | |
| uses: docker/setup-qemu-action@v3 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Log in to GitHub Container Registry | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Log in to Docker Hub | |
| uses: docker/login-action@v3 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Extract version from tag | |
| id: version | |
| run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" | |
| - name: Build and push Docker image | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| file: Dockerfile.ci | |
| platforms: linux/amd64,linux/arm64 | |
| push: true | |
| tags: | | |
| ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} | |
| ghcr.io/${{ github.repository }}:latest | |
| ${{ secrets.DOCKERHUB_USERNAME }}/codeg:${{ steps.version.outputs.version }} | |
| ${{ secrets.DOCKERHUB_USERNAME }}/codeg:latest | |
| publish-release: | |
| needs: | |
| - create-draft-release | |
| - build-tauri | |
| - build-server | |
| # - build-docker | |
| if: ${{ needs.build-tauri.result == 'success' && needs.build-server.result == 'success' }} | |
| runs-on: ubuntu-22.04 | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Publish draft release | |
| uses: actions/github-script@v7 | |
| env: | |
| RELEASE_ID: ${{ needs.create-draft-release.outputs.release_id }} | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const releaseId = Number(process.env.RELEASE_ID); | |
| const prerelease = | |
| "${{ needs.create-draft-release.outputs.prerelease }}" === "true"; | |
| await github.rest.repos.updateRelease({ | |
| owner, | |
| repo, | |
| release_id: releaseId, | |
| draft: false, | |
| prerelease, | |
| }); | |
| core.info(`Published release #${releaseId}`); |