diff --git a/.gitea/workflows/host-release.yml b/.gitea/workflows/host-release.yml new file mode 100644 index 00000000..d030f554 --- /dev/null +++ b/.gitea/workflows/host-release.yml @@ -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'" diff --git a/rpm/Containerfile b/rpm/Containerfile new file mode 100644 index 00000000..c8f64f30 --- /dev/null +++ b/rpm/Containerfile @@ -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 diff --git a/rpm/generate-packages-json.py b/rpm/generate-packages-json.py new file mode 100644 index 00000000..ea663df0 --- /dev/null +++ b/rpm/generate-packages-json.py @@ -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() diff --git a/rpm/rpmmacros b/rpm/rpmmacros new file mode 100644 index 00000000..03ce44d3 --- /dev/null +++ b/rpm/rpmmacros @@ -0,0 +1 @@ +%_openpgp_sign_id @GPG_NAME@ diff --git a/rpm/vibe-kanban.spec b/rpm/vibe-kanban.spec new file mode 100644 index 00000000..05a2adf9 --- /dev/null +++ b/rpm/vibe-kanban.spec @@ -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 - 0.1.44-1 +- First lair build: sunset reverted, remote and relay endpoints baked in.