host: build and publish the local server as a signed RPM
Some checks failed
host-release / build (push) Failing after 19m50s
host-release / package (43) (push) Has been skipped
host-release / package (44) (push) Has been skipped
host-release / publish (43) (push) Has been skipped
host-release / publish (44) (push) Has been skipped

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:
rob thijssen
2026-07-21 19:34:06 +03:00
parent d7c912c7f4
commit ce825d9c93
5 changed files with 470 additions and 0 deletions

View 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
View 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

View 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
View File

@@ -0,0 +1 @@
%_openpgp_sign_id @GPG_NAME@

53
rpm/vibe-kanban.spec Normal file
View 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.