Skip to content

chore(release): prepare 0.15.11-1 release #72

chore(release): prepare 0.15.11-1 release

chore(release): prepare 0.15.11-1 release #72

Workflow file for this run

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}`);