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.