diff --git a/.github/actions/setup-build/action.yml b/.github/actions/setup-build/action.yml
new file mode 100644
index 00000000..03b32be2
--- /dev/null
+++ b/.github/actions/setup-build/action.yml
@@ -0,0 +1,22 @@
+name: setup-build
+description: Pin uv (and optionally Node 20) for kbagent build/release jobs.
+inputs:
+ node:
+ description: Install Node 20 (needed for the React SPA build hook).
+ default: "true"
+runs:
+ using: composite
+ steps:
+ - uses: astral-sh/setup-uv@v7
+ with:
+ version: "0.11.16"
+ # Pin the interpreter explicitly (matches ci.yml / release.yml). Without it a job
+ # could run on whatever Python the runner ships, which may not satisfy >=3.12.
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ - if: ${{ inputs.node == 'true' }}
+ uses: actions/setup-node@v6
+ with:
+ node-version: "20"
+ package-manager-cache: false
diff --git a/.github/workflows/release-kbagent.yml b/.github/workflows/release-kbagent.yml
new file mode 100644
index 00000000..6c5abd66
--- /dev/null
+++ b/.github/workflows/release-kbagent.yml
@@ -0,0 +1,400 @@
+# Release & distribution pipeline for kbagent — see docs/adr/0003-release-distribution-cicd.md
+#
+# Ships a SELF-CONTAINED NATIVE BINARY (no Python / no uv at the user's end), packaged
+# for brew / apt / dnf / apk / chocolatey / winget — the same UX the legacy Go `kbc`
+# delivered. The Python app is frozen with PyInstaller (proven: `env -i kbagent
+# --version` works), then packaged with nfpm (deb/rpm/apk) and wrapped for Homebrew /
+# Chocolatey / WinGet. GoReleaser is NOT used (it builds Go); nothing depends on the
+# deprecated keboola-as-code repo.
+#
+# Steps are REAL, not stubbed. Jobs that push to external repos/S3 run under the
+# protected `release` environment and need its secrets; the freeze + nfpm + GitHub
+# Release jobs produce installable artifacts on their own.
+name: release-kbagent
+
+on:
+ push:
+ tags: ["v*.*.*"]
+ # Manual dispatch is a DRY RUN only: it freezes + signs + packages so the build and
+ # the signing creds get exercised, but every external-publish job (PyPI, S3 repo,
+ # Homebrew, Chocolatey, WinGet, GitHub Release) is gated on `refs/tags/*`, so a
+ # dispatch from an arbitrary ref can never ship an untagged build. Real publishing
+ # happens only on a pushed `v*.*.*` tag.
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Version to build for a DRY RUN (PEP 440, e.g. 0.43.0b1 / 0.58.0). Freezes + signs + packages only; external publishing happens exclusively on a pushed v*.*.* tag."
+ required: true
+
+env:
+ PKG_NAME: keboola-cli2
+ BIN_NAME: kbagent
+ BASE_URL: https://cli-dist.keboola.com
+ S3_PREFIX: keboola-cli2
+ AWS_BUCKET_NAME: cli-dist-keboola-com
+ AWS_REGION: us-east-1
+ # Pin the interpreter uv uses across all jobs for reproducible release builds
+ # (project requires-python >=3.12; CI gates on 3.12).
+ UV_PYTHON: "3.12"
+ # Apple notarization identifiers — public (not secret); kept here so rotating the
+ # account/team is a one-line change rather than editing the freeze step's env. The
+ # macOS sign step inherits these; the actual secrets stay in that step.
+ APPLE_ACCOUNT_USERNAME: apple@keboola.com
+ APPLE_TEAM_ID: "46P6KJ65M2"
+
+# Least-privilege default: every job is read-only unless it overrides this with the
+# narrower scope it actually needs (see the per-job `permissions:` below). Only three
+# jobs get an elevated token — pypi + publish-s3 (id-token for OIDC) and github-release
+# (contents: write to create the Release). version/gate/freeze/package-linux/homebrew/
+# chocolatey/winget/test-install all stay read-only.
+permissions:
+ contents: read
+
+jobs:
+ version:
+ runs-on: ubuntu-latest
+ outputs:
+ VERSION: ${{ steps.v.outputs.VERSION }}
+ # "true" for a pre-release tag (has a -suffix, e.g. v0.0.1-dev.1). Pre-releases
+ # build + package + sign + GitHub pre-release ONLY; all external publishing
+ # (PyPI, S3 repo index, Homebrew, Chocolatey, WinGet) is skipped.
+ IS_PRERELEASE: ${{ steps.v.outputs.IS_PRERELEASE }}
+ steps:
+ - id: v
+ env:
+ REF: ${{ github.ref_name }}
+ INPUT: ${{ github.event.inputs.version }}
+ run: |
+ RAW="${INPUT:-$REF}"
+ # PEP 440 (the repo's convention, see CONTRIBUTING.md): X.Y.Z, X.Y.Z{a,b,rc}N,
+ # X.Y.Z.devN/.postN — plus the SemVer -suffix.N form for dev tags.
+ VERSION=$(printf '%s' "$RAW" | sed -n -E 's:^v?([0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+|\.dev[0-9]+|\.post[0-9]+|-[a-z]+\.[0-9]+)?)$:\1:p')
+ [ -z "$VERSION" ] && { echo "not a PEP 440 / semver version: $RAW"; exit 1; }
+ echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
+ # Pre-release = any a/b/rc/.dev/-suffix marker (.post is a real release).
+ if printf '%s' "$VERSION" | grep -Eq '(a|b|rc)[0-9]+$|\.dev[0-9]+$|-'; then PRE=true; else PRE=false; fi
+ echo "IS_PRERELEASE=$PRE" >> "$GITHUB_OUTPUT"
+ echo "version=$VERSION prerelease=$PRE"
+
+ # Reuse the per-PR gates against the tagged commit, plus changelog-check.
+ gate:
+ needs: [version]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ # Fail before any publish if the tag/input version drifts from pyproject.toml —
+ # otherwise we'd ship artifacts labelled vX.Y.Z that self-report a different
+ # version, and only test-install (which greps for the tag) would catch it later.
+ - name: Tag matches pyproject version
+ env:
+ VERSION: ${{ needs.version.outputs.VERSION }}
+ run: |
+ PY=$(sed -n -E 's/^version = "([^"]+)"/\1/p' pyproject.toml | head -1)
+ [ "$PY" = "$VERSION" ] || { echo "::error::tag v$VERSION does not match pyproject version $PY"; exit 1; }
+ - uses: astral-sh/setup-uv@v7
+ with: { version: "0.11.16" }
+ - uses: actions/setup-python@v6
+ with: { python-version: "3.12" }
+ - run: uv sync --extra server
+ - run: uv run ruff check src/ tests/ scripts/
+ - run: uv run ruff format . --check
+ - run: uv run ty check
+ - run: uv run pytest tests/ -m "not integration and not e2e" -q
+ # Same silent-drift gates ci.yml enforces, so a tag pushed directly can't bypass them.
+ - run: uv run python scripts/check_command_sync.py
+ - run: uv run python scripts/check_error_codes.py
+ - name: SKILL.md + version drift
+ run: |
+ uv run python scripts/generate_skill.py > /dev/null 2>&1
+ uv run python scripts/sync_version.py > /dev/null 2>&1
+ git diff --exit-code plugins/kbagent/skills/kbagent/SKILL.md plugins/kbagent/.claude-plugin/plugin.json .claude-plugin/marketplace.json uv.lock
+ - name: changelog-check (skipped on pre-releases)
+ if: needs.version.outputs.IS_PRERELEASE == 'false'
+ run: uv run python scripts/generate_changelog.py --check
+
+ # ── Build the prebuilt wheel and publish to PyPI (for the `uv`/`pipx` audience).
+ # This is ADDITIONAL to the native binary, not the primary install path.
+ pypi:
+ needs: [version, gate]
+ if: needs.version.outputs.IS_PRERELEASE == 'false' && startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ environment: release
+ permissions:
+ contents: read # actions/checkout
+ id-token: write # PyPI Trusted Publishing (OIDC)
+ steps:
+ - uses: actions/checkout@v5
+ - uses: ./.github/actions/setup-build
+ - run: uv build
+ - run: uv run python scripts/check_wheel_ui.py --expect-ui
+ - uses: pypa/gh-action-pypi-publish@release/v1
+
+ # ── Freeze a self-contained native binary per OS/arch (PyInstaller).
+ freeze:
+ needs: [version, gate]
+ strategy:
+ fail-fast: false
+ matrix:
+ # `platform` is the OS name baked into artifact/zip filenames (darwin/linux/
+ # windows) — Homebrew/Chocolatey/WinGet URLs depend on these exact values.
+ include:
+ - { os: ubuntu-latest, platform: linux, arch: amd64 }
+ - { os: ubuntu-24.04-arm, platform: linux, arch: arm64 }
+ # Single macOS environment (Apple Silicon). PyInstaller can't cross-compile,
+ # so there's no Intel leg; Intel Macs use `uv tool install`. Re-add a
+ # `{ os: macos-13, platform: darwin, arch: amd64 }` entry if native Intel is needed.
+ - { os: macos-latest, platform: darwin, arch: arm64 }
+ - { os: windows-latest, platform: windows, arch: amd64 }
+ runs-on: ${{ matrix.os }}
+ environment: release # so APPLE_*/WINDOWS_* signing secrets resolve
+ env:
+ VERSION: ${{ needs.version.outputs.VERSION }}
+ steps:
+ - uses: actions/checkout@v5
+ - uses: ./.github/actions/setup-build
+
+ # Populate src/keboola_agent_cli/_ui_dist via the hatch build hook (needs Node),
+ # so `kbagent serve --ui` works from the frozen binary. `uv build` runs the hook.
+ - name: Build SPA into the package (for serve --ui)
+ run: uv build --wheel
+
+ - name: Freeze with PyInstaller
+ shell: bash
+ run: |
+ uv run --with "pyinstaller==6.11.1" pyinstaller --onefile \
+ --name "$BIN_NAME" \
+ --collect-all keboola_agent_cli \
+ --distpath dist \
+ build/package/entry.py
+
+ # macOS: sign + notarize. On a pre-release tag this runs as a NON-FATAL
+ # signature check (continue-on-error) so a dev tag verifies the Apple creds
+ # without blocking the build; on a real tag it is required.
+ - name: Sign & notarize (macOS)
+ if: ${{ matrix.platform == 'darwin' }}
+ continue-on-error: ${{ needs.version.outputs.IS_PRERELEASE == 'true' }}
+ env:
+ APPLE_DEVELOPER_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
+ APPLE_DEVELOPER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
+ APPLE_ACCOUNT_PASSWORD: ${{ secrets.APPLE_ACCOUNT_PASSWORD }}
+ # APPLE_ACCOUNT_USERNAME / APPLE_TEAM_ID inherited from the workflow-level env.
+ run: bash build/package/macos/sign_notarize.sh "dist/$BIN_NAME"
+
+ # Windows: Authenticode sign the .exe. Non-fatal on pre-release (signature
+ # check only); required on a real tag.
+ - name: Sign (Windows, Azure Key Vault)
+ if: ${{ matrix.platform == 'windows' }}
+ continue-on-error: ${{ needs.version.outputs.IS_PRERELEASE == 'true' }}
+ shell: bash
+ env:
+ WINDOWS_SIGNING_TENANT_ID: ${{ secrets.WINDOWS_SIGNING_TENANT_ID }}
+ WINDOWS_SIGNING_CLIENT_ID: ${{ secrets.WINDOWS_SIGNING_CLIENT_ID }}
+ WINDOWS_SIGNING_CLIENT_SECRET: ${{ secrets.WINDOWS_SIGNING_CLIENT_SECRET }}
+ run: bash build/package/windows/sign.sh "dist/${BIN_NAME}.exe"
+
+ - name: Zip the binary
+ shell: bash
+ run: bash build/package/zip_binary.sh "$PKG_NAME" "$VERSION" "${{ matrix.platform }}" "${{ matrix.arch }}" "$BIN_NAME"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: bin-${{ matrix.platform }}-${{ matrix.arch }}
+ path: |
+ dist/*.zip
+ dist/*.sha256
+ dist/${{ env.BIN_NAME }}*
+
+ # ── Package the Linux binaries into deb/rpm/apk with nfpm (language-agnostic).
+ package-linux:
+ needs: [version, freeze]
+ runs-on: ubuntu-latest
+ environment: release # so DEB/RPM/APK signing keys resolve
+ env:
+ VERSION: ${{ needs.version.outputs.VERSION }}
+ steps:
+ - uses: actions/checkout@v5
+ - name: Install nfpm
+ run: |
+ curl -fsSL "https://github.com/goreleaser/nfpm/releases/download/v2.41.1/nfpm_2.41.1_amd64.deb" -o /tmp/nfpm.deb
+ echo "0a188f8bcf4ba4ba6414a514bc1f1d59f5cc92ba86160462d7aa7dab5930d327 /tmp/nfpm.deb" | sha256sum -c -
+ sudo dpkg -i /tmp/nfpm.deb
+ - uses: actions/download-artifact@v4
+ with: { pattern: bin-linux-*, path: artifacts }
+ - name: Write package signing keys
+ env:
+ # DEB/RPM are signed with GPG keys; APK with an abuild RSA key.
+ DEB_KEY_PRIVATE: ${{ secrets.DEB_KEY_PRIVATE }}
+ RPM_KEY_PRIVATE: ${{ secrets.RPM_KEY_PRIVATE }}
+ APK_KEY_PRIVATE: ${{ secrets.APK_KEY_PRIVATE }}
+ run: |
+ mkdir -p /tmp/keys
+ for k in DEB_KEY_PRIVATE RPM_KEY_PRIVATE APK_KEY_PRIVATE; do
+ [ -n "${!k}" ] || { echo "::error::$k is empty — refusing to build unsigned/garbage-signed packages"; exit 1; }
+ done
+ printf '%s' "$DEB_KEY_PRIVATE" > /tmp/keys/deb.key && chmod 600 /tmp/keys/deb.key
+ printf '%s' "$RPM_KEY_PRIVATE" > /tmp/keys/rpm.key && chmod 600 /tmp/keys/rpm.key
+ printf '%s' "$APK_KEY_PRIVATE" > /tmp/keys/apk.key && chmod 600 /tmp/keys/apk.key
+ - name: Build deb/rpm/apk for each arch
+ run: bash build/package/linux/build_packages.sh "$VERSION" artifacts
+ - uses: actions/upload-artifact@v4
+ with: { name: linux-packages, path: dist/* }
+
+ # ── Create the GitHub Release with every binary + package attached (directly
+ # installable: `dpkg -i keboola-cli2_*.deb`, unzip the binary, etc.).
+ github-release:
+ needs: [version, freeze, package-linux]
+ # Only on a real tag push. A workflow_dispatch run has no tag, so creating a
+ # GitHub Release for v${VERSION} would fail or fabricate an unexpected release.
+ if: startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # softprops/action-gh-release creates the GitHub Release
+ env:
+ VERSION: ${{ needs.version.outputs.VERSION }}
+ steps:
+ - uses: actions/download-artifact@v4
+ with: { path: artifacts }
+ - name: Collect release assets
+ run: |
+ mkdir -p release
+ find artifacts -type f \( -name '*.zip' -o -name '*.sha256' -o -name '*.deb' -o -name '*.rpm' -o -name '*.apk' \) -exec cp {} release/ \;
+ ls -al release/
+ - uses: softprops/action-gh-release@v2
+ with:
+ tag_name: v${{ env.VERSION }}
+ files: release/*
+ prerelease: ${{ needs.version.outputs.IS_PRERELEASE == 'true' }}
+
+ # ── Publish to S3 (cli-dist.keboola.com/keboola-cli2/) and index the apt/rpm/apk
+ # repos so `apt-get install keboola-cli2` works. Real; needs AWS + GPG secrets.
+ publish-s3:
+ needs: [version, freeze, package-linux]
+ if: needs.version.outputs.IS_PRERELEASE == 'false' && startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ environment: release
+ permissions:
+ contents: read # actions/checkout
+ id-token: write # AWS OIDC (configure-aws-credentials assumes the role)
+ env:
+ VERSION: ${{ needs.version.outputs.VERSION }}
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/download-artifact@v4
+ with: { path: artifacts }
+ - uses: aws-actions/configure-aws-credentials@v6
+ with:
+ role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
+ aws-region: ${{ env.AWS_REGION }}
+ - name: Upload versioned assets
+ run: |
+ mkdir -p up && find artifacts -type f \( -name '*.zip' -o -name '*.deb' -o -name '*.rpm' -o -name '*.apk' -o -name '*.sha256' \) -exec cp {} up/ \;
+ aws s3 sync up/ "s3://${AWS_BUCKET_NAME}/${S3_PREFIX}/v${VERSION}/"
+ - name: Index apt/rpm/apk repos (signed)
+ env:
+ # No DEB_KEY_PUBLIC: the apt keyring is derived (dearmored) from DEB_KEY_PRIVATE.
+ DEB_KEY_PRIVATE: ${{ secrets.DEB_KEY_PRIVATE }}
+ RPM_KEY_PUBLIC: ${{ secrets.RPM_KEY_PUBLIC }}
+ APK_KEY_PRIVATE: ${{ secrets.APK_KEY_PRIVATE }}
+ APK_KEY_PUBLIC: ${{ secrets.APK_KEY_PUBLIC }}
+ run: bash build/package/linux/index.sh "${AWS_BUCKET_NAME}" "${S3_PREFIX}"
+
+ # ── Homebrew: render the formula from the template and push to the kbagent-owned tap.
+ homebrew:
+ needs: [version, freeze, publish-s3]
+ if: needs.version.outputs.IS_PRERELEASE == 'false' && startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ environment: release
+ env:
+ VERSION: ${{ needs.version.outputs.VERSION }}
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/download-artifact@v4
+ with: { path: artifacts }
+ - name: Render formula
+ run: |
+ mkdir -p out/Formula
+ bash build/package/homebrew/render.sh "$VERSION" artifacts > out/Formula/keboola-cli2.rb
+ - name: Push to tap keboola/homebrew-keboola-cli2
+ # SHA-pinned (not a mutable tag): this action receives HOMEBREW_TAP_TOKEN, a
+ # write-scoped PAT to the tap repo, so a re-pointed tag would be a supply-chain
+ # foothold. Bump the SHA and the trailing version comment together on upgrade.
+ uses: cpina/github-action-push-to-another-repository@07c4d7b3def0a8ebe788a8f2c843a4e1de4f6900 # v1.7.2
+ env:
+ API_TOKEN_GITHUB: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+ with:
+ source-directory: "out"
+ target-directory: "."
+ destination-github-username: keboola
+ destination-repository-name: homebrew-keboola-cli2
+ target-branch: main
+
+ # ── Chocolatey: wrap the signed Windows .exe and push.
+ chocolatey:
+ needs: [version, freeze, publish-s3]
+ if: needs.version.outputs.IS_PRERELEASE == 'false' && startsWith(github.ref, 'refs/tags/')
+ runs-on: windows-latest
+ environment: release
+ env:
+ VERSION: ${{ needs.version.outputs.VERSION }}
+ steps:
+ - uses: actions/checkout@v5
+ - name: Pack & push
+ shell: pwsh
+ env:
+ CHOCOLATEY_KEY: ${{ secrets.CHOCOLATEY_KEY }}
+ run: |
+ $url = "$env:BASE_URL/$env:S3_PREFIX/v$env:VERSION/keboola-cli2_${env:VERSION}_windows_amd64.zip"
+ $checksum = (Invoke-WebRequest "$url.sha256").Content.Split(" ")[0]
+ $push = "build/package/chocolatey"
+ (Get-Content "$push/keboola-cli2.nuspec" -Raw).Replace('{VERSION}', $env:VERSION) | Set-Content "$push/keboola-cli2.nuspec"
+ (Get-Content "$push/tools/chocolateyinstall.ps1" -Raw).Replace('{URL}', $url).Replace('{CHECKSUM}', $checksum) | Set-Content "$push/tools/chocolateyinstall.ps1"
+ cd $push
+ choco pack keboola-cli2.nuspec
+ choco apikey -k $env:CHOCOLATEY_KEY -s https://push.chocolatey.org/
+ choco push "keboola-cli2.$env:VERSION.nupkg" -s https://push.chocolatey.org
+
+ # ── WinGet: submit a PR to microsoft/winget-pkgs via wingetcreate.
+ winget:
+ needs: [version, chocolatey, publish-s3]
+ if: needs.version.outputs.IS_PRERELEASE == 'false' && startsWith(github.ref, 'refs/tags/')
+ runs-on: windows-latest
+ environment: release
+ env:
+ VERSION: ${{ needs.version.outputs.VERSION }}
+ steps:
+ - name: Submit to winget-pkgs
+ shell: bash
+ env:
+ WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
+ run: |
+ url="${BASE_URL}/${S3_PREFIX}/v${VERSION}/keboola-cli2_${VERSION}_windows_amd64.zip"
+ curl -fLSs https://aka.ms/wingetcreate/latest -o wingetcreate.exe
+ # Verify it's validly Authenticode-signed by Microsoft before executing.
+ powershell -Command '$s = Get-AuthenticodeSignature wingetcreate.exe; if ($s.Status -ne "Valid" -or $s.SignerCertificate.Subject -notmatch "Microsoft") { Write-Error "wingetcreate.exe signature not valid/Microsoft: $($s.Status)"; exit 1 }'
+ ./wingetcreate.exe update -v "$VERSION" -u "$url" -t "$WINGET_TOKEN" Keboola.KeboolaCLI2 -s
+
+ # ── Smoke-test the real install paths end to end.
+ test-install:
+ needs: [version, publish-s3, homebrew]
+ runs-on: ubuntu-latest
+ env:
+ VERSION: ${{ needs.version.outputs.VERSION }}
+ steps:
+ - name: apt (Ubuntu)
+ run: |
+ docker run --rm ubuntu bash -c '
+ set -eo pipefail
+ apt-get update -y && apt-get install -y wget ca-certificates gnupg
+ wget -P /etc/apt/trusted.gpg.d https://cli-dist.keboola.com/${{ env.S3_PREFIX }}/deb/keboola.gpg
+ echo "deb https://cli-dist.keboola.com/${{ env.S3_PREFIX }}/deb /" > /etc/apt/sources.list.d/keboola.list
+ apt-get update && apt-get install -y keboola-cli2
+ kbagent --version | grep -q "${{ env.VERSION }}"
+ '
+ - name: Homebrew (Linux)
+ run: |
+ docker run --rm homebrew/brew bash -c '
+ set -e
+ brew tap keboola/keboola-cli2 https://github.com/keboola/homebrew-keboola-cli2
+ brew install keboola-cli2
+ kbagent --version | grep -q "${{ env.VERSION }}"
+ '
diff --git a/.gitignore b/.gitignore
index fe302cea..05a6d162 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,7 +6,10 @@ __pycache__/
# Distribution / packaging
dist/
-build/
+# Ignore build artifacts, but NOT build/package/ (release packaging config = source).
+# Must be `build/*` (not `build/`) so the re-include below can take effect.
+build/*
+!build/package/
*.egg-info/
*.egg
diff --git a/build/package/chocolatey/keboola-cli2.nuspec b/build/package/chocolatey/keboola-cli2.nuspec
new file mode 100644
index 00000000..f8e29dfb
--- /dev/null
+++ b/build/package/chocolatey/keboola-cli2.nuspec
@@ -0,0 +1,21 @@
+
+
+
+
+ keboola-cli2
+ {VERSION}
+ Keboola Agent CLI (kbagent)
+ Keboola
+ https://github.com/keboola/cli
+ https://github.com/keboola/cli/blob/main/LICENSE
+ false
+ AI-friendly CLI for managing Keboola projects.
+ Self-contained native CLI for managing Keboola Connection projects. No Python runtime required.
+ keboola cli kbagent
+
+
+
+
+
diff --git a/build/package/chocolatey/tools/chocolateyinstall.ps1 b/build/package/chocolatey/tools/chocolateyinstall.ps1
new file mode 100644
index 00000000..3e817778
--- /dev/null
+++ b/build/package/chocolatey/tools/chocolateyinstall.ps1
@@ -0,0 +1,16 @@
+# Chocolatey install script for kbagent. Downloads the signed Windows .exe zip from
+# cli-dist.keboola.com and shims `kbagent` onto PATH. {URL} and {CHECKSUM} are
+# substituted by the release workflow. No Python runtime required.
+$ErrorActionPreference = 'Stop'
+$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
+
+$packageArgs = @{
+ packageName = 'keboola-cli2'
+ unzipLocation = $toolsDir
+ url64bit = '{URL}'
+ checksum64 = '{CHECKSUM}'
+ checksumType64= 'sha256'
+}
+
+Install-ChocolateyZipPackage @packageArgs
+# The extracted kbagent.exe in $toolsDir is auto-shimmed onto PATH by Chocolatey.
diff --git a/build/package/entry.py b/build/package/entry.py
new file mode 100644
index 00000000..ff7fb874
--- /dev/null
+++ b/build/package/entry.py
@@ -0,0 +1,14 @@
+"""PyInstaller entry point for the frozen `kbagent` binary.
+
+PyInstaller runs the entry script as top-level `__main__`, so the package's own
+``src/keboola_agent_cli/__main__.py`` (which uses a relative import
+``from .cli import app``) cannot be used directly — it raises
+``ImportError: attempted relative import with no known parent package``.
+
+This launcher uses an absolute import instead. Verified to produce a working
+no-Python binary (`env -i kbagent --version` → `kbagent vX.Y.Z`).
+"""
+
+from keboola_agent_cli.cli import app
+
+app()
diff --git a/build/package/homebrew/keboola-cli2.rb.tmpl b/build/package/homebrew/keboola-cli2.rb.tmpl
new file mode 100644
index 00000000..3281af78
--- /dev/null
+++ b/build/package/homebrew/keboola-cli2.rb.tmpl
@@ -0,0 +1,41 @@
+# Homebrew formula template for kbagent (package: keboola-cli2, binary: kbagent).
+# The release workflow substitutes {VERSION} and the per-arch {SHA256_*} and pushes
+# the rendered formula to the kbagent-owned tap repo `keboola/homebrew-keboola-cli2`.
+# Wraps the prebuilt PyInstaller binary — no Python required on the user's machine.
+class KeboolaCli2 < Formula
+ desc "AI-friendly CLI for managing Keboola projects (kbagent)"
+ homepage "https://github.com/keboola/cli"
+ version "{VERSION}"
+ license "MIT"
+
+ on_macos do
+ # Apple Silicon only (single macOS build env). Gate on arch so Intel Macs get a
+ # clear error instead of a broken arm64 binary.
+ on_arm do
+ url "https://cli-dist.keboola.com/keboola-cli2/v{VERSION}/keboola-cli2_{VERSION}_darwin_arm64.zip"
+ sha256 "{SHA256_DARWIN_ARM64}"
+ end
+ on_intel do
+ odie "keboola-cli2 ships Apple Silicon only on macOS. Install via: uv tool install keboola-cli"
+ end
+ end
+
+ on_linux do
+ on_arm do
+ url "https://cli-dist.keboola.com/keboola-cli2/v{VERSION}/keboola-cli2_{VERSION}_linux_arm64.zip"
+ sha256 "{SHA256_LINUX_ARM64}"
+ end
+ on_intel do
+ url "https://cli-dist.keboola.com/keboola-cli2/v{VERSION}/keboola-cli2_{VERSION}_linux_amd64.zip"
+ sha256 "{SHA256_LINUX_AMD64}"
+ end
+ end
+
+ def install
+ bin.install "kbagent"
+ end
+
+ test do
+ assert_match "kbagent v#{version}", shell_output("#{bin}/kbagent --version")
+ end
+end
diff --git a/build/package/homebrew/render.sh b/build/package/homebrew/render.sh
new file mode 100755
index 00000000..fa44a5d5
--- /dev/null
+++ b/build/package/homebrew/render.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Render the Homebrew formula from the template, substituting the version and the
+# per-arch SHA256 sums (read from the *.sha256 sidecar files produced by `freeze`).
+# Usage: render.sh (prints the formula to stdout)
+set -euo pipefail
+VERSION="$1"
+ART="$2"
+TMPL="$(dirname "$0")/keboola-cli2.rb.tmpl"
+
+sha() {
+ # $1 = os, $2 = arch -> sha256 of keboola-cli2___.zip.
+ # Fail hard if the sidecar is missing — never render a formula with a bad checksum.
+ local f
+ f=$(find "$ART" -name "keboola-cli2_${VERSION}_$1_$2.zip.sha256" | head -1)
+ [ -n "$f" ] || { echo "::error::missing checksum for $1_$2 — refusing to render formula" >&2; exit 1; }
+ awk '{print $1}' "$f"
+}
+
+# Only the arches the template references (macOS arm64; Linux amd64 + arm64).
+sed \
+ -e "s/{VERSION}/${VERSION}/g" \
+ -e "s/{SHA256_DARWIN_ARM64}/$(sha darwin arm64)/g" \
+ -e "s/{SHA256_LINUX_ARM64}/$(sha linux arm64)/g" \
+ -e "s/{SHA256_LINUX_AMD64}/$(sha linux amd64)/g" \
+ "$TMPL"
diff --git a/build/package/linux/build_packages.sh b/build/package/linux/build_packages.sh
new file mode 100755
index 00000000..a6404df6
--- /dev/null
+++ b/build/package/linux/build_packages.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# Build deb/rpm/apk for each Linux arch from the frozen binaries, using nfpm.
+# nfpm does not reliably expand ${...} in its config, so we render it with envsubst.
+# Usage: build_packages.sh [artifacts-dir]
+set -euo pipefail
+VERSION="$1"
+ART="${2:-artifacts}"
+
+command -v envsubst >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y gettext-base; }
+mkdir -p dist
+
+for arch in amd64 arm64; do
+ BIN="$ART/bin-linux-${arch}/kbagent"
+ [ -f "$BIN" ] || BIN="$ART/bin-linux-${arch}/dist/kbagent" # tolerate either download layout
+ [ -f "$BIN" ] || { echo "missing kbagent for $arch"; ls -R "$ART/bin-linux-${arch}"; exit 1; }
+ chmod +x "$BIN"
+
+ export VERSION PKG_ARCH="$arch" BIN_PATH="$BIN"
+ envsubst '${VERSION} ${PKG_ARCH} ${BIN_PATH}' < build/package/nfpm.yaml > /tmp/nfpm.yaml
+ for fmt in deb rpm apk; do
+ nfpm package -f /tmp/nfpm.yaml -p "$fmt" -t "dist/keboola-cli2_${VERSION}_linux_${arch}.${fmt}"
+ done
+done
+ls -al dist/
diff --git a/build/package/linux/index.sh b/build/package/linux/index.sh
new file mode 100755
index 00000000..fa472d92
--- /dev/null
+++ b/build/package/linux/index.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# Build/refresh the SIGNED apt(deb), yum(rpm) and apk repositories on the CLI dist
+# S3 bucket so `apt-get install keboola-cli2` (etc.) works out of the box.
+#
+# Signing keys (separate per format):
+# DEB_KEY_PRIVATE — GPG, signs the apt repo. The public keyring apt downloads is
+# derived from it (dearmored binary), so there's no DEB_KEY_PUBLIC.
+# RPM_KEY_PUBLIC — public half of the SEPARATE rpm signing key (nfpm signs the rpm
+# packages with RPM_KEY_PRIVATE); published for yum clients.
+# APK_KEY_PRIVATE / APK_KEY_PUBLIC — abuild RSA keypair, signs the apk index
+# Requires AWS creds already configured (OIDC).
+# Usage: index.sh → s3:////{deb,rpm,apk}/
+set -euo pipefail
+BUCKET="$1"
+PREFIX="$2"
+WORK=$(mktemp -d)
+trap 'rm -rf "$WORK"' EXIT # clean up temp dir + any key material on exit/failure
+
+if [ -z "${DEB_KEY_PRIVATE:-}" ]; then
+ # publish-s3 only runs on real (non-pre-release) tags, where the repo + key must
+ # exist for the downstream test-install job. Fail loudly rather than silently
+ # skipping and leaving test-install to fail with an obscure root cause.
+ echo "::error::DEB_KEY_PRIVATE not set — cannot sign/index the apt repo for a real release."
+ exit 1
+fi
+
+sudo apt-get update -y
+# apk-tools + abuild are needed for the apk repo index; tolerate their absence.
+sudo apt-get install -y dpkg-dev apt-utils createrepo-c gnupg apk-tools abuild || \
+ sudo apt-get install -y dpkg-dev apt-utils createrepo-c gnupg
+
+# Import GPG signing key (deb + rpm metadata).
+printf '%s' "$DEB_KEY_PRIVATE" | gpg --batch --import
+KEYID=$(gpg --list-secret-keys --with-colons | awk -F: '/^sec/{print $5; exit}')
+
+# publish_repo
+# Common pull-existing → add-new → index → publish flow; the per-format index
+# command is the only thing that differs (passed as a function name).
+publish_repo() {
+ local fmt="$1" indexer="$2" pub_file="$3" pub_content="$4"
+ local dir="$WORK/$fmt"
+ mkdir -p "$dir"
+ aws s3 sync "s3://$BUCKET/$PREFIX/$fmt/" "$dir/" --exclude '*' --include "*.$fmt" || true
+ find . -path ./.git -prune -o -name "*.$fmt" -exec cp {} "$dir/" \;
+ ( cd "$dir" && "$indexer" )
+ [ -n "$pub_content" ] && printf '%s' "$pub_content" > "$dir/$pub_file"
+ aws s3 sync "$dir/" "s3://$BUCKET/$PREFIX/$fmt/"
+}
+
+index_deb() {
+ dpkg-scanpackages . /dev/null > Packages && gzip -kf Packages
+ apt-ftparchive release . > Release
+ gpg --batch --yes --default-key "$KEYID" -abs -o Release.gpg Release
+ gpg --batch --yes --default-key "$KEYID" --clearsign -o InRelease Release
+ # Publish the DEARMORED (binary) keyring — apt's /etc/apt/trusted.gpg.d expects a
+ # binary keyring, not an ASCII-armored block, so `gpg --export` WITHOUT --armor.
+ gpg --export "$KEYID" > keboola.gpg
+}
+index_rpm() { createrepo_c .; }
+index_apk() {
+ printf '%s' "$APK_KEY_PRIVATE" > "$WORK/apk_index.rsa" && chmod 600 "$WORK/apk_index.rsa"
+ apk index -o APKINDEX.tar.gz ./*.apk
+ abuild-sign -k "$WORK/apk_index.rsa" APKINDEX.tar.gz
+}
+
+# deb: index_deb writes its own (dearmored) keboola.gpg, so pass no pub-key content.
+publish_repo deb index_deb "" ""
+publish_repo rpm index_rpm keboola.gpg "${RPM_KEY_PUBLIC:-}"
+if [ -z "${APK_KEY_PRIVATE:-}" ]; then
+ # No apk signing key configured — the apk index is genuinely opt-out, so skip it.
+ echo "::warning::APK_KEY_PRIVATE not set — skipping apk index (deb/rpm done)."
+elif command -v abuild-sign >/dev/null 2>&1 && command -v apk >/dev/null 2>&1; then
+ publish_repo apk index_apk keboola.rsa.pub "${APK_KEY_PUBLIC:-}"
+else
+ # Key IS set, so apk publishing is intended — but the tooling is missing (the
+ # `apk-tools abuild` apt install above fell back to the slimmer package set).
+ # publish-s3 only runs on real (non-pre-release) tags, so silently skipping here
+ # would ship a release with no apk index. Fail loudly — same policy as the
+ # DEB_KEY_PRIVATE guard at the top of this script.
+ echo "::error::APK_KEY_PRIVATE is set but abuild-sign/apk is unavailable — refusing to ship a real release without a signed apk index (check the 'apk-tools abuild' apt install above)."
+ exit 1
+fi
+
+echo "Repositories indexed and published under s3://$BUCKET/$PREFIX/{deb,rpm,apk}/"
diff --git a/build/package/macos/sign_notarize.sh b/build/package/macos/sign_notarize.sh
new file mode 100755
index 00000000..d0e499fe
--- /dev/null
+++ b/build/package/macos/sign_notarize.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# Code-sign + notarize + staple a macOS binary. Required Apple secrets:
+# APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 (Developer ID Application cert, base64 .p12)
+# APPLE_DEVELOPER_CERTIFICATE_PASSWORD (.p12 password)
+# APPLE_ACCOUNT_USERNAME (Apple ID email, e.g. apple@keboola.com)
+# APPLE_ACCOUNT_PASSWORD (app-specific password)
+# APPLE_TEAM_ID (e.g. 46P6KJ65M2)
+# FAILS (exit 1) if the cert secret is absent — fail-closed so a real release never
+# ships unsigned (pre-release tags mark this step continue-on-error). Usage: sign_notarize.sh
+set -euo pipefail
+BIN="$1"
+
+for v in APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 APPLE_DEVELOPER_CERTIFICATE_PASSWORD \
+ APPLE_ACCOUNT_USERNAME APPLE_ACCOUNT_PASSWORD APPLE_TEAM_ID; do
+ [ -n "${!v:-}" ] || { echo "::error::$v not set — refusing to ship an unsigned/un-notarized macOS binary."; exit 1; }
+done
+
+KEYCHAIN=build.keychain
+security create-keychain -p actions "$KEYCHAIN"
+security default-keychain -s "$KEYCHAIN"
+security unlock-keychain -p actions "$KEYCHAIN"
+printf '%s' "$APPLE_DEVELOPER_CERTIFICATE_P12_BASE64" | base64 -d > /tmp/cert.p12
+security import /tmp/cert.p12 -k "$KEYCHAIN" -P "${APPLE_DEVELOPER_CERTIFICATE_PASSWORD:-}" -T /usr/bin/codesign
+security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k actions "$KEYCHAIN" >/dev/null
+
+IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/{print $2; exit}')
+[ -n "$IDENTITY" ] || { echo "::error::no 'Developer ID Application' identity after import — wrong cert type/password or import failed"; exit 1; }
+codesign --force --options runtime --timestamp --sign "$IDENTITY" "$BIN"
+
+# Notarize with the Apple ID + app-specific password (notarytool supports this; no API key needed).
+ZIP=/tmp/notarize.zip
+ditto -c -k "$BIN" "$ZIP"
+xcrun notarytool submit "$ZIP" \
+ --apple-id "$APPLE_ACCOUNT_USERNAME" \
+ --password "$APPLE_ACCOUNT_PASSWORD" \
+ --team-id "$APPLE_TEAM_ID" \
+ --wait
+# Staple must succeed on a real release (an unstapled binary needs online
+# notarization checks → worse offline install UX). Pre-release tags mark the
+# whole signing step continue-on-error, so a hard failure here is safe there.
+xcrun stapler staple "$BIN"
+codesign --verify --verbose "$BIN"
diff --git a/build/package/nfpm.yaml b/build/package/nfpm.yaml
new file mode 100644
index 00000000..22ec8a51
--- /dev/null
+++ b/build/package/nfpm.yaml
@@ -0,0 +1,38 @@
+# nfpm config — packages the frozen `kbagent` binary into deb / rpm / apk.
+#
+# nfpm (https://nfpm.goreleaser.com) is a standalone, language-agnostic packager.
+# We do NOT use goreleaser (it builds Go); the binary is produced by PyInstaller in
+# the release workflow's `freeze` matrix, and nfpm only wraps that prebuilt binary.
+#
+# The ${...} placeholders are NOT expanded by nfpm — build_packages.sh renders this
+# file with `envsubst` first. Env vars: VERSION, PKG_ARCH (amd64|arm64), BIN_PATH.
+name: keboola-cli2
+arch: ${PKG_ARCH}
+platform: linux
+version: ${VERSION}
+section: utils
+priority: optional
+maintainer: "Keboola "
+description: |
+ AI-friendly CLI for managing Keboola projects (kbagent).
+ Self-contained native binary; no Python runtime required.
+vendor: "Keboola"
+homepage: "https://github.com/keboola/cli"
+license: "MIT"
+
+contents:
+ - src: ${BIN_PATH}
+ dst: /usr/bin/kbagent
+ file_info:
+ mode: 0755
+
+# Signing keys are written by the workflow to /tmp/keys/* (org GPG keys, repo secrets).
+deb:
+ signature:
+ key_file: /tmp/keys/deb.key
+rpm:
+ signature:
+ key_file: /tmp/keys/rpm.key
+apk:
+ signature:
+ key_file: /tmp/keys/apk.key
diff --git a/build/package/windows/sign.sh b/build/package/windows/sign.sh
new file mode 100755
index 00000000..c0afcd0c
--- /dev/null
+++ b/build/package/windows/sign.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Authenticode-sign a Windows .exe via Azure Key Vault + jsign (no PKCS#12 stored in
+# GitHub). Required service-principal secrets:
+# WINDOWS_SIGNING_TENANT_ID, WINDOWS_SIGNING_CLIENT_ID, WINDOWS_SIGNING_CLIENT_SECRET
+# Key Vault keystore + cert alias default to the existing ones; override via env.
+# FAILS (exit 1) if signing secrets are missing — the workflow marks this step
+# continue-on-error on pre-releases, so dev builds stay non-blocking while a real
+# release tag refuses to ship unsigned. Usage: sign.sh
+set -euo pipefail
+EXE="$1"
+KEYVAULT="${AZURE_KEYVAULT_NAME:-kbc-cli-code-signing}"
+ALIAS="${AZURE_CERT_ALIAS:-codesigning}"
+
+for v in WINDOWS_SIGNING_TENANT_ID WINDOWS_SIGNING_CLIENT_ID WINDOWS_SIGNING_CLIENT_SECRET; do
+ [ -n "${!v:-}" ] || { echo "::error::$v not set — refusing to ship an unsigned Windows exe."; exit 1; }
+done
+
+# Service-principal token for the Key Vault data plane.
+TOKEN=$(curl -sf -X POST "https://login.microsoftonline.com/${WINDOWS_SIGNING_TENANT_ID}/oauth2/v2.0/token" \
+ --data-urlencode "grant_type=client_credentials" \
+ --data-urlencode "client_id=${WINDOWS_SIGNING_CLIENT_ID}" \
+ --data-urlencode "client_secret=${WINDOWS_SIGNING_CLIENT_SECRET}" \
+ --data-urlencode "scope=https://vault.azure.net/.default" \
+ | jq -er .access_token)
+[ -n "$TOKEN" ] || { echo "::error::failed to obtain Azure access token"; exit 1; }
+
+curl -fsSL -o /tmp/jsign.jar https://github.com/ebourg/jsign/releases/download/6.0/jsign-6.0.jar
+echo "05ca18d4ab7b8c2183289b5378d32860f0ea0f3bdab1f1b8cae5894fb225fa8a /tmp/jsign.jar" | sha256sum -c -
+java -jar /tmp/jsign.jar \
+ --storetype AZUREKEYVAULT \
+ --keystore "$KEYVAULT" \
+ --alias "$ALIAS" \
+ --storepass "$TOKEN" \
+ --tsaurl https://timestamp.digicert.com \
+ --replace \
+ "$EXE"
diff --git a/build/package/zip_binary.sh b/build/package/zip_binary.sh
new file mode 100755
index 00000000..8a96b89f
--- /dev/null
+++ b/build/package/zip_binary.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# Zip the frozen binary in ./dist and write a .sha256 sidecar.
+# Usage: zip_binary.sh
+set -euo pipefail
+PKG="$1"; VERSION="$2"; GOOS="$3"; ARCH="$4"; BIN_NAME="$5"
+
+cd dist
+BIN="$BIN_NAME"
+[ "$GOOS" = "windows" ] && BIN="${BIN_NAME}.exe"
+ZIP="${PKG}_${VERSION}_${GOOS}_${ARCH}.zip"
+
+# Explicit per-OS archiver (7z ships on the Windows runner; zip on *nix).
+if [ "$GOOS" = "windows" ]; then
+ 7z a "$ZIP" "$BIN" >/dev/null
+else
+ zip -q "$ZIP" "$BIN"
+fi
+
+if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$ZIP" > "$ZIP.sha256"
+else
+ shasum -a 256 "$ZIP" > "$ZIP.sha256"
+fi
+echo "packaged dist/$ZIP"
diff --git a/docs/adr/0003-release-distribution-cicd.md b/docs/adr/0003-release-distribution-cicd.md
new file mode 100644
index 00000000..ee4668f2
--- /dev/null
+++ b/docs/adr/0003-release-distribution-cicd.md
@@ -0,0 +1,136 @@
+# ADR 0003: Release & Distribution CI/CD
+
+## Status
+
+Accepted
+
+## Date
+
+2026-06-09
+
+## Context
+
+`kbagent` (`keboola-agent-cli`) ships today **only** via
+`uv tool install git+https://github.com/keboola/cli` — no PyPI release, no native
+packages, and a startup auto-update from mutable git `HEAD` (unsigned). The legacy
+`kbc` CLI ships nine signed/notarized channels (Homebrew, apt/deb, rpm, apk, winget,
+chocolatey, scoop, MSI, S3) from one goreleaser pipeline.
+
+Current CI (`.github/workflows/`):
+
+- **`ci.yml`** (push/PR → `main`): `check` (lint, ruff-format, `ty`, SKILL freshness,
+ version consistency, command-sync, error-codes) · `test` (matrix py3.12/3.13,
+ `pytest -m "not integration"`, coverage **informational**, no `--cov-fail-under`) ·
+ `build-windows` (real `uv build --wheel` with Node 20, asserts the React SPA is
+ bundled — issue #320 — plus a CLI-only `KBAGENT_SKIP_UI_BUILD=1` wheel).
+- **`e2e.yml`** (cron 03:17 daily + dispatch): real-project suite that **self-skips
+ green** when `E2E_API_TOKEN` is unset.
+
+Key observation: `build-windows` already proves the **SPA-bundled wheel builds in CI**
+(Node is present on the runner). The npm build hook is therefore **not** a CI problem —
+it only bites **end users** installing on a Node-less host. The fix is small: publish
+the CI-built **prebuilt wheel**, rather than re-running the hook on the user's machine.
+
+We want native install/upgrade parity with `kbc`, using a `…2` naming
+(`keboola-cli2` / `Keboola.KeboolaCLI2`) so both CLIs coexist during transition, while
+retiring the unsigned git-HEAD auto-update.
+
+## Decision
+
+Add a **tag-triggered release pipeline** (`.github/workflows/release-kbagent.yml`) that:
+
+1. **Gates** on the existing `check` + `test` against the tagged commit (plus
+ `changelog-check`, which is intentionally excluded from per-PR CI).
+2. **Builds the prebuilt wheel** (reusing the pinned uv (`astral-sh/setup-uv` at uv `0.11.16`) + `setup-node@20` +
+ `uv build` recipe already proven in `build-windows`) and publishes to **PyPI via
+ OIDC Trusted Publishing** (no stored token).
+3. **Freezes** native `kbagent` binaries with **PyInstaller** (`--onefile`,
+ `--collect-all keboola_agent_cli`, entry `build/package/entry.py`) across a matrix
+ (linux amd64/arm64, macOS arm64, windows amd64 — macOS is Apple Silicon only;
+ Intel Macs fall back to `uv tool install`), then **signs/notarizes**
+ (Apple `notarytool`, Windows Authenticode via `jsign`). **Verified:** a frozen
+ Linux binary runs in a stripped env — `env -i kbagent --version` → `kbagent v0.58.0`
+ — with no Python/uv present. This is the **primary install path**; `uv`/`pipx` is
+ only an additional convenience for Python users, never a requirement.
+4. **Packages** via `nfpm` (deb/rpm/apk; no MSI — Chocolatey wraps the signed `.exe`) and **publishes** to S3
+ (`cli-dist.keboola.com/keboola-cli2/`), a Homebrew tap PR (`keboola-cli2`),
+ Chocolatey, and a winget PR. Scoop is **optional** (uv/uvx already covers that
+ Windows audience). Packaging uses **`nfpm`** (standalone, language-agnostic — NOT
+ goreleaser, which only builds Go) for deb/rpm/apk, a Homebrew formula template, and a
+ Chocolatey nuspec wrapping the signed `.exe`. **All packaging config lives in this
+ repo** under `build/package/` — **no dependency on the legacy keboola-as-code (`kbc`)
+ repo, which is being deprecated**. The Homebrew tap is a new kbagent-owned repo
+ (`keboola/homebrew-keboola-cli2`), not kbc's tap.
+
+Jobs that need release credentials (`freeze` signing, `pypi`, `publish-s3`, the package
+pushes) reference the GitHub **Environment `release`** purely to scope its secrets. The
+environment has **no required reviewers** — releases are automatic on a version tag, so
+the deliberate tag push *is* the approval. This matters: the `freeze` matrix is
+environment-scoped (so signing secrets resolve), and because there are no reviewers it
+is **never blocked**, which is what lets the `continue-on-error` signing steps actually
+keep pre-release/dev runs non-blocking. Dev-vs-prod gating is the `IS_PRERELEASE` check,
+not an approval prompt. (If approval is ever wanted, add it as a separate gated publish
+job, not on `freeze`.) Native installs **defer upgrades to the package manager**; the
+built-in self-update is pointed at **signed PyPI** (or disabled on managed installs),
+retiring the git-HEAD risk.
+
+### As-is → to-be delta
+
+| Change | File | Type |
+|---|---|---|
+| New tag-triggered release pipeline | `.github/workflows/release-kbagent.yml` | ADD |
+| Release gate re-runs `check`+`test`+`changelog-check` on the tag | release workflow | ADD |
+| Protected `release` environment + scoped secrets | repo settings | ADD |
+| Reusable wheel-build step shared by `build-windows` and release | `ci.yml` / composite | REFACTOR (optional) |
+| e2e credentials guard **fails** (not warns) on scheduled/release runs | `e2e.yml` | CHANGE |
+| Coverage floor asserted on release commit (PR stays informational) | release workflow | ADD (optional) |
+
+### Required secrets (scoped to `release`)
+
+| Name | Purpose | Source |
+|---|---|---|
+| *(PyPI OIDC — none)* | Publish to PyPI | Trusted Publisher on pypi.org for project `keboola-cli` (repo + workflow + env `release`) |
+| `HOMEBREW_TAP_TOKEN` | Push formula to `keboola/homebrew-keboola-cli2` | fine-grained PAT, tap repo only |
+| `CHOCOLATEY_KEY` | Push `.nupkg` | chocolatey.org API key (org account) |
+| `WINGET_TOKEN` | Fork + PR `microsoft/winget-pkgs` | classic PAT (`public_repo`) on an org bot |
+| `APPLE_DEVELOPER_CERTIFICATE_P12_BASE64` / `APPLE_DEVELOPER_CERTIFICATE_PASSWORD` | macOS code-sign | Developer ID Application cert (`.p12` base64) + its password |
+| `APPLE_ACCOUNT_PASSWORD` | Notarization | App-specific password for the Apple ID (account/team are literals in the workflow) |
+| `WINDOWS_SIGNING_TENANT_ID` / `WINDOWS_SIGNING_CLIENT_ID` / `WINDOWS_SIGNING_CLIENT_SECRET` | Authenticode via Azure Key Vault | service principal with Key Vault access |
+| `AWS_ROLE_ARN` | Upload to `cli-dist.keboola.com` | IAM role trusting GitHub OIDC |
+| `DEB_KEY_PRIVATE` (apt keyring exported inline from it by `index.sh`; no `DEB_KEY_PUBLIC`), `RPM_KEY_PRIVATE` / `RPM_KEY_PUBLIC` | Sign deb/rpm packages + repo metadata | GPG keypair (passphrase-less) |
+| `APK_KEY_PRIVATE` / `APK_KEY_PUBLIC` | Sign the apk index | abuild RSA keypair |
+
+Set with `gh secret set --env release --repo keboola/cli` piping the value via
+**stdin** (never argv); shred the temp file after.
+
+### Rollout
+
+1. **P0** — npm-hook prebuilt wheel + PyPI (OIDC). Instant `uv`/`uvx`/`pipx` parity.
+2. **P1a** — PyInstaller freeze (Linux) → `nfpm` deb/rpm/apk + S3 repo index + Homebrew formula.
+3. **P1b** — macOS sign/notarize, Windows sign → winget + choco.
+4. **P2** — retire git-HEAD auto-update (managed-install detection); Scoop on demand.
+
+## Consequences
+
+- **Positive:** native install/upgrade parity with `kbc`; signed artifacts; OIDC for
+ PyPI/AWS removes long-lived secrets; resolves the unsigned-auto-update risk
+ (see ADR 0002 `docs/adr/0002-sec-09-config-privilege-separation.md` and the security review).
+- **Cost:** Python cannot cross-compile, so the freeze matrix needs ~6 per-OS/arch
+ runners (vs Go's single build); per-release wall-clock and maintenance grow.
+- **Risk:** signing-cert and notarization setup is fiddly; mitigated by Keboola's
+ existing org-level signing certs/keys (stored as secrets in this repo) and the
+ `release` environment gate. Everything required to cut a release lives in this repo —
+ no dependency on the deprecated `kbc` repository.
+
+## Notes
+
+This ADR is the **canonical, in-repo** decision record. The concrete artifacts all live
+in this repository:
+
+- `.github/workflows/release-kbagent.yml` — the pipeline (real publish jobs, gated by the `release` environment + the pre-release check; triggers on version tags and gated manual dispatch).
+- `build/package/` — self-contained packaging: `entry.py` (freeze entry), `nfpm.yaml`
+ (deb/rpm/apk), `homebrew/` (formula template + render), `chocolatey/` (nuspec + install),
+ and `linux/`, `macos/`, `windows/` sign/index scripts.
+
+No build- or release-time dependency on the legacy `keboola-as-code` (`kbc`) repository,
+which is being deprecated.