name: release # Maintainer-only release workflow. # Trigger: GitHub Actions UI → "Run workflow" (workflow_dispatch). # # Flow (one click → everything below): # 1. prepare — bump version, regen CHANGELOG, open + merge release PR, push tag vX.Y.Z # 2. build-web — build Vite SPA bundle (shared across arches) # 3. build-bundles — assemble native bin/ tarballs for 5 platforms # 4. build-desktop — package Tauri desktop installers from matching runtime bundles # 5. docker-{amd64,arm64} — native per-arch image builds (no QEMU), consuming # the prebuilt linux bundles from step 3 # 6. docker-manifest — stitch the two arch digests into the user-facing # multi-arch tags via `buildx imagetools create` # 7. docker-universal-{amd64,arm64} — native universal builds on top of # docker-manifest's per-arch base # 8. docker-universal-manifest — size-gate amd64 staging, then promote both # arches to the user-facing :universal tags # 9. publish-release — create GH release with runtime + desktop assets and release notes # 10. publish-npm — publish kandev@X.Y.Z + @kdlbs/runtime-*@X.Y.Z to npmjs # 11. update-homebrew-tap — push updated Formula/kandev.rb to kdlbs/homebrew-kandev # # npm publishing uses Trusted Publishers (OIDC) — no NPM_TOKEN required. # Each package (kandev + 5 @kdlbs/runtime-* packages) must have this workflow # configured as its trusted publisher on npmjs.com (Settings → Publishing access). # # Required secrets: # HOMEBREW_TAP_DEPLOY_KEY — SSH private key for kdlbs/homebrew-kandev deploy key # APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / KEYCHAIN_PASSWORD — macOS Developer ID signing # APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID or APPLE_API_KEY / APPLE_API_ISSUER / APPLE_API_KEY_P8 — macOS notarization # WINDOWS_CERTIFICATE / WINDOWS_CERTIFICATE_PASSWORD — Windows installer signing on: workflow_dispatch: inputs: bump: description: "Version bump" required: true type: choice default: minor options: - patch - minor - major dry_run: description: "Dry run (no PR, tag, npm publish, or tap update)" type: boolean default: false allow_unsigned_desktop: description: "Allow unsigned macOS/Windows desktop artifacts; still publishes unless validation-only is checked" type: boolean default: false desktop_validation_only: description: "Build desktop artifacts only (no PR, tag, publish, npm, or tap update)" type: boolean default: false # Default to read-only at the workflow level. Each job opts into the writes # it actually needs. permissions: contents: read jobs: prepare: name: Prepare release (bump version, PR, tag) runs-on: ubuntu-latest permissions: contents: write # push release branch + tag pull-requests: write # create + merge release PR (required by main ruleset) outputs: version: ${{ steps.compute.outputs.version }} tag: ${{ steps.compute.outputs.tag }} ref: ${{ steps.compute.outputs.ref }} steps: - uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - uses: pnpm/action-setup@v5 with: version: "9.15.9" - uses: actions/setup-node@v6 with: node-version: "24" - name: Install git-cliff env: # Pinned to v2.13.1 to match git-cliff-action@v4's default runtime. # To bump, also update the sha512 below — fetch the # new one from: # https://github.com/orhun/git-cliff/releases/download//.sha512 CLIFF_VERSION: "2.13.1" CLIFF_ASSET: "git-cliff-2.13.1-x86_64-unknown-linux-gnu.tar.gz" CLIFF_SHA512: "e716cce3a07dda41b1e370d6afbd7a59eb3d4739509fb7856aeec8da2be28c0396584e29e106141c1a1c535c1827dbc1f60417524f5cfb1da9e11f700bd00f30" run: | set -euo pipefail curl -sSL "https://github.com/orhun/git-cliff/releases/download/v${CLIFF_VERSION}/${CLIFF_ASSET}" -o /tmp/git-cliff.tar.gz echo "${CLIFF_SHA512} /tmp/git-cliff.tar.gz" | sha512sum -c - tar -xz -C /tmp -f /tmp/git-cliff.tar.gz sudo mv "/tmp/git-cliff-${CLIFF_VERSION}/git-cliff" /usr/local/bin/ rm -rf /tmp/git-cliff.tar.gz "/tmp/git-cliff-${CLIFF_VERSION}" git-cliff --version - name: Configure git identity run: | git config user.email "release-bot@kandev" git config user.name "kandev release bot" - name: Compute next version id: compute env: BUMP: ${{ inputs.bump }} DESKTOP_VALIDATION_ONLY: ${{ inputs.desktop_validation_only }} CURRENT_REF: ${{ github.sha }} run: | set -euo pipefail PKG_VERSION="$(node -p "require('./apps/cli/package.json').version")" # Find latest git tag, normalizing legacy two-part tags (vM.m → M.m.0) LATEST_TAG="$(git tag --list 'v*' | python3 -c " import sys, re best = (0, 0, 0) for t in (l.strip() for l in sys.stdin if l.strip()): s = t.lstrip('v') m3 = re.match(r'^(\d+)\.(\d+)\.(\d+)$', s) m2 = re.match(r'^(\d+)\.(\d+)$', s) if m3: v = tuple(int(x) for x in m3.groups()) elif m2: v = (int(m2.group(1)), int(m2.group(2)), 0) else: continue if v > best: best = v print(f'{best[0]}.{best[1]}.{best[2]}') ")" # Use the higher of package.json version and the latest tag as baseline BASELINE="$(python3 -c " a = '$PKG_VERSION'.split('.') b = '$LATEST_TAG'.split('.') ai = tuple(int(x) for x in a) bi = tuple(int(x) for x in b) print('$PKG_VERSION' if ai >= bi else '$LATEST_TAG') ")" IFS=. read -r MAJ MIN PAT <<<"$BASELINE" case "$BUMP" in patch) NEXT="${MAJ}.${MIN}.$((PAT + 1))" ;; minor) NEXT="${MAJ}.$((MIN + 1)).0" ;; major) NEXT="$((MAJ + 1)).0.0" ;; *) echo "Invalid bump '$BUMP'" >&2; exit 1 ;; esac TAG="v${NEXT}" echo "Current package.json version: $PKG_VERSION" echo "Latest git tag (normalized): $LATEST_TAG" echo "Baseline: $BASELINE" echo "Bump: $BUMP" echo "Next version: $NEXT" echo "Next tag: $TAG" echo "version=$NEXT" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" if [ "$DESKTOP_VALIDATION_ONLY" = "true" ]; then echo "ref=$CURRENT_REF" >> "$GITHUB_OUTPUT" else echo "ref=$TAG" >> "$GITHUB_OUTPUT" fi # Sanity: tag must not already exist if git rev-parse "$TAG" >/dev/null 2>&1; then echo "Tag $TAG already exists locally" >&2 exit 1 fi if git ls-remote --tags origin "refs/tags/$TAG" | grep -q .; then echo "Tag $TAG already exists on origin" >&2 exit 1 fi - name: Bump version + generate CHANGELOG (in working tree) if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} env: NEXT: ${{ steps.compute.outputs.version }} TAG: ${{ steps.compute.outputs.tag }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail # Bump only the main package version. optionalDependencies stay # pinned to 0.0.0-bootstrap in committed source — publish-npm.sh # rewrites them to the real version in-flight before publishing, # once the matching @kdlbs/runtime-* tarballs exist on npm. (cd apps/cli && npm version --no-git-tag-version "$NEXT") (cd apps/desktop && npm version --no-git-tag-version "$NEXT") node <<'NODE' const fs = require("node:fs"); const next = process.env.NEXT; const tauriConfigPath = "apps/desktop/src-tauri/tauri.conf.json"; const tauriConfig = JSON.parse(fs.readFileSync(tauriConfigPath, "utf8")); tauriConfig.version = next; fs.writeFileSync(tauriConfigPath, `${JSON.stringify(tauriConfig, null, 2)}\n`); const cargoTomlPath = "apps/desktop/src-tauri/Cargo.toml"; const cargoToml = fs.readFileSync(cargoTomlPath, "utf8"); fs.writeFileSync(cargoTomlPath, cargoToml.replace(/^version = ".*"$/m, `version = "${next}"`)); const cargoLockPath = "apps/desktop/src-tauri/Cargo.lock"; const cargoLock = fs.readFileSync(cargoLockPath, "utf8"); const packageBlock = /(\[\[package\]\]\nname = "kandev-desktop"\nversion = ")[^"]+(")/; if (!packageBlock.test(cargoLock)) { throw new Error("Could not find kandev-desktop package entry in Cargo.lock"); } fs.writeFileSync(cargoLockPath, cargoLock.replace(packageBlock, `$1${next}$2`)); NODE pnpm -C apps install --no-frozen-lockfile git-cliff --tag "$TAG" -o CHANGELOG.md - name: Create release PR + squash-merge + tag if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NEXT: ${{ steps.compute.outputs.version }} TAG: ${{ steps.compute.outputs.tag }} run: | set -euo pipefail # main is protected by a ruleset that requires PRs, so we go through # a release branch + auto-merged PR rather than pushing directly. BRANCH="release/$TAG" MSG="release: $NEXT" git checkout -b "$BRANCH" git add apps/cli/package.json apps/desktop/package.json apps/desktop/src-tauri/Cargo.lock apps/desktop/src-tauri/Cargo.toml apps/desktop/src-tauri/tauri.conf.json CHANGELOG.md apps/pnpm-lock.yaml git commit -m "$MSG" # If a previous run pushed this branch but failed before the merge # landed, the remote branch still exists. Delete it so the push # below is a clean -u origin "$BRANCH" rather than a non-fast-forward # rejection. Closing the corresponding stale PR (if any) happens # automatically when GitHub sees the head branch deleted. git push origin --delete "$BRANCH" 2>/dev/null || true git push -u origin "$BRANCH" gh pr create \ --title "$MSG" \ --body "Automated release: \`$NEXT\`" \ --head "$BRANCH" \ --base main # Immediate squash-merge. This relies on main's ruleset requiring # only "PR required" with no status checks. If status checks are # added to the ruleset later, this needs to switch to `--auto` + # polling for merge completion before the tag steps below. gh pr merge "$BRANCH" --squash --delete-branch --subject "$MSG" --body "" # Pull the squashed commit into local main, then tag from it. git fetch origin main git checkout main git reset --hard origin/main git tag -a "$TAG" -m "release: $NEXT" # Tag push: retry once for transient network blips. If both attempts # fail, main already has the bumped commit but no tag exists. # Recover by manually pushing the tag from any clone pointing at the # squashed merge commit: git push origin "$TAG" git push origin "$TAG" || { echo "Tag push failed; retrying in 5s..." >&2 sleep 5 git push origin "$TAG" || { echo "ERROR: Tag push failed twice after PR merge succeeded." >&2 echo " main is at $NEXT but the tag was not created on origin." >&2 echo " Recover manually: git push origin $TAG" >&2 exit 1 } } - name: Desktop validation summary if: ${{ !inputs.dry_run && inputs.desktop_validation_only }} env: NEXT: ${{ steps.compute.outputs.version }} TAG: ${{ steps.compute.outputs.tag }} REF: ${{ steps.compute.outputs.ref }} ALLOW_UNSIGNED_DESKTOP: ${{ inputs.allow_unsigned_desktop }} run: | echo "Desktop validation for $TAG / $NEXT." echo "Unsigned desktop artifacts allowed: $ALLOW_UNSIGNED_DESKTOP." echo "No release PR, tag, GitHub release, public container tags, npm publish, or Homebrew update will be created." echo "Build jobs will check out $REF." - name: Dry-run summary if: ${{ inputs.dry_run }} env: NEXT: ${{ steps.compute.outputs.version }} TAG: ${{ steps.compute.outputs.tag }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # In dry-run we still bump + run git-cliff locally so the maintainer # can verify the computed version + changelog look right, but nothing # is committed or pushed. (cd apps/cli && npm version --no-git-tag-version "$NEXT") pnpm -C apps install --no-frozen-lockfile git-cliff --tag "$TAG" -o CHANGELOG.md echo echo "DRY RUN: Would have created + merged release PR and tagged $TAG." echo "DRY RUN: apps/cli/package.json bumped to $NEXT (in-runner only)." echo "DRY RUN: CHANGELOG.md regenerated (in-runner only)." build-web: name: Build web SPA needs: prepare if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.ref }} persist-credentials: false - uses: pnpm/action-setup@v5 with: version: "9.15.9" - uses: actions/setup-node@v6 with: node-version: "24" cache: pnpm cache-dependency-path: apps/pnpm-lock.yaml - name: Install dependencies run: pnpm -C apps install --frozen-lockfile - name: Build web (Vite) env: KANDEV_VERSION: ${{ needs.prepare.outputs.tag }} VITE_KANDEV_API_PORT: "" VITE_KANDEV_DEBUG: "" run: pnpm -C apps --filter @kandev/web build - uses: actions/upload-artifact@v5 with: name: web-bundle path: apps/web/dist include-hidden-files: true build-bundles: name: Build platform bundle (${{ matrix.platform }}) needs: [prepare, build-web] if: ${{ !inputs.dry_run }} strategy: matrix: include: - os: ubuntu-22.04 platform: linux-x64 goos: linux goarch: amd64 - os: ubuntu-24.04-arm platform: linux-arm64 goos: linux goarch: arm64 - os: macos-14 platform: macos-x64 goos: darwin goarch: amd64 - os: macos-14 platform: macos-arm64 goos: darwin goarch: arm64 - os: ubuntu-latest platform: windows-x64 goos: windows goarch: amd64 cc: x86_64-w64-mingw32-gcc runs-on: ${{ matrix.os }} env: CGO_ENABLED: 1 GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.ref }} persist-credentials: false - uses: actions/setup-go@v6 with: go-version-file: apps/backend/go.mod cache-dependency-path: apps/backend/go.sum - uses: actions/download-artifact@v5 with: name: web-bundle path: apps/backend/internal/webapp/embedded/generated - name: Install cross-compilation tools if: matrix.cc run: | sudo apt-get update sudo apt-get install -y gcc-mingw-w64-x86-64 # The `fts5` build tag is REQUIRED on the kandev binary. The office # task search migrations create a `tasks_fts` virtual table using # `USING fts5(...)`; without the tag, mattn/go-sqlite3 lacks the # module and any subsequent boot against a DB that already has the # virtual table fails with "no such module: fts5" during schema init. # See issue #1006 for the regression this prevents. # # Version ldflags mirror apps/backend/Makefile so `kandev --version` # reports the released tag instead of the build-time default "dev". - name: Build backend binaries env: CC: ${{ matrix.cc || '' }} KANDEV_VERSION: ${{ needs.prepare.outputs.tag }} run: | test -f apps/backend/internal/webapp/embedded/generated/index.html cd apps/backend mkdir -p bin EXT=${{ matrix.goos == 'windows' && '.exe' || '' }} COMMIT=$(git rev-parse --short HEAD) BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ') LDFLAGS="-s -w -X main.Version=${KANDEV_VERSION} -X main.Commit=${COMMIT} -X main.BuildTime=${BUILD_TIME}" # Export to subsequent steps so the cross-compiled agentctl helper # below gets the same version metadata (matches Makefile parity). echo "LDFLAGS=${LDFLAGS}" >> "${GITHUB_ENV}" go build -tags fts5 -ldflags "${LDFLAGS}" -o bin/kandev${EXT} ./cmd/kandev go build -ldflags "${LDFLAGS}" -o bin/agentctl${EXT} ./cmd/agentctl # Cross-compile a linux/amd64 agentctl regardless of the host platform. # Remote executors (local Docker, remote Docker, Sprites) bind-mount this # binary into the container — without it, Docker sessions fail with # "agentctl linux binary not found" on Mac/Windows installs and even on # Linux installs (the resolver looks for the -linux-amd64 suffix). # CGO is off so this works from any host without extra toolchain setup. - name: Build linux/amd64 agentctl helper run: | cd apps/backend GOOS=linux GOARCH=amd64 CGO_ENABLED=0 CC= go build -ldflags "${LDFLAGS}" -o bin/agentctl-linux-amd64 ./cmd/agentctl - name: Package bundle run: | rm -rf dist/kandev mkdir -p dist/kandev/bin EXT=${{ matrix.goos == 'windows' && '.exe' || '' }} cp apps/backend/bin/kandev${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl-linux-amd64 dist/kandev/bin/ cd dist tar -czf "kandev-${{ matrix.platform }}.tar.gz" kandev shasum -a 256 "kandev-${{ matrix.platform }}.tar.gz" > "kandev-${{ matrix.platform }}.tar.gz.sha256" - uses: actions/upload-artifact@v5 with: name: bundle-${{ matrix.platform }} path: | dist/kandev-${{ matrix.platform }}.tar.gz dist/kandev-${{ matrix.platform }}.tar.gz.sha256 build-desktop: name: Build desktop app (${{ matrix.platform }}) needs: [prepare, build-bundles] if: ${{ !inputs.dry_run }} strategy: matrix: include: - platform: macos-arm64 os: macos-15 rust_target: aarch64-apple-darwin tauri_bundles: dmg - platform: macos-x64 os: macos-15-intel rust_target: x86_64-apple-darwin tauri_bundles: dmg - platform: linux-x64 os: ubuntu-22.04 rust_target: x86_64-unknown-linux-gnu tauri_bundles: deb,rpm - platform: linux-arm64 os: ubuntu-24.04-arm rust_target: aarch64-unknown-linux-gnu tauri_bundles: deb,rpm - platform: windows-x64 os: windows-latest rust_target: x86_64-pc-windows-msvc tauri_bundles: nsis runs-on: ${{ matrix.os }} defaults: run: shell: bash steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.ref }} persist-credentials: false - uses: pnpm/action-setup@v5 with: version: "9.15.9" - uses: actions/setup-node@v6 with: node-version: "24" cache: pnpm cache-dependency-path: apps/pnpm-lock.yaml - name: Set up Rust run: rustup toolchain install stable --profile minimal --target "${{ matrix.rust_target }}" - name: Cache Rust build uses: Swatinem/rust-cache@v2 with: workspaces: apps/desktop/src-tauri -> target key: desktop-${{ matrix.platform }}-${{ matrix.rust_target }} - name: Install Linux desktop dependencies if: startsWith(matrix.platform, 'linux-') run: | sudo apt-get update sudo apt-get install -y \ pkg-config \ libglib2.0-dev \ libwebkit2gtk-4.1-dev \ libgtk-3-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ patchelf - name: Install dependencies run: pnpm -C apps install --frozen-lockfile - uses: actions/download-artifact@v5 with: name: bundle-${{ matrix.platform }} path: dist/desktop-runtime - name: Prepare desktop runtime resources run: | set -euo pipefail mkdir -p dist/desktop-runtime-src tar -xzf "dist/desktop-runtime/kandev-${{ matrix.platform }}.tar.gz" -C dist/desktop-runtime-src scripts/release/prepare-desktop-runtime.sh \ --bundle-dir "dist/desktop-runtime-src/kandev" \ --platform "${{ matrix.platform }}" \ --output-dir "apps/desktop/src-tauri/resources/kandev" - name: Configure macOS signing and notarization if: startsWith(matrix.platform, 'macos-') env: ALLOW_UNSIGNED_DESKTOP: ${{ inputs.allow_unsigned_desktop }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_PROVIDER_SHORT_NAME: ${{ secrets.APPLE_PROVIDER_SHORT_NAME }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} run: | set -euo pipefail if [ "$ALLOW_UNSIGNED_DESKTOP" = "true" ]; then echo "::notice::Unsigned desktop artifacts requested; skipping macOS signing and notarization setup." exit 0 fi missing=() for key in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD KEYCHAIN_PASSWORD; do if [ -z "${!key:-}" ]; then missing+=("$key") fi done apple_id_notarization=0 if [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then apple_id_notarization=1 fi api_key_notarization=0 if [ -n "${APPLE_API_KEY:-}" ] && [ -n "${APPLE_API_ISSUER:-}" ] && [ -n "${APPLE_API_KEY_P8:-}" ]; then api_key_notarization=1 fi if [ "$apple_id_notarization" -eq 0 ] && [ "$api_key_notarization" -eq 0 ]; then missing+=("APPLE_ID/APPLE_PASSWORD/APPLE_TEAM_ID or APPLE_API_KEY/APPLE_API_ISSUER/APPLE_API_KEY_P8") fi if [ "${#missing[@]}" -gt 0 ]; then echo "::error::Public macOS desktop releases require signing and notarization inputs: ${missing[*]}" >&2 exit 1 fi certificate_path="$RUNNER_TEMP/kandev-developer-id.p12" python3 - <<'PY' import base64 import os from pathlib import Path Path(os.environ["RUNNER_TEMP"], "kandev-developer-id.p12").write_bytes( base64.b64decode(os.environ["APPLE_CERTIFICATE"]) ) PY security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security default-keychain -s build.keychain security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security set-keychain-settings -t 3600 -u build.keychain security import "$certificate_path" -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain cert_info="$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application" | head -n 1 || true)" if [ -z "$cert_info" ]; then cert_info="$(security find-identity -v -p codesigning build.keychain | head -n 1 || true)" fi if [ -z "$cert_info" ]; then echo "::error::No code signing identity found in build.keychain" >&2 exit 1 fi cert_id="$(echo "$cert_info" | awk -F'"' '{print $2}')" echo "APPLE_SIGNING_IDENTITY=$cert_id" >> "$GITHUB_ENV" if [ "$api_key_notarization" -eq 1 ]; then private_keys_dir="$RUNNER_TEMP/private_keys" mkdir -p "$private_keys_dir" printf '%s\n' "$APPLE_API_KEY_P8" > "$private_keys_dir/AuthKey_${APPLE_API_KEY}.p8" echo "APPLE_API_KEY_PATH=$private_keys_dir/AuthKey_${APPLE_API_KEY}.p8" >> "$GITHUB_ENV" fi - name: Sign macOS desktop runtime binaries if: startsWith(matrix.platform, 'macos-') && !inputs.allow_unsigned_desktop run: | set -euo pipefail runtime_bin="apps/desktop/src-tauri/resources/kandev/bin" for binary in kandev agentctl; do path="$runtime_bin/$binary" if [ ! -f "$path" ]; then echo "::error::Missing macOS runtime binary at $path" >&2 exit 1 fi codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" "$path" codesign --verify --strict --verbose=2 "$path" done - name: Sign Windows desktop runtime binaries if: matrix.platform == 'windows-x64' && !inputs.allow_unsigned_desktop shell: pwsh env: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }} WINDOWS_SIGNTOOL_PATH: ${{ secrets.WINDOWS_SIGNTOOL_PATH }} run: | $runtimeBin = Join-Path $env:GITHUB_WORKSPACE "apps/desktop/src-tauri/resources/kandev/bin" foreach ($binary in @("kandev.exe", "agentctl.exe")) { $path = Join-Path $runtimeBin $binary if (!(Test-Path -LiteralPath $path)) { Write-Error "Missing Windows runtime binary at $path" exit 1 } & "$env:GITHUB_WORKSPACE/apps/desktop/src-tauri/windows-sign.ps1" -Path $path } - name: Build Tauri desktop app env: CI: "true" APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_PROVIDER_SHORT_NAME: ${{ secrets.APPLE_PROVIDER_SHORT_NAME }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} ALLOW_UNSIGNED_DESKTOP: ${{ inputs.allow_unsigned_desktop }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }} WINDOWS_SIGNTOOL_PATH: ${{ secrets.WINDOWS_SIGNTOOL_PATH }} run: | set -euo pipefail if [ "$ALLOW_UNSIGNED_DESKTOP" = "true" ]; then unset APPLE_CERTIFICATE unset APPLE_CERTIFICATE_PASSWORD unset APPLE_ID unset APPLE_PASSWORD unset APPLE_TEAM_ID unset APPLE_PROVIDER_SHORT_NAME unset APPLE_API_KEY unset APPLE_API_ISSUER unset APPLE_API_KEY_PATH unset APPLE_SIGNING_IDENTITY unset WINDOWS_CERTIFICATE unset WINDOWS_CERTIFICATE_PASSWORD unset WINDOWS_TIMESTAMP_URL unset WINDOWS_SIGNTOOL_PATH fi pnpm -C apps --filter @kandev/desktop build:vite cd apps/desktop pnpm tauri build \ --features desktop-runtime \ --target "${{ matrix.rust_target }}" \ --bundles "${{ matrix.tauri_bundles }}" - name: Sign Windows desktop artifacts if: matrix.platform == 'windows-x64' && !inputs.allow_unsigned_desktop shell: pwsh env: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }} WINDOWS_SIGNTOOL_PATH: ${{ secrets.WINDOWS_SIGNTOOL_PATH }} run: | $missing = @() if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE)) { $missing += "WINDOWS_CERTIFICATE" } if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_PASSWORD)) { $missing += "WINDOWS_CERTIFICATE_PASSWORD" } if ($missing.Count -gt 0) { Write-Error "Public Windows desktop releases require signing inputs: $($missing -join ', ')" exit 1 } $certificatePath = Join-Path $env:RUNNER_TEMP "kandev-code-signing.pfx" [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)) $bundleDir = Join-Path $env:GITHUB_WORKSPACE "apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle" if (!(Test-Path $bundleDir)) { $bundleDir = Join-Path $env:GITHUB_WORKSPACE "apps/desktop/src-tauri/target/release/bundle" } if (!(Test-Path $bundleDir)) { Write-Error "Missing Tauri bundle directory" exit 1 } $timestampUrl = if ([string]::IsNullOrWhiteSpace($env:WINDOWS_TIMESTAMP_URL)) { "https://timestamp.digicert.com" } else { $env:WINDOWS_TIMESTAMP_URL } $signTool = if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNTOOL_PATH)) { "signtool" } else { $env:WINDOWS_SIGNTOOL_PATH } $artifacts = @(Get-ChildItem -Path $bundleDir -Recurse -File -Include *.exe, *.msi) if ($artifacts.Count -eq 0) { Write-Error "No Windows installer artifacts found under $bundleDir" exit 1 } function Invoke-SignTool { param( [Parameter(Mandatory = $true)] [string[]]$Arguments, [Parameter(Mandatory = $true)] [string]$Description ) & $signTool @Arguments if ($LASTEXITCODE -ne 0) { throw "$Description failed with exit code $LASTEXITCODE" } } # signtool sign is the default Windows signing mechanism for CI artifacts. try { foreach ($artifact in $artifacts) { $signArguments = @( "sign", "/fd", "SHA256", "/td", "SHA256", "/tr", $timestampUrl, "/f", $certificatePath, "/p", $env:WINDOWS_CERTIFICATE_PASSWORD, $artifact.FullName ) $verifyArguments = @("verify", "/pa", $artifact.FullName) Invoke-SignTool ` -Description "signtool sign $($artifact.Name)" ` -Arguments $signArguments Invoke-SignTool ` -Description "signtool verify $($artifact.Name)" ` -Arguments $verifyArguments } } finally { if (Test-Path -LiteralPath $certificatePath) { Remove-Item -LiteralPath $certificatePath -Force -ErrorAction SilentlyContinue } } - name: Collect desktop artifacts run: | set -euo pipefail out_dir="$GITHUB_WORKSPACE/dist/desktop-artifacts" mkdir -p "$out_dir" bundle_dir="apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle" if [ ! -d "$bundle_dir" ]; then bundle_dir="apps/desktop/src-tauri/target/release/bundle" fi if [ ! -d "$bundle_dir" ]; then echo "Missing Tauri bundle directory" >&2 exit 1 fi found=0 while IFS= read -r -d '' artifact; do base="$(basename "$artifact")" dest="$out_dir/kandev-desktop-${{ matrix.platform }}-$base" cp "$artifact" "$dest" scripts/release/write-sha256.sh "$dest" "$dest.sha256" found=$((found + 1)) done < <(find "$bundle_dir" -type f \( \ -name '*.dmg' -o \ -name '*.deb' -o \ -name '*.rpm' -o \ -name '*.msi' -o \ -name '*.exe' -o \ -name '*.AppImage' \ \) -print0) if [ "$found" -eq 0 ]; then echo "No desktop artifacts found under $bundle_dir" >&2 exit 1 fi scripts/release/verify-desktop-assets.sh "$out_dir" "${{ matrix.platform }}" ls -lh "$out_dir" - uses: actions/upload-artifact@v5 with: name: desktop-${{ matrix.platform }} path: dist/desktop-artifacts/* # --------------------------------------------------------------------------- # Docker base image — split per arch onto native runners, then stitched into # a multi-arch manifest list. Each arch consumes the matching linux bundle # produced by build-bundles, so no Go/Vite/pnpm work happens inside the # Docker build (the Dockerfile is reduced to apt-get + COPY). # --------------------------------------------------------------------------- docker-amd64: name: Build & push container image (amd64) needs: [prepare, build-bundles] if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest permissions: contents: read packages: write outputs: digest: ${{ steps.build.outputs.digest }} env: STAGING_IMAGE: ghcr.io/kdlbs/kandev:base-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}-amd64 steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.tag }} - uses: actions/download-artifact@v4 with: name: bundle-linux-x64 path: dist/bundle - name: Prepare Docker build context run: | set -euo pipefail mkdir -p ctx/bundle # --strip-components=1 drops the top-level `kandev/` dir inside the # tarball so ctx/bundle/ ends up with bin/ directly. tar -xzf dist/bundle/kandev-linux-x64.tar.gz --strip-components=1 -C ctx/bundle cp docker-entrypoint.sh ctx/ - uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # OCI labels are produced once here per arch. The manifest job combines # the per-arch image manifests into a multi-arch list via `imagetools # create`, which cannot retroactively add labels to image configs, so # the labels MUST be stamped at build time on each arch. GHCR's package # page link + provenance tooling rely on these annotations. - name: Docker metadata (labels) id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/kdlbs/kandev - name: Build and push (amd64 staging tag) id: build uses: docker/build-push-action@v6 with: context: ./ctx file: ./Dockerfile push: true platforms: linux/amd64 tags: ${{ env.STAGING_IMAGE }} labels: ${{ steps.meta.outputs.labels }} # provenance/sbom would attach an extra attestation manifest, which # turns `imagetools inspect --raw` into an OCI index and breaks the # size gate's `.layers[].size` query. The final multi-arch tags # don't preserve attestations through `imagetools create` anyway, # so disabling here doesn't change user-facing image content. provenance: false sbom: false cache-from: type=gha,scope=base-amd64 cache-to: type=gha,mode=max,scope=base-amd64 docker-arm64: name: Build & push container image (arm64) needs: [prepare, build-bundles] if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-24.04-arm permissions: contents: read packages: write outputs: digest: ${{ steps.build.outputs.digest }} env: STAGING_IMAGE: ghcr.io/kdlbs/kandev:base-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}-arm64 steps: - uses: actions/checkout@v4 with: ref: ${{ needs.prepare.outputs.tag }} - uses: actions/download-artifact@v4 with: name: bundle-linux-arm64 path: dist/bundle - name: Prepare Docker build context run: | set -euo pipefail mkdir -p ctx/bundle tar -xzf dist/bundle/kandev-linux-arm64.tar.gz --strip-components=1 -C ctx/bundle cp docker-entrypoint.sh ctx/ - uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker metadata (labels) id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/kdlbs/kandev - name: Build and push (arm64 staging tag) id: build uses: docker/build-push-action@v6 with: context: ./ctx file: ./Dockerfile push: true platforms: linux/arm64 tags: ${{ env.STAGING_IMAGE }} labels: ${{ steps.meta.outputs.labels }} provenance: false sbom: false cache-from: type=gha,scope=base-arm64 cache-to: type=gha,mode=max,scope=base-arm64 docker-manifest: name: Assemble multi-arch base manifest needs: [prepare, docker-amd64, docker-arm64] if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Docker meta id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/kdlbs/kandev tags: | type=raw,value=${{ needs.prepare.outputs.version }} type=raw,value=${{ needs.prepare.outputs.tag }} type=sha type=raw,value=latest - uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # `imagetools create` combines two single-arch images into a multi-arch # manifest list under each of the user-facing tags. No layers are # rebuilt or re-uploaded; this is a registry-side metadata operation. # The per-arch source images are referenced by digest (not by the # mutable staging tag) so this is pinned to the exact bytes the # per-arch jobs just pushed. - name: Combine arch images into multi-arch tags env: AMD64_REF: ghcr.io/kdlbs/kandev@${{ needs.docker-amd64.outputs.digest }} ARM64_REF: ghcr.io/kdlbs/kandev@${{ needs.docker-arm64.outputs.digest }} TAGS: ${{ steps.meta.outputs.tags }} run: | set -euo pipefail tag_args=() while IFS= read -r tag; do [ -z "$tag" ] && continue tag_args+=(--tag "$tag") done <<<"$TAGS" docker buildx imagetools create "${tag_args[@]}" "$AMD64_REF" "$ARM64_REF" # --------------------------------------------------------------------------- # Universal image — same per-arch native split, on top of the base image # we just promoted. The size gate inspects the amd64 staging tag and aborts # before the user-facing :universal tags are moved. # --------------------------------------------------------------------------- docker-universal-amd64: name: Build & push universal image (amd64) needs: [prepare, docker-manifest] if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest permissions: contents: read packages: write outputs: digest: ${{ steps.build.outputs.digest }} env: STAGING_IMAGE: ghcr.io/kdlbs/kandev:universal-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}-amd64 steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.tag }} - uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker metadata (labels) id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/kdlbs/kandev - name: Build and push (universal amd64 staging tag) id: build uses: docker/build-push-action@v6 with: context: . file: Dockerfile.universal push: true platforms: linux/amd64 build-args: | BASE_IMAGE=ghcr.io/kdlbs/kandev:${{ needs.prepare.outputs.tag }} tags: ${{ env.STAGING_IMAGE }} labels: ${{ steps.meta.outputs.labels }} # provenance/sbom off so `imagetools inspect --raw` on the staging # tag returns the plain image manifest, not an OCI index — required # by the size gate in docker-universal-manifest. provenance: false sbom: false cache-from: type=gha,scope=universal-amd64 cache-to: type=gha,mode=max,scope=universal-amd64 docker-universal-arm64: name: Build & push universal image (arm64) needs: [prepare, docker-manifest] if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-24.04-arm permissions: contents: read packages: write outputs: digest: ${{ steps.build.outputs.digest }} env: STAGING_IMAGE: ghcr.io/kdlbs/kandev:universal-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}-arm64 steps: - uses: actions/checkout@v4 with: ref: ${{ needs.prepare.outputs.tag }} - uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker metadata (labels) id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/kdlbs/kandev - name: Build and push (universal arm64 staging tag) id: build uses: docker/build-push-action@v6 with: context: . file: Dockerfile.universal push: true platforms: linux/arm64 build-args: | BASE_IMAGE=ghcr.io/kdlbs/kandev:${{ needs.prepare.outputs.tag }} tags: ${{ env.STAGING_IMAGE }} labels: ${{ steps.meta.outputs.labels }} provenance: false sbom: false cache-from: type=gha,scope=universal-arm64 cache-to: type=gha,mode=max,scope=universal-arm64 docker-universal-manifest: name: Gate size + promote universal manifest # docker-universal-manifest is included in publish-release's `needs` so # its size gate (and any per-arch build failure) blocks the release. needs: [prepare, docker-universal-amd64, docker-universal-arm64] if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest permissions: contents: read packages: write env: AMD64_REF: ghcr.io/kdlbs/kandev@${{ needs.docker-universal-amd64.outputs.digest }} ARM64_REF: ghcr.io/kdlbs/kandev@${{ needs.docker-universal-arm64.outputs.digest }} steps: - uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # Universal size gate. Inspects the amd64 staging image by digest - the # image is in GHCR but under a per-run tag no user pulls. Failing the # gate aborts before promotion, so the user-facing :universal / # :vX.Y.Z-universal tags never move to an oversized image. Bump # MAX_COMPRESSED_BYTES deliberately when adding scope, with a # one-sentence justification in the commit msg. amd64 is the bigger of # the two arches in practice, so gating on it gives us a single-number # signal. Per-arch staging builds set provenance: false, so the digest # points directly at the image manifest (not an OCI index). - name: Enforce size budget env: MAX_COMPRESSED_BYTES: "2684354560" # 2.5 GiB run: | set -euo pipefail docker buildx imagetools inspect "$AMD64_REF" --raw > /tmp/manifest.json TOTAL="$(jq '[.layers[].size] | add' /tmp/manifest.json)" echo "Universal image (amd64) compressed size: $TOTAL bytes (max: $MAX_COMPRESSED_BYTES)" if [ "$TOTAL" -gt "$MAX_COMPRESSED_BYTES" ]; then echo "::error::Universal image exceeds compressed-size budget. Staging tags will not be promoted. See docs/images.md inclusion policy." >&2 exit 1 fi # Promote: combine both arches into a single multi-arch manifest list # under the user-facing tags. `imagetools create` is a metadata-only # registry call - no layers are rebuilt or re-uploaded. Per-arch source # images referenced by digest, not tag, so this is pinned to the bytes # the previous jobs pushed. - name: Promote staging to final tags run: | set -euo pipefail docker buildx imagetools create \ --tag "ghcr.io/kdlbs/kandev:${{ needs.prepare.outputs.version }}-universal" \ --tag "ghcr.io/kdlbs/kandev:${{ needs.prepare.outputs.tag }}-universal" \ --tag "ghcr.io/kdlbs/kandev:universal" \ "$AMD64_REF" "$ARM64_REF" publish-release: name: Publish GitHub release needs: [prepare, build-bundles, build-desktop, docker-universal-manifest] if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest permissions: contents: write # softprops/action-gh-release creates the release steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.tag }} fetch-depth: 0 - name: Generate release notes uses: orhun/git-cliff-action@v4 with: config: cliff.toml args: --latest --strip header env: OUTPUT: RELEASE_NOTES.md GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Add unsigned desktop warning to release notes if: ${{ inputs.allow_unsigned_desktop }} run: | set -euo pipefail tmp="$(mktemp)" { echo "> [!WARNING]" echo "> macOS and Windows desktop installers in this release are unsigned." echo "> They may require manual OS security bypasses and are not trusted downloads." echo cat RELEASE_NOTES.md } > "$tmp" mv "$tmp" RELEASE_NOTES.md - uses: actions/download-artifact@v5 with: pattern: bundle-* path: dist/release-assets merge-multiple: true - uses: actions/download-artifact@v5 with: pattern: desktop-* path: dist/release-assets merge-multiple: true - name: Verify release assets run: | set -euo pipefail required_platforms=(linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64) for platform in "${required_platforms[@]}"; do archive="dist/release-assets/kandev-${platform}.tar.gz" checksum="${archive}.sha256" if [ ! -f "$archive" ]; then echo "Missing required archive: $archive" >&2 exit 1 fi if [ ! -f "$checksum" ]; then echo "Missing required checksum: $checksum" >&2 exit 1 fi done scripts/release/verify-desktop-assets.sh dist/release-assets ls -lh dist/release-assets - name: Publish release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.prepare.outputs.tag }} name: ${{ needs.prepare.outputs.tag }} body_path: RELEASE_NOTES.md files: | dist/release-assets/kandev-*.tar.gz dist/release-assets/kandev-*.tar.gz.sha256 dist/release-assets/kandev-desktop-* publish-npm: name: Publish npm packages needs: [prepare, publish-release] if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest permissions: id-token: write # Required for OIDC trusted publishing contents: read steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.tag }} - uses: pnpm/action-setup@v5 with: version: "9.15.9" - uses: actions/setup-node@v6 with: node-version: "24" registry-url: "https://registry.npmjs.org" cache: pnpm cache-dependency-path: apps/pnpm-lock.yaml - name: Install dependencies run: pnpm -C apps install --frozen-lockfile - name: Publish npm packages env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | bash scripts/release/publish-npm.sh \ "${{ needs.prepare.outputs.version }}" \ "${{ needs.prepare.outputs.tag }}" update-homebrew-tap: name: Update Homebrew tap needs: [prepare, publish-release] if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.tag }} - name: Update Homebrew tap formula env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} run: | bash scripts/release/update-homebrew-tap.sh \ "${{ needs.prepare.outputs.version }}" \ "${{ needs.prepare.outputs.tag }}"