host: build and publish the local server as a signed RPM
Some checks failed
Some checks failed
The banner on a host comes from `npx vibe-kanban` fetching upstream's published 0.1.44, which embeds upstream's frontend. Our fork already fixes that UI -- the revert restored LocalProjectKanban and showCloudShutdownBanner is pinned false in SharedAppLayout, which local-web uses -- it just was not in the binary anyone was running. npx cannot be pointed at us. npx-cli resolves its download from R2_BASE_URL, a placeholder substituted at npm-publish time to BloopAI's bucket, with no runtime override. That bucket serves the sunset binary and dies with them. So build our own. crates/server embeds packages/local-web/dist via rust-embed and crates/server/build.rs reads VK_SHARED_API_BASE at compile time, so one build yields the fixed UI and our endpoints as defaults, with no environment on the command line -- std::env::var still wins at runtime for overrides. Packaged as an RPM to rpm.lair.cafe, mirroring lair/claude-desktop-package: package and publish on the `rpm` runner, sign, rsync to oolon, createrepo under the shared flock. The compile runs first on a podman runner instead, so this never depends on the rpm host's toolchain matching the nightly pinned in rust-toolchain.toml. Built on bookworm although the target is Fedora 43/44: glibc compatibility is forward-only, so one binary covers both. The build fails closed if the sunset strings reappear or the baked endpoint is missing. Needs RPM_SIGNING_KEY, RPM_SIGNING_KEY_ID and RSYNC_SSH_KEY on this repo; they currently exist only on lair/claude-desktop-package.
This commit is contained in:
198
.gitea/workflows/host-release.yml
Normal file
198
.gitea/workflows/host-release.yml
Normal file
@@ -0,0 +1,198 @@
|
||||
name: host-release
|
||||
# Build the LOCAL vibe-kanban server and publish it as a signed RPM to
|
||||
# rpm.lair.cafe, so a host installs it with dnf instead of `npx vibe-kanban`.
|
||||
#
|
||||
# npx is not an option here: npx-cli resolves its download from R2_BASE_URL, a
|
||||
# placeholder substituted at npm-publish time to BloopAI's bucket, with no
|
||||
# runtime override (npx-cli/src/download.ts). That serves upstream's binary --
|
||||
# which still carries the product sunset -- from infrastructure that disappears
|
||||
# when they do.
|
||||
#
|
||||
# Convention mirrors lair/claude-desktop-package: package and publish on the
|
||||
# `rpm` runner, sign with RPM_SIGNING_KEY, rsync to oolon, createrepo under
|
||||
# flock. The compile happens first on a podman runner rather than on the rpm
|
||||
# runner, so this never depends on a host toolchain matching the pinned
|
||||
# nightly in rust-toolchain.toml.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "crates/**"
|
||||
- "packages/local-web/**"
|
||||
- "packages/web-core/**"
|
||||
- "packages/ui/**"
|
||||
- "shared/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- "rpm/**"
|
||||
- ".gitea/workflows/host-release.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: host-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on:
|
||||
- metal
|
||||
- podman
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: derive version
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: build the host binary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# --target export writes the binary straight out of the scratch stage,
|
||||
# so nothing has to be extracted from an image afterwards.
|
||||
mkdir -p out
|
||||
podman build --pull=newer \
|
||||
-f rpm/Containerfile \
|
||||
--target export \
|
||||
--output type=local,dest=out \
|
||||
.
|
||||
test -s out/vibe-kanban
|
||||
chmod 0755 out/vibe-kanban
|
||||
file out/vibe-kanban || true
|
||||
|
||||
- name: sanity-check the binary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The whole point of building our own: upstream's carries the sunset.
|
||||
if grep -qa "Project functionality has been retired" out/vibe-kanban; then
|
||||
echo "ERROR: the sunset page is embedded in the host binary."
|
||||
echo "The revert of #3387 has been lost -- refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
if grep -qa "Cloud is shutting down" out/vibe-kanban; then
|
||||
echo "ERROR: the cloud-shutdown banner is embedded in the host binary."
|
||||
exit 1
|
||||
fi
|
||||
# And it must point at us, not at api.vibekanban.com.
|
||||
grep -qa "kanban.internal" out/vibe-kanban \
|
||||
|| { echo "ERROR: baked API base missing from the binary"; exit 1; }
|
||||
echo "ok: no sunset UI, lair endpoints baked in"
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: host-binary
|
||||
path: out/vibe-kanban
|
||||
retention-days: 7
|
||||
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
commit: ${{ steps.meta.outputs.commit }}
|
||||
|
||||
package:
|
||||
needs: build
|
||||
runs-on: rpm
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
fedora_version: ["43", "44"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: host-binary
|
||||
path: bin/
|
||||
|
||||
- name: rpmbuild
|
||||
run: |
|
||||
set -euo pipefail
|
||||
find bin/ -name vibe-kanban -exec mv --target-directory=bin/ {} + 2>/dev/null || true
|
||||
mkdir -p ~/rpmbuild/SOURCES
|
||||
install -m 0755 bin/vibe-kanban ~/rpmbuild/SOURCES/vibe-kanban
|
||||
rpmbuild -bb rpm/vibe-kanban.spec \
|
||||
--define "vk_version ${{ needs.build.outputs.version }}" \
|
||||
--define "vk_commit ${{ needs.build.outputs.commit }}" \
|
||||
--define "dist .fc${{ matrix.fedora_version }}"
|
||||
ls -l ~/rpmbuild/RPMS/x86_64/
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: rpm-fc${{ matrix.fedora_version }}
|
||||
path: ~/rpmbuild/RPMS/x86_64/*.rpm
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
needs: package
|
||||
runs-on: rpm
|
||||
env:
|
||||
RPM_REPO_HOST: oolon.kosherinata.internal
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
fedora_version: ["43", "44"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: rpm-fc${{ matrix.fedora_version }}
|
||||
path: rpms/
|
||||
|
||||
- name: Flatten RPM artifacts
|
||||
run: |
|
||||
find rpms/ -name '*.rpm' -exec mv --target-directory=rpms/ {} +
|
||||
find rpms/ -mindepth 1 -type d -empty -delete
|
||||
|
||||
- name: Import signing key
|
||||
run: |
|
||||
echo "${{ secrets.RPM_SIGNING_KEY }}" | gpg --batch --import
|
||||
fpr=$(gpg --batch --with-colons --list-keys "${{ secrets.RPM_SIGNING_KEY_ID }}" | awk -F: '/^fpr:/ { print $10; exit }')
|
||||
echo "${fpr}:6:" | gpg --batch --import-ownertrust
|
||||
sed "s/@GPG_NAME@/${{ secrets.RPM_SIGNING_KEY_ID }}/" rpm/rpmmacros > ~/.rpmmacros
|
||||
|
||||
- name: Sign RPMs
|
||||
run: |
|
||||
for rpm in rpms/*.rpm; do
|
||||
echo "signing ${rpm}..."
|
||||
rpm --addsign "${rpm}"
|
||||
done
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
install --directory --mode 700 ~/.ssh
|
||||
echo "${RSYNC_SSH_KEY}" | install --mode 600 /dev/stdin ~/.ssh/id_ed25519
|
||||
env:
|
||||
RSYNC_SSH_KEY: ${{ secrets.RSYNC_SSH_KEY }}
|
||||
|
||||
- name: Test SSH connectivity
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=accept-new "gitea_ci@${RPM_REPO_HOST}" exit
|
||||
|
||||
- name: Sync RPMs to repo
|
||||
run: |
|
||||
rsync \
|
||||
--archive \
|
||||
--verbose \
|
||||
--chmod D755,F644 \
|
||||
rpms/*.rpm \
|
||||
"gitea_ci@${RPM_REPO_HOST}:/var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/"
|
||||
|
||||
- name: Update repo metadata
|
||||
run: |
|
||||
# flock guards createrepo against concurrent publishes into the
|
||||
# shared repo tree (other package repos publish here too).
|
||||
ssh "gitea_ci@${RPM_REPO_HOST}" \
|
||||
"flock /var/www/rpm/.publish.lock -c 'cd /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64 && createrepo_c --update .'"
|
||||
|
||||
- name: Generate packages.json
|
||||
run: |
|
||||
scp rpm/generate-packages-json.py "gitea_ci@${RPM_REPO_HOST}:/tmp/"
|
||||
ssh "gitea_ci@${RPM_REPO_HOST}" \
|
||||
"flock /var/www/rpm/.publish.lock -c 'python3 /tmp/generate-packages-json.py \
|
||||
--repodata-dir /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/repodata \
|
||||
--output /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/packages.json \
|
||||
--base-url https://rpm.lair.cafe/fedora/${{ matrix.fedora_version }}/x86_64'"
|
||||
64
rpm/Containerfile
Normal file
64
rpm/Containerfile
Normal file
@@ -0,0 +1,64 @@
|
||||
# Builder for the LOCAL vibe-kanban server (crates/server) — the binary a host
|
||||
# runs to pair with our remote. Produces /out/vibe-kanban.
|
||||
#
|
||||
# `npx vibe-kanban` cannot be used for this: npx-cli resolves its download from
|
||||
# R2_BASE_URL, a placeholder substituted at npm-publish time to BloopAI's bucket
|
||||
# (npx-cli/src/download.ts) with no runtime override. That bucket ships upstream's
|
||||
# binary -- which carries the product sunset -- and disappears when they do.
|
||||
#
|
||||
# Built on bookworm although the RPM targets Fedora 43/44. glibc compatibility is
|
||||
# forward-only, so a binary linked against bookworm's 2.36 runs on Fedora's newer
|
||||
# glibc; the reverse would not. One binary therefore serves both Fedora targets.
|
||||
|
||||
FROM node:20-alpine AS fe-builder
|
||||
WORKDIR /repo
|
||||
RUN corepack enable
|
||||
|
||||
# Lockfile-first so a source-only change does not re-resolve ~900 packages.
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY packages/local-web/package.json packages/local-web/package.json
|
||||
COPY packages/remote-web/package.json packages/remote-web/package.json
|
||||
COPY packages/ui/package.json packages/ui/package.json
|
||||
COPY packages/web-core/package.json packages/web-core/package.json
|
||||
COPY patches/ patches/
|
||||
RUN --mount=type=cache,id=pnpm-local,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY packages/ packages/
|
||||
COPY shared/ shared/
|
||||
# PostHog/Sentry VITE_ vars are deliberately unset: unset means no telemetry.
|
||||
RUN pnpm -C packages/local-web build
|
||||
|
||||
FROM rust:1.93-slim-bookworm AS builder
|
||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
|
||||
ENV CARGO_TARGET_DIR=/app/target
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Whole-tree copy rather than enumerating workspace manifests: crates/server
|
||||
# pulls in most of the workspace, and an enumerated list silently breaks when a
|
||||
# crate is added. The cargo cache mounts below carry the incremental build.
|
||||
COPY . .
|
||||
COPY --from=fe-builder /repo/packages/local-web/dist packages/local-web/dist
|
||||
|
||||
# Baked at compile time by crates/server/build.rs, so the installed binary points
|
||||
# at our instance with no environment on the command line. std::env::var still
|
||||
# takes precedence at runtime, so either can be overridden per-invocation.
|
||||
ARG VK_SHARED_API_BASE=https://kanban.internal
|
||||
ARG VK_SHARED_RELAY_API_BASE=https://kanban.internal/relay-api
|
||||
ENV VK_SHARED_API_BASE=${VK_SHARED_API_BASE}
|
||||
ENV VK_SHARED_RELAY_API_BASE=${VK_SHARED_RELAY_API_BASE}
|
||||
|
||||
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,id=host-target,target=/app/target \
|
||||
cargo build --release --manifest-path crates/server/Cargo.toml \
|
||||
&& mkdir -p /out \
|
||||
&& cp /app/target/release/server /out/vibe-kanban
|
||||
|
||||
FROM scratch AS export
|
||||
COPY --from=builder /out/vibe-kanban /vibe-kanban
|
||||
154
rpm/generate-packages-json.py
Normal file
154
rpm/generate-packages-json.py
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse RPM repodata and emit a packages.json manifest for the UI."""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
|
||||
RPM_NS = "http://linux.duke.edu/metadata/common"
|
||||
OTHER_NS = "http://linux.duke.edu/metadata/other"
|
||||
REPO_NS = "http://linux.duke.edu/metadata/repo"
|
||||
|
||||
|
||||
def find_repodata_file(repodata_dir, data_type):
|
||||
"""Read repomd.xml and return the path to a specific data type's file."""
|
||||
repomd_path = os.path.join(repodata_dir, "repomd.xml")
|
||||
tree = ET.parse(repomd_path)
|
||||
root = tree.getroot()
|
||||
|
||||
for data in root.findall(f"{{{REPO_NS}}}data"):
|
||||
if data.get("type") == data_type:
|
||||
location = data.find(f"{{{REPO_NS}}}location")
|
||||
if location is not None:
|
||||
href = location.get("href", "")
|
||||
return os.path.join(os.path.dirname(repodata_dir), href)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def open_compressed(path):
|
||||
"""Open a gzip or zstd compressed file for reading."""
|
||||
if path.endswith(".zst"):
|
||||
result = subprocess.run(
|
||||
["zstdcat", path], capture_output=True, check=True
|
||||
)
|
||||
import io
|
||||
return io.BytesIO(result.stdout)
|
||||
else:
|
||||
return gzip.open(path, "rb")
|
||||
|
||||
|
||||
def parse_primary(repodata_dir):
|
||||
"""Parse primary.xml.{gz,zst} and return package metadata."""
|
||||
path = find_repodata_file(repodata_dir, "primary")
|
||||
if not path:
|
||||
print("error: primary metadata not found in repomd.xml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
packages = {}
|
||||
with open_compressed(path) as f:
|
||||
tree = ET.parse(f)
|
||||
|
||||
for pkg in tree.getroot().findall(f"{{{RPM_NS}}}package"):
|
||||
if pkg.get("type") != "rpm":
|
||||
continue
|
||||
|
||||
name = pkg.findtext(f"{{{RPM_NS}}}name", "")
|
||||
version_el = pkg.find(f"{{{RPM_NS}}}version")
|
||||
ver = version_el.get("ver", "") if version_el is not None else ""
|
||||
rel = version_el.get("rel", "") if version_el is not None else ""
|
||||
arch = pkg.findtext(f"{{{RPM_NS}}}arch", "")
|
||||
|
||||
size_el = pkg.find(f"{{{RPM_NS}}}size")
|
||||
size = int(size_el.get("package", "0")) if size_el is not None else 0
|
||||
|
||||
time_el = pkg.find(f"{{{RPM_NS}}}time")
|
||||
build_time = int(time_el.get("build", "0")) if time_el is not None else 0
|
||||
|
||||
location_el = pkg.find(f"{{{RPM_NS}}}location")
|
||||
filename = os.path.basename(location_el.get("href", "")) if location_el is not None else ""
|
||||
|
||||
key = f"{name}-{ver}-{rel}"
|
||||
packages[key] = {
|
||||
"name": name,
|
||||
"version": ver,
|
||||
"release": rel,
|
||||
"arch": arch,
|
||||
"summary": pkg.findtext(f"{{{RPM_NS}}}summary", ""),
|
||||
"size": size,
|
||||
"buildTime": build_time,
|
||||
"rpmFilename": filename,
|
||||
"changelog": [],
|
||||
}
|
||||
|
||||
return packages
|
||||
|
||||
|
||||
def parse_other(repodata_dir, packages):
|
||||
"""Parse other.xml.gz and attach changelog entries to packages."""
|
||||
path = find_repodata_file(repodata_dir, "other")
|
||||
if not path:
|
||||
return
|
||||
|
||||
with open_compressed(path) as f:
|
||||
tree = ET.parse(f)
|
||||
|
||||
for pkg in tree.getroot().findall(f"{{{OTHER_NS}}}package"):
|
||||
name = pkg.get("name", "")
|
||||
version_el = pkg.find(f"{{{OTHER_NS}}}version")
|
||||
ver = version_el.get("ver", "") if version_el is not None else ""
|
||||
rel = version_el.get("rel", "") if version_el is not None else ""
|
||||
key = f"{name}-{ver}-{rel}"
|
||||
|
||||
if key not in packages:
|
||||
continue
|
||||
|
||||
for entry in pkg.findall(f"{{{OTHER_NS}}}changelog"):
|
||||
packages[key]["changelog"].append({
|
||||
"author": entry.get("author", ""),
|
||||
"date": int(entry.get("date", "0")),
|
||||
"text": (entry.text or "").strip(),
|
||||
})
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--repodata-dir",
|
||||
required=True,
|
||||
help="path to the repodata/ directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
required=True,
|
||||
help="path to write packages.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
required=True,
|
||||
help="public base URL for the repo (e.g. https://rpm.lair.cafe/fedora/43/x86_64)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
packages = parse_primary(args.repodata_dir)
|
||||
parse_other(args.repodata_dir, packages)
|
||||
|
||||
manifest = {
|
||||
"generated": datetime.now(timezone.utc).isoformat(),
|
||||
"baseUrl": args.base_url,
|
||||
"packages": list(packages.values()),
|
||||
}
|
||||
|
||||
with open(args.output, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
print(f"wrote {len(packages)} packages to {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
rpm/rpmmacros
Normal file
1
rpm/rpmmacros
Normal file
@@ -0,0 +1 @@
|
||||
%_openpgp_sign_id @GPG_NAME@
|
||||
53
rpm/vibe-kanban.spec
Normal file
53
rpm/vibe-kanban.spec
Normal file
@@ -0,0 +1,53 @@
|
||||
# Prebuilt Rust binary produced by rpm/Containerfile — no compile here, no
|
||||
# debuginfo (matches the house convention in lair/claude-desktop-package).
|
||||
%global _build_id_links none
|
||||
%global debug_package %{nil}
|
||||
%global __strip /usr/bin/true
|
||||
|
||||
# Passed in via --define at rpmbuild time (see .gitea/workflows/host-release.yml).
|
||||
%{!?vk_version: %global vk_version 0.0.0}
|
||||
%{!?vk_commit: %global vk_commit unknown}
|
||||
|
||||
Name: vibe-kanban
|
||||
Version: %{vk_version}
|
||||
Release: 1.g%{vk_commit}%{?dist}
|
||||
Summary: Local vibe-kanban server — pairs a host with the lair remote
|
||||
|
||||
License: Apache-2.0
|
||||
URL: https://git.lair.cafe/lair/vibe-kanban
|
||||
Source0: vibe-kanban
|
||||
ExclusiveArch: x86_64
|
||||
|
||||
# git is invoked at runtime for worktree and branch operations.
|
||||
Requires: git-core
|
||||
|
||||
%description
|
||||
The local half of vibe-kanban: runs on a development host, registers with the
|
||||
relay at https://kanban.internal/relay-api and serves its own UI, so the host's
|
||||
repositories and coding agents can be driven from the remote board.
|
||||
|
||||
Built from lair/vibe-kanban, our fork of BloopAI/vibe-kanban. Upstream sunset
|
||||
the product in April 2026, replacing the kanban board with an export-only page
|
||||
and adding a cloud-shutdown banner; both are reverted in this fork. It is not
|
||||
`npx vibe-kanban`, which downloads upstream's binary from a bucket that carries
|
||||
the sunset and disappears with them.
|
||||
|
||||
The remote and relay endpoints are compiled in, so no configuration is needed on
|
||||
a lair host. Override per-invocation with VK_SHARED_API_BASE and
|
||||
VK_SHARED_RELAY_API_BASE, which take precedence over the baked defaults.
|
||||
|
||||
%prep
|
||||
# Source0 is the bare binary; nothing to unpack.
|
||||
|
||||
%build
|
||||
# Built upstream of rpmbuild, in a container. See rpm/Containerfile.
|
||||
|
||||
%install
|
||||
install -D -m 0755 %{SOURCE0} %{buildroot}%{_bindir}/vibe-kanban
|
||||
|
||||
%files
|
||||
%{_bindir}/vibe-kanban
|
||||
|
||||
%changelog
|
||||
* Tue Jul 21 2026 rob thijssen <rthijssen@gmail.com> - 0.1.44-1
|
||||
- First lair build: sunset reverted, remote and relay endpoints baked in.
|
||||
Reference in New Issue
Block a user