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 for public releases: # HOMEBREW_TAP_DEPLOY_KEY — SSH private key for kdlbs/homebrew-kandev deploy key # Optional secrets for signed desktop artifacts. If these are incomplete, the # workflow still builds unsigned desktop artifacts and warns in release notes. # 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 # TAURI_SIGNING_PRIVATE_KEY / TAURI_SIGNING_PRIVATE_KEY_PASSWORD — updater artifact 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 desktop_validation_only: description: "Build desktop artifacts only (no PR, tag, publish, npm, or tap update)" type: boolean default: false backfill_tag: description: "Existing release tag to rebuild/publish (for recovery; leave empty for normal release)" required: false default: "" type: string # 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: version: "9.15.9" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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: BACKFILL_TAG: ${{ inputs.backfill_tag }} BUMP: ${{ inputs.bump }} DRY_RUN: ${{ inputs.dry_run }} DESKTOP_VALIDATION_ONLY: ${{ inputs.desktop_validation_only }} CURRENT_REF: ${{ github.sha }} run: | set -euo pipefail if [ -n "$BACKFILL_TAG" ]; then echo "::notice::backfill_tag is set; the 'bump' input ($BUMP) is ignored." if [ "$DRY_RUN" = "true" ]; then echo "backfill_tag cannot be combined with dry_run." >&2 exit 1 fi if [ "$DESKTOP_VALIDATION_ONLY" = "true" ]; then echo "backfill_tag cannot be combined with desktop_validation_only." >&2 exit 1 fi case "$BACKFILL_TAG" in refs/tags/*) BACKFILL_TAG="${BACKFILL_TAG#refs/tags/}" ;; esac if [[ ! "$BACKFILL_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "backfill_tag must be a SemVer release tag like v1.2.3." >&2 exit 1 fi BACKFILL_REF="refs/tags/$BACKFILL_TAG" if ! git rev-parse --verify --quiet "${BACKFILL_REF}^{commit}" >/dev/null; then echo "backfill_tag does not exist: $BACKFILL_TAG" >&2 exit 1 fi NEXT="${BACKFILL_TAG#v}" LATEST_RELEASE_TAG="$(git tag --list 'v*' | python3 -c " import sys, re best = None best_tag = '' 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()) tag = f'v{v[0]}.{v[1]}.{v[2]}' elif m2: v = (int(m2.group(1)), int(m2.group(2)), 0) tag = f'v{v[0]}.{v[1]}.{v[2]}' else: continue if best is None or v > best: best = v best_tag = tag print(best_tag) ")" if [ -z "$LATEST_RELEASE_TAG" ]; then echo "backfill_tag cannot be used: no existing release tags found." >&2 exit 1 fi if [ "$LATEST_RELEASE_TAG" != "$BACKFILL_TAG" ]; then echo "backfill_tag must be the latest release tag ($LATEST_RELEASE_TAG) because backfill publishes mutable channels." >&2 exit 1 fi BACKFILL_REF="$BACKFILL_REF" BACKFILL_TAG="$BACKFILL_TAG" NEXT="$NEXT" node <<'NODE' const { execFileSync } = require("node:child_process"); const tag = process.env.BACKFILL_TAG; const ref = process.env.BACKFILL_REF; const next = process.env.NEXT; const show = (path) => execFileSync("git", ["show", `${ref}:${path}`], { encoding: "utf8" }); const versions = new Map([ ["apps/cli/package.json", JSON.parse(show("apps/cli/package.json")).version], ["apps/desktop/package.json", JSON.parse(show("apps/desktop/package.json")).version], ["apps/desktop/src-tauri/tauri.conf.json", JSON.parse(show("apps/desktop/src-tauri/tauri.conf.json")).version], ["apps/desktop/src-tauri/Cargo.toml", show("apps/desktop/src-tauri/Cargo.toml").match(/^version = "([^"]+)"/m)?.[1]], [ "apps/desktop/src-tauri/Cargo.lock", show("apps/desktop/src-tauri/Cargo.lock").match(/\[\[package\]\]\nname = "kandev-desktop"\nversion = "([^"]+)"/)?.[1], ], ]); for (const [path, version] of versions) { if (version !== next) { console.error(`backfill_tag version mismatch: ${tag} points to ${path} version ${version ?? ""} (expected ${next}).`); process.exit(1); } } NODE TAG="$BACKFILL_TAG" echo "Backfill existing release tag: $TAG" echo "Next version: $NEXT" echo "version=$NEXT" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "ref=$BACKFILL_REF" >> "$GITHUB_OUTPUT" exit 0 fi 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=refs/tags/$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 && inputs.backfill_tag == '' }} 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 && inputs.backfill_tag == '' }} 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 }} run: | echo "Desktop validation for $TAG / $NEXT." echo "Desktop signing is automatic: complete signing inputs produce signed artifacts; otherwise artifacts are unsigned." 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ needs.prepare.outputs.ref }} persist-credentials: false - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: version: "9.15.9" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@330a01c490aca151604b8cf639adc76d48f6c5d4 # 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ needs.prepare.outputs.ref }} persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: apps/backend/go.mod cache-dependency-path: apps/backend/go.sum - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # 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 remote agentctl helpers regardless of the host platform. # Remote executors upload or bind-mount these sidecars into Linux and # macOS SSH hosts. CGO is off so this works from any host without extra # toolchain setup. - name: Build remote agentctl helpers run: | cd apps/backend GOOS=linux GOARCH=amd64 CGO_ENABLED=0 CC= go build -ldflags "${LDFLAGS}" -o bin/agentctl-linux-amd64 ./cmd/agentctl GOOS=linux GOARCH=arm64 CGO_ENABLED=0 CC= go build -ldflags "${LDFLAGS}" -o bin/agentctl-linux-arm64 ./cmd/agentctl # darwin/arm64 is ad-hoc-signed by the Go linker at link time, which is # what Apple Silicon (AMFI) requires; bundle validation enforces it. GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 CC= go build -ldflags "${LDFLAGS}" -o bin/agentctl-darwin-arm64 ./cmd/agentctl GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 CC= go build -ldflags "${LDFLAGS}" -o bin/agentctl-darwin-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/ cp apps/backend/bin/agentctl-linux-arm64 dist/kandev/bin/ cp apps/backend/bin/agentctl-darwin-arm64 dist/kandev/bin/ cp apps/backend/bin/agentctl-darwin-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@330a01c490aca151604b8cf639adc76d48f6c5d4 # 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: appimage,deb,rpm - platform: linux-arm64 os: ubuntu-24.04-arm rust_target: aarch64-unknown-linux-gnu tauri_bundles: appimage,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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ needs.prepare.outputs.ref }} persist-credentials: false - name: Prepare desktop asset verifier env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail helper="$RUNNER_TEMP/verify-desktop-assets.sh" gh api \ --header "Accept: application/vnd.github.raw+json" \ "repos/${{ github.repository }}/contents/scripts/release/verify-desktop-assets.sh?ref=${{ github.workflow_sha }}" \ > "$helper" echo "DESKTOP_ASSET_VERIFIER=$helper" >> "$GITHUB_ENV" - name: Prepare macOS desktop diagnostics helper if: startsWith(matrix.platform, 'macos-') continue-on-error: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail helper="$RUNNER_TEMP/collect-macos-desktop-diagnostics.sh" mkdir -p "$(dirname "$helper")" if [ -f "$GITHUB_WORKSPACE/.github/scripts/collect-macos-desktop-diagnostics.sh" ]; then cp "$GITHUB_WORKSPACE/.github/scripts/collect-macos-desktop-diagnostics.sh" "$helper" else encoded_helper="$RUNNER_TEMP/collect-macos-desktop-diagnostics.sh.b64" gh api \ "repos/${{ github.repository }}/contents/.github/scripts/collect-macos-desktop-diagnostics.sh?ref=${{ github.workflow_sha }}" \ --jq .content > "$encoded_helper" HELPER="$helper" ENCODED_HELPER="$encoded_helper" python3 - <<'PY' import base64 import os from pathlib import Path encoded = Path(os.environ["ENCODED_HELPER"]).read_text() Path(os.environ["HELPER"]).write_bytes(base64.b64decode(encoded)) PY fi chmod +x "$helper" echo "DESKTOP_DIAGNOSTICS_SCRIPT=$helper" >> "$GITHUB_ENV" - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: version: "9.15.9" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@42dc69e1aa15d09112580998cf2ef0119e2e91ae # 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 \ xdg-utils \ patchelf command -v xdg-open - name: Install dependencies run: pnpm -C apps install --frozen-lockfile - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # 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: 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 ! bash scripts/release/desktop-signing-ready.sh macos >/dev/null 2>&1; then echo "MACOS_SIGNING_ENABLED=false" >> "$GITHUB_ENV" echo "::notice::macOS signing/notarization inputs are incomplete; building unsigned desktop artifact." exit 0 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" echo "MACOS_SIGNING_ENABLED=true" >> "$GITHUB_ENV" if [ -n "${APPLE_API_KEY:-}" ] && [ -n "${APPLE_API_ISSUER:-}" ] && [ -n "${APPLE_API_KEY_P8:-}" ]; 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-') && env.MACOS_SIGNING_ENABLED == 'true' run: | set -euo pipefail runtime_bin="apps/desktop/src-tauri/resources/kandev/bin" for binary in kandev agentctl agentctl-darwin-arm64 agentctl-darwin-amd64; 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: Detect Windows signing inputs if: matrix.platform == 'windows-x64' env: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} run: | set -euo pipefail if bash scripts/release/desktop-signing-ready.sh windows >/dev/null 2>&1; then echo "WINDOWS_SIGNING_ENABLED=true" >> "$GITHUB_ENV" else echo "WINDOWS_SIGNING_ENABLED=false" >> "$GITHUB_ENV" echo "::notice::Windows signing inputs are incomplete; building unsigned desktop artifact." fi - name: Sign Windows desktop runtime binaries if: matrix.platform == 'windows-x64' && env.WINDOWS_SIGNING_ENABLED == 'true' 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: Detect Tauri updater signing input env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} run: | set -euo pipefail if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then echo "UPDATER_SIGNING_ENABLED=false" >> "$GITHUB_ENV" echo "::notice::Tauri updater signing key is absent; signed updater artifacts and latest.json will be skipped." exit 0 fi helper="$RUNNER_TEMP/updater-signing-ready.sh" gh api \ --header "Accept: application/vnd.github.raw+json" \ "repos/${{ github.repository }}/contents/scripts/release/updater-signing-ready.sh?ref=${{ github.workflow_sha }}" \ > "$helper" if ! bash "$helper" "${{ matrix.platform }}"; then echo "::error::Updater signing prerequisites failed for ${{ matrix.platform }}." >&2 exit 1 fi echo "UPDATER_SIGNING_ENABLED=true" >> "$GITHUB_ENV" - name: Build Tauri desktop app timeout-minutes: 70 env: CI: "true" DESKTOP_DIAGNOSTICS_PLATFORM: ${{ matrix.platform }} DESKTOP_DIAGNOSTICS_RUST_TARGET: ${{ matrix.rust_target }} TAURI_BUNDLE_ATTEMPTS: ${{ startsWith(matrix.platform, 'macos-') && '2' || '1' }} TAURI_BUILD_TIMEOUT_SECONDS: ${{ startsWith(matrix.platform, 'macos-') && '1800' || '0' }} 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 }} 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 }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail if [ "${MACOS_SIGNING_ENABLED:-false}" != "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 fi if [ "${WINDOWS_SIGNING_ENABLED:-false}" != "true" ]; then unset WINDOWS_CERTIFICATE unset WINDOWS_CERTIFICATE_PASSWORD unset WINDOWS_TIMESTAMP_URL unset WINDOWS_SIGNTOOL_PATH fi tauri_bundles="${{ matrix.tauri_bundles }}" updater_config='{"bundle":{"createUpdaterArtifacts":false}}' if [ "${UPDATER_SIGNING_ENABLED:-false}" = "true" ]; then updater_config='{"bundle":{"createUpdaterArtifacts":"v1Compatible"}}' if [[ "${{ matrix.platform }}" == macos-* ]]; then # Tauri derives the macOS updater archive from the app bundle. tauri_bundles="${tauri_bundles},app" fi else unset TAURI_SIGNING_PRIVATE_KEY unset TAURI_SIGNING_PRIVATE_KEY_PASSWORD fi pnpm -C apps --filter @kandev/desktop build:vite cd apps/desktop collect_macos_desktop_diagnostics() { local label="${1:-failure}" if [[ "${{ matrix.platform }}" != macos-* ]]; then return 0 fi bash "${DESKTOP_DIAGNOSTICS_SCRIPT:-$GITHUB_WORKSPACE/.github/scripts/collect-macos-desktop-diagnostics.sh}" "$label" || true } terminate_process_tree() { local pid="$1" terminate_process_tree_with_signal "$pid" TERM sleep 5 terminate_process_tree_with_signal "$pid" KILL } terminate_process_tree_with_signal() { local pid="$1" local signal="$2" local child while IFS= read -r child; do [ -n "$child" ] || continue terminate_process_tree_with_signal "$child" "$signal" done < <(pgrep -P "$pid" 2>/dev/null || true) kill "-$signal" "$pid" 2>/dev/null || true } run_with_timeout() { local timeout_seconds="$1" shift if [ "$timeout_seconds" -le 0 ]; then "$@" return $? fi "$@" & local command_pid=$! local start_seconds start_seconds="$(date +%s)" while kill -0 "$command_pid" 2>/dev/null; do local now_seconds now_seconds="$(date +%s)" if [ $((now_seconds - start_seconds)) -ge "$timeout_seconds" ]; then echo "::warning::Command timed out after ${timeout_seconds}s: $*" collect_macos_desktop_diagnostics "timeout" terminate_process_tree "$command_pid" wait "$command_pid" 2>/dev/null || true return 124 fi sleep 10 done wait "$command_pid" } run_tauri_build() { run_with_timeout "$TAURI_BUILD_TIMEOUT_SECONDS" \ pnpm tauri build \ --features desktop-runtime \ --target "${{ matrix.rust_target }}" \ --bundles "$tauri_bundles" \ --config "$updater_config" } status=0 for attempt in $(seq 1 "$TAURI_BUNDLE_ATTEMPTS"); do echo "Tauri desktop build attempt $attempt/$TAURI_BUNDLE_ATTEMPTS" if run_tauri_build; then status=0 break else status=$? fi echo "::warning::Tauri desktop build attempt $attempt/$TAURI_BUNDLE_ATTEMPTS failed with exit code $status." collect_macos_desktop_diagnostics "attempt-${attempt}" if [ "$attempt" -lt "$TAURI_BUNDLE_ATTEMPTS" ]; then sleep 20 fi done exit "$status" - name: Collect macOS desktop diagnostics if: failure() && startsWith(matrix.platform, 'macos-') env: DESKTOP_DIAGNOSTICS_PLATFORM: ${{ matrix.platform }} DESKTOP_DIAGNOSTICS_RUST_TARGET: ${{ matrix.rust_target }} run: | set -euo pipefail bash "${DESKTOP_DIAGNOSTICS_SCRIPT:-$GITHUB_WORKSPACE/.github/scripts/collect-macos-desktop-diagnostics.sh}" failure || true - name: Upload macOS desktop diagnostics if: failure() && startsWith(matrix.platform, 'macos-') uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: desktop-diagnostics-${{ matrix.platform }} path: | dist/desktop-diagnostics/** apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg/** apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos/** apps/desktop/src-tauri/target/release/bundle/dmg/** apps/desktop/src-tauri/target/release/bundle/macos/** if-no-files-found: ignore include-hidden-files: true - name: Sign Windows desktop artifacts if: matrix.platform == 'windows-x64' && env.WINDOWS_SIGNING_ENABLED == 'true' 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: | $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' -o \ -name '*.app.tar.gz' -o \ -name '*.AppImage.tar.gz' -o \ -name '*.nsis.zip' -o \ -name '*.sig' \ \) -print0) if [ "$found" -eq 0 ]; then echo "No desktop artifacts found under $bundle_dir" >&2 exit 1 fi if [ "${UPDATER_SIGNING_ENABLED:-false}" = "true" ]; then bash "$DESKTOP_ASSET_VERIFIER" --require-updaters \ "$out_dir" "${{ matrix.platform }}" else bash "$DESKTOP_ASSET_VERIFIER" "$out_dir" "${{ matrix.platform }}" fi ls -lh "$out_dir" - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ needs.prepare.outputs.ref }} - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # 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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # 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@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ghcr.io/kdlbs/kandev - name: Build and push (amd64 staging tag) id: build uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ needs.prepare.outputs.ref }} - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # 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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker metadata (labels) id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ghcr.io/kdlbs/kandev - name: Build and push (arm64 staging tag) id: build uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # 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@c299e40c65443455700f0fdfc63efafe5b349051 # 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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ needs.prepare.outputs.ref }} - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker metadata (labels) id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ghcr.io/kdlbs/kandev - name: Build and push (universal amd64 staging tag) id: build uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ needs.prepare.outputs.ref }} - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker metadata (labels) id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ghcr.io/kdlbs/kandev - name: Build and push (universal arm64 staging tag) id: build uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # 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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ needs.prepare.outputs.ref }} fetch-depth: 0 - name: Prepare desktop asset verifier env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail helper="$RUNNER_TEMP/verify-desktop-assets.sh" gh api \ --header "Accept: application/vnd.github.raw+json" \ "repos/${{ github.repository }}/contents/scripts/release/verify-desktop-assets.sh?ref=${{ github.workflow_sha }}" \ > "$helper" echo "DESKTOP_ASSET_VERIFIER=$helper" >> "$GITHUB_ENV" - name: Install updater signature verifier toolchain run: rustup toolchain install 1.88.0 --profile minimal - name: Generate release notes uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # 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 env: 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_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} run: | set -euo pipefail unsigned=() if ! bash scripts/release/desktop-signing-ready.sh macos >/dev/null 2>&1; then unsigned+=("macOS") fi if ! bash scripts/release/desktop-signing-ready.sh windows >/dev/null 2>&1; then unsigned+=("Windows") fi if [ "${#unsigned[@]}" -eq 0 ]; then exit 0 fi platforms="${unsigned[0]}" for ((i = 1; i < ${#unsigned[@]}; i++)); do platforms="$platforms, ${unsigned[$i]}" done tmp="$(mktemp)" { echo "> [!WARNING]" echo "> $platforms desktop installers in this release are unsigned development builds." 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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 with: pattern: bundle-* path: dist/release-assets merge-multiple: true - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # 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 bash "$DESKTOP_ASSET_VERIFIER" dist/release-assets ls -lh dist/release-assets - name: Generate and verify updater manifest env: RUSTUP_TOOLCHAIN: 1.88.0 run: | set -euo pipefail pub_date="$(git show -s --format=%cI '${{ needs.prepare.outputs.tag }}^{commit}')" node scripts/release/updater-manifest.mjs generate \ --assets-dir dist/release-assets \ --output dist/release-assets/latest.json \ --version "${{ needs.prepare.outputs.version }}" \ --tag "${{ needs.prepare.outputs.tag }}" \ --repository "${{ github.repository }}" \ --notes-file RELEASE_NOTES.md \ --pub-date "$pub_date" \ --allow-unsigned if [ -f dist/release-assets/latest.json ]; then node scripts/release/updater-manifest.mjs verify \ --assets-dir dist/release-assets \ --manifest dist/release-assets/latest.json \ --version "${{ needs.prepare.outputs.version }}" \ --tag "${{ needs.prepare.outputs.tag }}" \ --repository "${{ github.repository }}" fi - name: Publish release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # 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-* dist/release-assets/latest.json 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ needs.prepare.outputs.ref }} - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: version: "9.15.9" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ needs.prepare.outputs.ref }} - 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 }}"