Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/actions/setup-build/action.yml
Original file line number Diff line number Diff line change
@@ -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
400 changes: 400 additions & 0 deletions .github/workflows/release-kbagent.yml

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions build/package/chocolatey/keboola-cli2.nuspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Chocolatey package for kbagent (id: keboola-cli2). Wraps the signed PyInstaller
.exe downloaded from cli-dist.keboola.com — no Python required. {VERSION} is
substituted by the release workflow. -->
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>keboola-cli2</id>
<version>{VERSION}</version>
<title>Keboola Agent CLI (kbagent)</title>
<authors>Keboola</authors>
<projectUrl>https://github.com/keboola/cli</projectUrl>
<licenseUrl>https://github.com/keboola/cli/blob/main/LICENSE</licenseUrl>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<summary>AI-friendly CLI for managing Keboola projects.</summary>
<description>Self-contained native CLI for managing Keboola Connection projects. No Python runtime required.</description>
<tags>keboola cli kbagent</tags>
</metadata>
<files>
<file src="tools\**" target="tools" />
</files>
</package>
16 changes: 16 additions & 0 deletions build/package/chocolatey/tools/chocolateyinstall.ps1
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions build/package/entry.py
Original file line number Diff line number Diff line change
@@ -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()
41 changes: 41 additions & 0 deletions build/package/homebrew/keboola-cli2.rb.tmpl
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions build/package/homebrew/render.sh
Original file line number Diff line number Diff line change
@@ -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 <version> <artifacts-dir> (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_<v>_<os>_<arch>.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"
24 changes: 24 additions & 0 deletions build/package/linux/build_packages.sh
Original file line number Diff line number Diff line change
@@ -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 <version> [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/
84 changes: 84 additions & 0 deletions build/package/linux/index.sh
Original file line number Diff line number Diff line change
@@ -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-bucket> <prefix> → s3://<bucket>/<prefix>/{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
Comment thread
Matovidlo marked this conversation as resolved.

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 <fmt> <indexer-fn> <public-key-file> <public-key-content>
# 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}/"
42 changes: 42 additions & 0 deletions build/package/macos/sign_notarize.sh
Original file line number Diff line number Diff line change
@@ -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 <binary>
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"
38 changes: 38 additions & 0 deletions build/package/nfpm.yaml
Original file line number Diff line number Diff line change
@@ -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 <dev@keboola.com>"
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
36 changes: 36 additions & 0 deletions build/package/windows/sign.sh
Original file line number Diff line number Diff line change
@@ -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 <exe-path>
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; }
Comment thread
Matovidlo marked this conversation as resolved.

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 \
Comment thread
Matovidlo marked this conversation as resolved.
--storetype AZUREKEYVAULT \
--keystore "$KEYVAULT" \
--alias "$ALIAS" \
--storepass "$TOKEN" \
--tsaurl https://timestamp.digicert.com \
--replace \
"$EXE"
Loading