feat(jetbrains): publish bundled CLI builds

This commit is contained in:
kirillk
2026-07-24 12:49:58 -04:00
parent 232d7f2c61
commit 452d0eb55f
15 changed files with 691 additions and 13 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation.
+270
View File
@@ -0,0 +1,270 @@
# kilocode_change - new file
name: bundle-jetbrains
on:
workflow_dispatch:
inputs:
pr:
description: Merged JetBrains release PR number to bundle
required: true
type: string
merge_commit:
description: Merge commit SHA from the reviewed release PR
required: true
type: string
concurrency:
group: bundle-jetbrains-pr-${{ inputs.pr }}
cancel-in-progress: false
permissions:
actions: read
contents: write
id-token: write
pages: write
pull-requests: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
bundle:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- name: Checkout merged release PR
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.merge_commit }}
- name: Setup Bun for validation
uses: ./.github/actions/setup-bun
- name: Validate release PR and tag
id: release
run: bun script/jetbrains-release-validate.ts --pr "$PR_NUMBER"
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ inputs.pr }}
- name: Save reviewed release metadata
run: |
cp packages/kilo-jetbrains/CHANGELOG.md "$RUNNER_TEMP/jetbrains-CHANGELOG.md"
cp packages/kilo-jetbrains/gradle.properties "$RUNNER_TEMP/jetbrains-gradle.properties"
- name: Checkout release tag
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ steps.release.outputs.tag }}
- name: Restore reviewed release metadata
run: |
cp "$RUNNER_TEMP/jetbrains-CHANGELOG.md" packages/kilo-jetbrains/CHANGELOG.md
cp "$RUNNER_TEMP/jetbrains-gradle.properties" packages/kilo-jetbrains/gradle.properties
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install dependencies
run: bun install
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Install build tools
run: |
sudo apt-get update
sudo apt-get install -y patchelf zip unzip
curl --fail --location \
https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \
--output "$RUNNER_TEMP/zig.tar.xz"
echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status
tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH"
- name: Validate signing secrets
run: |
missing=0
for name in JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do
if [[ -z "${!name}" ]]; then
echo "Missing required secret: $name" >&2
missing=1
fi
done
exit "$missing"
env:
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
- name: Build signed bundled plugin
working-directory: packages/kilo-jetbrains
run: |
./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin \
-Pproduction=true \
-Pkilo.version="$VERSION" \
-Pkilo.channel="$CHANNEL" \
-Pkilo.cli.bundled=true
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
VERSION: ${{ steps.release.outputs.version }}
CHANNEL: ${{ steps.release.outputs.marketplace_channel }}
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
- name: Resolve bundled archive
id: archive
run: |
mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip")
if [[ "${#signed[@]}" -ne 1 ]]; then
echo "Expected exactly one signed bundled JetBrains plugin ZIP, found ${#signed[@]}." >&2
printf '%s\n' "${signed[@]}" >&2
exit 1
fi
asset="kilo-code-${VERSION}-bundled.zip"
dest="packages/kilo-jetbrains/build/release/$asset"
mkdir -p "$(dirname "$dest")"
cp "${signed[0]}" "$dest"
echo "asset=$asset" >> "$GITHUB_OUTPUT"
echo "path=$dest" >> "$GITHUB_OUTPUT"
env:
VERSION: ${{ steps.release.outputs.version }}
- name: Upload bundled ZIP to GitHub Release
run: gh release upload "$TAG" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY"
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.release.outputs.tag }}
ARCHIVE: ${{ steps.archive.outputs.path }}
- name: Resolve bundled asset URL
id: asset
run: |
url="$(python3 - <<'PY'
import os
import urllib.parse
repo = os.environ["GITHUB_REPOSITORY"]
tag = urllib.parse.quote(os.environ["TAG"], safe="")
asset = urllib.parse.quote(os.environ["ASSET"], safe="")
print(f"https://github.com/{repo}/releases/download/{tag}/{asset}")
PY
)"
echo "url=$url" >> "$GITHUB_OUTPUT"
env:
TAG: ${{ steps.release.outputs.tag }}
ASSET: ${{ steps.archive.outputs.asset }}
- name: Generate stable plugin repository XML
if: steps.release.outputs.kind == 'stable'
run: |
mkdir -p pages/jetbrains
python3 <<'PY'
import html
import io
import os
import zipfile
import xml.etree.ElementTree as ET
archive = os.environ["ARCHIVE"]
asset = os.environ["ASSET_URL"]
version = os.environ["VERSION"]
def plugin_xml(path):
with zipfile.ZipFile(path) as zip:
for name in zip.namelist():
if name.endswith("META-INF/plugin.xml"):
return zip.read(name)
for name in zip.namelist():
if not name.endswith(".jar"):
continue
with zipfile.ZipFile(io.BytesIO(zip.read(name))) as jar:
for item in jar.namelist():
if item.endswith("META-INF/plugin.xml"):
return jar.read(item)
raise SystemExit("bundled plugin ZIP did not contain META-INF/plugin.xml")
root = ET.fromstring(plugin_xml(archive))
def text(name, default=""):
item = root.find(name)
return item.text.strip() if item is not None and item.text else default
def cdata(value):
return "<![CDATA[" + value.replace("]]>", "]]]]><![CDATA[>") + "]]>"
plugin = text("id", "ai.kilocode.jetbrains")
name = text("name", "Kilo Code")
vendor = text("vendor", "Kilo Code")
desc = text("description")
notes = text("change-notes")
idea = root.find("idea-version")
attrs = ""
if idea is not None:
since = idea.attrib.get("since-build")
until = idea.attrib.get("until-build")
if since:
attrs += f' since-build="{html.escape(since)}"'
if until:
attrs += f' until-build="{html.escape(until)}"'
xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<plugins>',
f' <plugin id="{html.escape(plugin)}" version="{html.escape(version)}" url="{html.escape(asset)}">',
f' <name>{html.escape(name)}</name>',
f' <vendor>{html.escape(vendor)}</vendor>',
f' <idea-version{attrs}/>',
]
if desc:
xml.append(f' <description>{cdata(desc)}</description>')
if notes:
xml.append(f' <change-notes>{cdata(notes)}</change-notes>')
xml.extend([' </plugin>', '</plugins>', ''])
with open("pages/jetbrains/updatePlugins.xml", "w", encoding="utf-8") as file:
file.write("\n".join(xml))
PY
env:
ARCHIVE: ${{ steps.archive.outputs.path }}
ASSET_URL: ${{ steps.asset.outputs.url }}
VERSION: ${{ steps.release.outputs.version }}
- name: Configure Pages
if: steps.release.outputs.kind == 'stable'
uses: actions/configure-pages@v5
- name: Upload Pages artifact
if: steps.release.outputs.kind == 'stable'
uses: actions/upload-pages-artifact@v4
with:
path: pages
- name: Deploy Pages
if: steps.release.outputs.kind == 'stable'
id: deployment
uses: actions/deploy-pages@v4
- name: Upload workflow artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: kilo-jetbrains-bundled-${{ steps.release.outputs.version }}
path: |
packages/kilo-jetbrains/build/release/*.zip
pages/jetbrains/updatePlugins.xml
if-no-files-found: ignore
+13
View File
@@ -23,6 +23,7 @@ concurrency:
cancel-in-progress: false
permissions:
actions: write
contents: write
pull-requests: read
@@ -199,6 +200,18 @@ jobs:
ARCHIVE: ${{ steps.archive.outputs.path }}
NOTES: packages/kilo-jetbrains/build/release-notes.md
- name: Dispatch bundled GitHub release build
run: |
gh workflow run bundle-jetbrains.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f pr="$PR_NUMBER" \
-f merge_commit="$MERGE_COMMIT"
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }}
MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.merge_commit }}
- name: Upload workflow artifact
if: always()
uses: actions/upload-artifact@v4
+1
View File
@@ -157,6 +157,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
- CLI process spawning, download, extraction, and lifecycle belong in `backend`.
- By default, the plugin does not bundle CLI binaries. At connect time the backend downloads the GitHub Release asset for the version pinned in `packages/kilo-jetbrains/package.json`; `backend` resources include `kilo.properties` with `cli.version` and `cli.pinned` for split-mode RPC and runtime use.
- Bundled release builds pass `-Pkilo.cli.bundled=true` while keeping `kilo.cli.pinned=true`. This build-only flag stages all pinned CLI release assets into `kilo-cli.zip`; runtime detects that resource and extracts only the current platform instead of downloading. Do not add a `cli.bundled` key to `kilo.properties` or repurpose `kilo.cli.pinned=false` for public bundled releases.
- For release questions, use the `release-jetbrains` skill and reference `.kilo/skills/release-jetbrains/SKILL.md`; it verifies the CLI pin before creating immutable `jetbrains/v*` tags.
- For OS and environment checks, prefer IntelliJ Platform classes over raw JVM APIs such as `System.getProperty(...)` or `System.getenv(...)`.
- Detect architecture with `com.intellij.util.system.CpuArch.CURRENT`, not `System.getProperty("os.arch")`.
+1 -1
View File
@@ -78,7 +78,7 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-<version>.zip
## Releasing
See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, and how to install RC builds via the custom plugin repository.
See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, how to install RC builds, and how the signed bundled CLI build is published to the GitHub-hosted stable plugin repository.
---
+4
View File
@@ -14,6 +14,8 @@
- Create a JetBrains Marketplace permanent token from Marketplace `My Tokens`.
- Add `JETBRAINS_MARKETPLACE_TOKEN` to GitHub Actions secrets or the protected environment.
- Confirm `GITHUB_TOKEN` has `contents: write` permission for creating and updating GitHub Releases for `jetbrains/v*` tags.
- Confirm `GITHUB_TOKEN` has `actions: write`, `pages: write`, and `id-token: write` permission for dispatching bundled releases and publishing the stable GitHub Pages plugin repository.
- Configure GitHub Pages for this repository with source set to GitHub Actions.
- Confirm `KILO_MAINTAINER_APP_ID` and `KILO_MAINTAINER_APP_SECRET` are available to create release PRs and immediate release tags.
- Optionally create a protected `jetbrains-marketplace` GitHub Environment with required reviewers.
- If using an environment, move the Marketplace and signing secrets there and set the workflow job environment.
@@ -35,6 +37,7 @@
- Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR.
- Merge the release PR to trigger publish from `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`.
- Watch the `publish-jetbrains` workflow.
- Confirm the follow-up `bundle-jetbrains` workflow completes and attaches `kilo-code-x.y.z-rc.n-bundled.zip` to the prerelease.
- Download and retain the workflow artifact if needed.
- Confirm the update appears on the JetBrains Marketplace `eap` channel.
- Confirm the GitHub Release for the `jetbrains/vx.y.z-rc.n` tag exists and contains the JetBrains plugin ZIP asset.
@@ -48,5 +51,6 @@
- Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR.
- Merge the release PR to trigger publish from `jetbrains/vx.y.z`.
- Watch the `publish-jetbrains` workflow.
- Confirm the follow-up `bundle-jetbrains` workflow completes, attaches `kilo-code-x.y.z-bundled.zip`, and updates `https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml`.
- Confirm the update appears on the default JetBrains Marketplace channel.
- Confirm the GitHub Release for the `jetbrains/vx.y.z` tag exists and contains the JetBrains plugin ZIP asset.
+10
View File
@@ -132,6 +132,16 @@ Publishing behavior:
The workflow checks out `jetbrains/v<version>` for verification, signing, and Marketplace publishing. It overlays the reviewed `packages/kilo-jetbrains/gradle.properties` and `packages/kilo-jetbrains/CHANGELOG.md` from the merged PR before rendering release notes and before `publishPlugin`, so the Marketplace plugin version, Marketplace notes, and GitHub Release use the reviewed metadata.
After Marketplace publishing succeeds, `publish-jetbrains` dispatches `bundle-jetbrains`. The bundled workflow rebuilds the same `jetbrains/v<version>` tag with `-Pkilo.cli.bundled=true`, signs and verifies the all-platform plugin ZIP, then uploads `kilo-code-<version>-bundled.zip` to the same GitHub Release. Bundled builds keep `kilo.cli.pinned=true`; the build flag only embeds the pinned CLI release assets so runtime extracts the bundled current-platform CLI instead of downloading it.
Stable bundled releases also publish the GitHub Pages custom plugin repository XML:
```text
https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml
```
RC bundled ZIPs are attached to prereleases for install-from-disk testing, but they do not update the stable custom repository XML.
## Installing RC Builds
RC builds are published to the `eap` channel. To get them in IntelliJ IDEA:
@@ -22,6 +22,7 @@ val generatedProps = layout.buildDirectory.dir("generated/kilo-props")
val generatedCli = layout.buildDirectory.dir("generated/kilo-cli-res")
val pinned = providers.gradleProperty("kilo.cli.pinned").map { it.trim().toBoolean() }.orElse(true)
val repoCli = pinned.map { !it }
val bundled = providers.gradleProperty("kilo.cli.bundled").map { it.trim().toBoolean() }.orElse(false)
val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode")
val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
@@ -32,11 +33,15 @@ val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirector
sourceSets {
main {
resources.srcDir(generatedProps)
if (repoCli.get()) resources.srcDir(generatedCli)
if (repoCli.get() || bundled.get()) resources.srcDir(generatedCli)
kotlin.srcDir(generatedApi)
}
}
if (repoCli.get() && bundled.get()) {
error("kilo.cli.bundled=true requires kilo.cli.pinned=true; do not combine release CLI bundling with local repo CLI mode.")
}
val writeKiloProperties by tasks.registering(WriteProperties::class) {
description = "Write pinned Kilo CLI properties"
val out = generatedProps.map { it.file("kilo.properties") }
@@ -88,6 +93,17 @@ val stageRepoCli by tasks.registering(StageRepoCliTask::class) {
outputs.upToDateWhen { false }
}
val stageBundledCli by tasks.registering(StageBundledCliTask::class) {
description = "Stage all pinned Kilo CLI release assets into backend resources"
cliVersion.set(pinnedCliVersion)
token.set(
providers.environmentVariable("GH_TOKEN")
.orElse(providers.environmentVariable("GITHUB_TOKEN"))
)
cacheDir.set(layout.buildDirectory.dir("cli-cache"))
archive.set(generatedCli.map { it.file("kilo-cli.zip") })
}
val normalizeOpenApiSpec by tasks.registering(NormalizeOpenApiSpecTask::class) {
description = "Normalize upstream CLI OpenAPI metadata before Kotlin client generation"
dependsOn(generateOpenApiSpec)
@@ -143,12 +159,14 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
tasks.named("compileKotlin") {
dependsOn(fixGeneratedApi, writeKiloProperties)
if (repoCli.get()) dependsOn(stageRepoCli)
if (bundled.get()) dependsOn(stageBundledCli)
inputs.dir(generatedApi)
}
tasks.named("processResources") {
dependsOn(writeKiloProperties)
if (repoCli.get()) dependsOn(stageRepoCli)
if (bundled.get()) dependsOn(stageBundledCli)
}
tasks.named("compileTestKotlin") {
@@ -126,8 +126,8 @@ class KiloBackendCliManager(
private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File {
val force = forceExtract
forceExtract = false
if (!KiloProps.pinned()) {
if (force) log.info("Force re-extracting local repo CLI ${KiloProps.cliVersion()}")
if (KiloRepoCli.available()) {
if (force) log.info("Force re-extracting bundled CLI ${KiloProps.cliVersion()}")
val cli = KiloRepoCli.extract(force)
onProgress(CliDownload(100, KiloProps.cliVersion(), KiloCliPlatform.current()))
return cli
@@ -10,17 +10,22 @@ import java.io.OutputStream
import java.util.zip.ZipInputStream
object KiloRepoCli {
private const val ARCHIVE = "kilo-cli.zip"
fun available(): Boolean = KiloRepoCli::class.java.classLoader.getResource(ARCHIVE) != null
suspend fun extract(force: Boolean): File = extract(
force = force,
root = File(PathManager.getSystemPath(), "kilo/repo-cli"),
root = File(PathManager.getSystemPath(), "kilo/repo-cli/${KiloProps.cliVersion()}"),
source = {
KiloRepoCli::class.java.classLoader.getResourceAsStream("kilo-cli.zip")
?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with kilo.cli.pinned=false")
KiloRepoCli::class.java.classLoader.getResourceAsStream(ARCHIVE)
?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with bundled CLI resources")
},
)
internal suspend fun extract(force: Boolean, root: File, source: () -> InputStream): File = withContext(Dispatchers.IO) {
val exe = File(root, "bin/${KiloCliPlatform.exe()}")
val platform = KiloCliPlatform.current()
val exe = File(root, "$platform/bin/${KiloCliPlatform.exe()}")
val done = File(root, ".complete")
if (!force && done.isFile && exe.isFile) {
if (!SystemInfo.isWindows) exe.setExecutable(true)
@@ -38,21 +43,42 @@ object KiloRepoCli {
ZipInputStream(input.buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
write(root, entry.name, entry.isDirectory) { out -> zip.copyTo(out) }
val path = select(root, entry.name, platform)
if (path != null) write(root, path, entry.isDirectory) { out -> zip.copyTo(out) }
zip.closeEntry()
}
}
}
if (!exe.isFile) throw IllegalStateException("Local repo CLI archive did not contain bin/${KiloCliPlatform.exe()}")
if (!exe.isFile) throw IllegalStateException("Bundled CLI archive did not contain $platform/bin/${KiloCliPlatform.exe()}")
if (!SystemInfo.isWindows) exe.setExecutable(true)
done.writeText("ok\n")
return@withContext exe
}
private fun select(dir: File, name: String, platform: String): String? {
check(dir, name)
val path = name.replace('\\', '/')
val prefix = "$platform/"
if (path.startsWith(prefix)) return path
if (path.startsWith("bin/")) return "$platform/$path"
return null
}
private fun check(dir: File, name: String) {
val raw = name.replace('\\', '/')
if (raw.startsWith("/")) throw IllegalStateException("Archive entry escapes target directory: $name")
val parts = raw.split('/').filter { it.isNotEmpty() }
if (parts.any { it == ".." }) throw IllegalStateException("Archive entry escapes target directory: $name")
val target = File(dir, name).canonicalFile
val base = dir.canonicalFile
if (target != base && !target.path.startsWith(base.path + File.separator)) {
throw IllegalStateException("Archive entry escapes target directory: $name")
}
}
private fun write(dir: File, name: String, directory: Boolean, copy: (OutputStream) -> Unit) {
val path = if (name.startsWith("bin/")) name else "bin/$name"
val target = File(dir, path).canonicalFile
val target = File(dir, name).canonicalFile
val base = dir.canonicalFile
if (target != base && !target.path.startsWith(base.path + File.separator)) {
throw IllegalStateException("Archive entry escapes target directory: $name")
@@ -12,6 +12,7 @@ import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class KiloRepoCliTest {
@@ -38,6 +39,20 @@ class KiloRepoCliTest {
assertEquals("#!/bin/new\n", forced.readText())
}
@Test
fun `extracts only current platform from multi platform archive`() = runBlocking {
val platform = KiloCliPlatform.current()
val other = if (platform == "windows-x64") "darwin-arm64" else "windows-x64"
val cli = KiloRepoCli.extract(false, dir) {
ByteArrayInputStream(multi(platform, other))
}
assertTrue(cli.isFile)
assertEquals("current", cli.readText())
assertFalse(File(dir, "$other/bin/kilo.exe").exists())
assertFalse(File(dir, "$other/bin/kilo").exists())
}
@Test
fun `rejects archive entries that escape root`() = runBlocking {
val ex = assertFailsWith<IllegalStateException> {
@@ -66,4 +81,17 @@ class KiloRepoCliTest {
}
return out.toByteArray()
}
private fun multi(platform: String, other: String): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zip ->
zip.putNextEntry(ZipEntry("$platform/bin/${KiloCliPlatform.exe()}"))
zip.write("current".toByteArray())
zip.closeEntry()
zip.putNextEntry(ZipEntry("$other/bin/kilo.exe"))
zip.write("other".toByteArray())
zip.closeEntry()
}
return out.toByteArray()
}
}
@@ -0,0 +1,221 @@
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.net.HttpURLConnection
import java.net.URI
import java.security.MessageDigest
import java.time.Instant
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
abstract class StageBundledCliTask : DefaultTask() {
companion object {
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
private val JSON = Json { ignoreUnknownKeys = true }
private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags"
private val PLATFORMS = listOf(
"darwin-arm64",
"darwin-x64",
"linux-arm64",
"linux-x64",
"windows-arm64",
"windows-x64",
)
}
@get:Input
abstract val cliVersion: Property<String>
@get:Internal
abstract val token: Property<String>
@get:Internal
abstract val cacheDir: DirectoryProperty
@get:OutputFile
abstract val archive: RegularFileProperty
@TaskAction
fun run() {
val ver = cliVersion.get()
val assets = assets(ver)
val files = PLATFORMS.associateWith { platform ->
val ext = ext(platform)
val name = "kilo-$platform.$ext"
val digest = assets[name] ?: throw GradleException("Kilo CLI release $ver did not include $name")
val file = cacheDir.dir(ver).map { it.dir(platform).file(name) }.get().asFile
fetch(ver, platform, name, digest, file)
file
}
val out = archive.get().asFile
out.parentFile.mkdirs()
ZipOutputStream(out.outputStream().buffered()).use { zip ->
for ((platform, file) in files) {
if (file.name.endsWith(".zip")) {
zip(platform, file, zip)
continue
}
tar(platform, file, zip)
}
}
}
private fun assets(ver: String): Map<String, String> {
val url = "$API/v$ver"
logger.lifecycle("Fetching pinned Kilo CLI release metadata from $url")
val conn = connect(url)
try {
val code = conn.responseCode
if (code !in 200..299) fail(conn, code, "Failed to fetch pinned Kilo CLI release metadata")
val body = conn.inputStream.bufferedReader().use { it.readText() }
return JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
?.associate { item ->
val obj = item.jsonObject
val name = obj["name"]?.jsonPrimitive?.contentOrNull
val digest = obj["digest"]?.jsonPrimitive?.contentOrNull
if (name.isNullOrBlank() || digest.isNullOrBlank()) return@associate "" to ""
name to digest
}
?.filter { it.key.isNotEmpty() }
?.mapValues { item ->
val digest = item.value
if (!digest.matches(DIGEST)) {
throw GradleException("Pinned Kilo CLI release $ver asset ${item.key} has invalid digest")
}
digest
}
?: emptyMap()
} finally {
conn.disconnect()
}
}
private fun fetch(ver: String, platform: String, name: String, digest: String, file: File) {
if (file.isFile && sum(file) == digest) return
file.parentFile.mkdirs()
val url = "https://github.com/Kilo-Org/kilocode/releases/download/v$ver/$name"
logger.lifecycle("Downloading pinned Kilo CLI $platform from $url")
val conn = connect(url)
try {
val code = conn.responseCode
if (code !in 200..299) fail(conn, code, "Failed to download pinned Kilo CLI $platform")
conn.inputStream.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
}
} finally {
conn.disconnect()
}
verify(file, digest)
}
private fun zip(platform: String, file: File, out: ZipOutputStream) {
ZipInputStream(file.inputStream().buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
if (!entry.isDirectory) write(out, platform, entry.name) { zip.copyTo(out) }
zip.closeEntry()
}
}
}
private fun tar(platform: String, file: File, out: ZipOutputStream) {
TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar ->
while (true) {
val entry = tar.nextEntry ?: break
if (!entry.isDirectory) write(out, platform, entry.name) { tar.copyTo(out) }
}
}
}
private fun write(out: ZipOutputStream, platform: String, name: String, copy: () -> Unit) {
out.putNextEntry(ZipEntry(path(platform, name)))
copy()
out.closeEntry()
}
private fun path(platform: String, name: String): String {
val raw = name.replace('\\', '/')
if (raw.startsWith("/")) throw GradleException("Archive entry escapes target directory: $name")
val parts = raw.split('/').filter { it.isNotEmpty() && it != "." }
if (parts.isEmpty()) throw GradleException("Archive entry is empty: $name")
if (parts.any { it == ".." }) throw GradleException("Archive entry escapes target directory: $name")
val path = if (parts.first() == "bin") parts else listOf("bin") + parts
return "$platform/${path.joinToString("/")}"
}
private fun verify(file: File, digest: String) {
val actual = sum(file)
if (actual == digest) return
if (file.exists() && !file.delete()) logger.warn("Failed to delete invalid pinned Kilo CLI archive ${file.absolutePath}")
throw GradleException("Pinned Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual")
}
private fun sum(file: File) = "sha256:${sha256(file)}"
private fun sha256(file: File): String {
val md = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val n = input.read(buffer)
if (n < 0) break
md.update(buffer, 0, n)
}
}
return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
private fun connect(url: String): HttpURLConnection {
val conn = URI(url).toURL().openConnection() as HttpURLConnection
conn.connectTimeout = 30_000
conn.readTimeout = 120_000
conn.instanceFollowRedirects = true
conn.setRequestProperty("Accept", "application/vnd.github+json")
token.getOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
return conn
}
private fun fail(conn: HttpURLConnection, code: Int, msg: String): Nothing {
val info = rate(conn)
val body = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } }
.getOrNull()
?.take(500)
val detail = if (body.isNullOrBlank()) "" else ": $body"
if (limited(conn, code)) {
throw GradleException("GitHub API rate limit exceeded while staging bundled Kilo CLI ($info)$detail")
}
throw GradleException("$msg: HTTP $code from ${conn.url} ($info)$detail")
}
private fun rate(conn: HttpURLConnection): String {
val reset = conn.getHeaderField("X-RateLimit-Reset")
?.toLongOrNull()
?.let { Instant.ofEpochSecond(it).toString() }
return "limit=${conn.getHeaderField("X-RateLimit-Limit")} remaining=${conn.getHeaderField("X-RateLimit-Remaining")} " +
"used=${conn.getHeaderField("X-RateLimit-Used")} reset=$reset retryAfter=${conn.getHeaderField("Retry-After")}"
}
private fun limited(conn: HttpURLConnection, code: Int) =
code == 429 || (code == 403 && conn.getHeaderField("X-RateLimit-Remaining") == "0")
private fun ext(platform: String) = if (platform.startsWith("linux-")) "tar.gz" else "zip"
}
@@ -28,12 +28,13 @@ abstract class StageRepoCliTask : DefaultTask() {
}
val out = archive.get().asFile
val platform = platform()
out.parentFile.mkdirs()
ZipOutputStream(out.outputStream().buffered()).use { zip ->
dir.walkTopDown()
.filter { it.isFile }
.forEach { file ->
val name = "bin/${file.relativeTo(dir).invariantSeparatorsPath}"
val name = "$platform/bin/${file.relativeTo(dir).invariantSeparatorsPath}"
zip.putNextEntry(ZipEntry(name))
file.inputStream().use { it.copyTo(zip) }
zip.closeEntry()
@@ -42,4 +43,20 @@ abstract class StageRepoCliTask : DefaultTask() {
}
private fun exe() = if (System.getProperty("os.name").lowercase().contains("windows")) "kilo.exe" else "kilo"
private fun platform(): String {
val os = System.getProperty("os.name").lowercase()
val name = when {
os.contains("mac") || os.contains("darwin") -> "darwin"
os.contains("linux") -> "linux"
os.contains("windows") -> "windows"
else -> throw GradleException("Unsupported OS: ${System.getProperty("os.name")}")
}
val arch = when (System.getProperty("os.arch").lowercase()) {
"aarch64", "arm64" -> "arm64"
"x86_64", "amd64" -> "x64"
else -> throw GradleException("Unsupported architecture: ${System.getProperty("os.arch")}")
}
return "$name-$arch"
}
}
@@ -0,0 +1,64 @@
# JetBrains Bundled-CLI Release Plan
Ship a signed, all-platform, CLI-bundled build of the Kilo JetBrains plugin to a GitHub-hosted custom plugin repository, as an alternative to the JetBrains Marketplace, which caps plugin ZIPs at 400 MB. The Marketplace build stays lean and downloads the CLI at runtime; the bundled build embeds every platform's CLI so it works offline or on restricted networks.
## Decisions
1. Host `updatePlugins.xml` via GitHub Pages deployed by Actions.
2. Maintain a single stable custom repo: one `updatePlugins.xml`, updated on stable releases only.
3. Auto-trigger the bundled workflow after `publish-jetbrains` succeeds.
4. Decide runtime delivery by presence of the bundled `kilo-cli.zip` resource. Do not add a `kilo.properties` flag, and do not edit committed files for a bundled build.
## Core Principle
- A bundled build uses the same `jetbrains/v<version>` tag, the same source, and `kilo.cli.pinned=true`.
- The only build difference is the override `-Pkilo.cli.bundled=true`.
- `kilo.properties` stays byte-identical between Marketplace and bundled builds. The only build-output difference is whether `kilo-cli.zip` is embedded in the backend jar.
- `kilo.cli.pinned` keeps its existing meaning: which CLI version / OpenAPI source / release guard. It does not control runtime delivery.
## Phase 1: Backend Delivery
- Add `KiloRepoCli.available()` to detect `kilo-cli.zip` on the classpath.
- Change `KiloBackendCliManager.resolveCli()` to extract when `KiloRepoCli.available()` is true; otherwise download the pinned release asset.
- Store bundled archives as `<platform>/bin/kilo[.exe]` for all six platforms: `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `windows-arm64`, `windows-x64`.
- Extract only the current platform's subtree to disk so users do not store all six binaries locally.
- Keep path traversal checks for every archive entry.
- Update repo CLI dev staging to use the same layout.
## Phase 2: Gradle Bundling
- Add a build-only property `kilo.cli.bundled`, defaulting to false.
- Keep `kilo.cli.pinned=true` for bundled production builds.
- Add a task that downloads all six pinned CLI release assets from GitHub, verifies their `sha256` digests from release metadata, and assembles `kilo-cli.zip` as a backend resource.
- Wire that generated resource only when `-Pkilo.cli.bundled=true` or local repo CLI mode is active.
- Leave the production guard against `kilo.cli.pinned=false` intact.
Bundled build command:
```bash
./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin \
-Pproduction=true -Pkilo.version=<version> -Pkilo.channel=default \
-Pkilo.cli.bundled=true
```
## Phase 3: Bundle Workflow
- Add `.github/workflows/bundle-jetbrains.yml`.
- Add a final success step to `publish-jetbrains.yml` that dispatches the bundle workflow with the merged release PR and merge commit.
- The bundle workflow checks out the merged release PR for validation, then checks out the immutable `jetbrains/v<version>` tag, restores reviewed release metadata, builds the bundled variant, signs it, verifies it, and uploads `kilo-code-<version>-bundled.zip` to the same GitHub Release.
- Bundle ZIPs are produced for RC and stable releases. Only stable releases update the custom plugin repository XML.
## Phase 4: GitHub Pages Repository
- Generate `jetbrains/updatePlugins.xml` from the signed bundled ZIP metadata on stable releases.
- Point the plugin URL at the uploaded GitHub Release asset.
- Deploy the XML with GitHub Pages Actions to `https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml`.
- Users add that URL in JetBrains IDEs under Settings -> Plugins -> Manage Plugin Repositories.
## Acceptance Criteria
- Marketplace builds remain unchanged and download the CLI at runtime.
- Bundled builds use the same tag and source, keep `kilo.cli.pinned=true`, and differ only by `-Pkilo.cli.bundled=true`.
- Bundled ZIPs are signed and attached to the `jetbrains/v<version>` release.
- Runtime extracts the bundled current-platform CLI and never downloads when `kilo-cli.zip` is present.
- Stable releases update the GitHub Pages `updatePlugins.xml` with the latest bundled signed ZIP URL.
+1
View File
@@ -29,6 +29,7 @@ const DIR = path.join(ROOT, ".github", "workflows")
const active = new Set([
"auto-docs.yml",
"beta.yml",
"bundle-jetbrains.yml",
"check-forbidden-strings.yml",
"check-kilo-generated-artifacts.yml",
"check-md-table-padding.yml",