diff --git a/.github/workflows/vivado-self-hosted.yml b/.github/workflows/vivado-self-hosted.yml new file mode 100644 index 00000000..47826799 --- /dev/null +++ b/.github/workflows/vivado-self-hosted.yml @@ -0,0 +1,102 @@ +name: Vivado Bitstream (self-hosted) + +on: + workflow_dispatch: + inputs: + design: + description: 'Design to build (blinky | gf16 | phi_heartbeat)' + required: false + default: 'gf16' + uart: + description: 'Include UART telemetry block' + required: false + default: 'true' + type: boolean + pull_request: + paths: + - 'fpga/vivado/**' + - 'specs/fpga/**' + - 'bootstrap/src/**' + - '.github/workflows/vivado-self-hosted.yml' + +jobs: + vivado-build: + runs-on: [self-hosted, vivado, x86_64, linux] + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Print runner info + run: | + uname -a + which vivado || echo "vivado not on PATH" + df -h /opt/Xilinx || true + + - name: Build t27c + run: | + cd bootstrap + cargo build --release --bin t27c + echo "T27C_BIN=$PWD/target/release/t27c" >> "$GITHUB_ENV" + + - name: Generate Verilog from .t27 specs + run: | + DESIGN="${{ github.event.inputs.design || 'gf16' }}" + "$T27C_BIN" fpga-build --smoke --device xc7a100tcsg324-1 --top "${DESIGN}_top" --output build/fpga + ls -la build/fpga/generated/ + + - name: Synthesize with Vivado + run: | + DESIGN="${{ github.event.inputs.design || 'gf16' }}" + UART_FLAG="${{ github.event.inputs.uart || 'true' }}" + cd fpga/vivado + vivado -mode batch -nojournal -nolog \ + -source "build_${DESIGN}.tcl" \ + -tclargs --uart "$UART_FLAG" --device xc7a100tcsg324-1 \ + 2>&1 | tee vivado.log + + - name: Run UART smoke (capture tok/s) + run: | + # Requires DLC-10 JTAG attached on runner host (optional path). + # Skipped when LANES_ATTACHED=0; only metadata-extract from Vivado log. + if [ "${LANES_ATTACHED:-0}" = "1" ]; then + cd fpga/vivado + timeout 30 cargo run --release --bin dlc10 -- program *.bit + timeout 30 cargo run --release --bin uart-smoke -- --port /dev/ttyUSB1 --duration 10 | tee tok_s.txt + else + echo "UART/JTAG hardware not attached on this runner; bit-only build." + fi + + - name: Extract utilization summary + run: | + cd fpga/vivado + awk ' + /Slice LUTs/ {print} + /Slice Registers/ {print} + /Block RAM Tile/ {print} + /DSPs/ {print} + /WNS\(ns\)/ {getline; print} + ' vivado.log > utilization_summary.txt || true + cat utilization_summary.txt + + - name: Upload bitstream + uses: actions/upload-artifact@v4 + if: always() + with: + name: vivado-bitstream-${{ github.event.inputs.design || 'gf16' }} + path: | + fpga/vivado/*.bit + fpga/vivado/*.ltx + retention-days: 30 + + - name: Upload Vivado logs & utilization + uses: actions/upload-artifact@v4 + if: always() + with: + name: vivado-log-${{ github.event.inputs.design || 'gf16' }} + path: | + fpga/vivado/vivado.log + fpga/vivado/utilization_summary.txt + fpga/vivado/tok_s.txt + retention-days: 30 diff --git a/infra/railway-vivado-runner/Cargo.toml b/infra/railway-vivado-runner/Cargo.toml new file mode 100644 index 00000000..ac631f76 --- /dev/null +++ b/infra/railway-vivado-runner/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "railway-vivado-entrypoint" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "entrypoint" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0" diff --git a/infra/railway-vivado-runner/Dockerfile b/infra/railway-vivado-runner/Dockerfile new file mode 100644 index 00000000..dbc5273e --- /dev/null +++ b/infra/railway-vivado-runner/Dockerfile @@ -0,0 +1,47 @@ +# Railway Vivado Self-Hosted Runner +# Base: Ubuntu 22.04 x86_64 (Vivado 2025.2 requirement) +# Final image size: ~150 GB (Vivado install ~110 GB + Ubuntu + runner) +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +# System deps for Vivado + GH Actions runner +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl wget jq git xz-utils \ + libc6 libstdc++6 libtinfo5 libncurses5 libx11-6 libxext6 libxrender1 libxtst6 libxi6 \ + libfontconfig1 libfreetype6 libgtk2.0-0 libcanberra-gtk-module libgomp1 \ + libusb-1.0-0 libssl-dev \ + build-essential pkg-config \ + sudo locales \ + && locale-gen en_US.UTF-8 \ + && rm -rf /var/lib/apt/lists/* + +ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 + +# Install Rust (for t27c build inside runner) +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable \ + && ln -s /root/.cargo/bin/* /usr/local/bin/ + +# Vivado installer is mounted from a Railway volume at /opt/installer +# Run scripts/install_vivado.sh after first container start (or in build stage with the installer COPY'd) +RUN mkdir -p /opt/Xilinx /opt/installer /actions-runner + +# GH Actions runner v2.319.1 (x64 Linux) +WORKDIR /actions-runner +RUN curl -sL https://github.com/actions/runner/releases/download/v2.319.1/actions-runner-linux-x64-2.319.1.tar.gz \ + | tar xz \ + && ./bin/installdependencies.sh + +# Build Rust entrypoint (project policy: no .sh / .py — Rust only) +COPY Cargo.toml /entrypoint-src/Cargo.toml +COPY src /entrypoint-src/src +RUN cd /entrypoint-src \ + && cargo build --release --bin entrypoint \ + && cp target/release/entrypoint /usr/local/bin/runner-entrypoint \ + && rm -rf /entrypoint-src /root/.cargo/registry /root/.cargo/git + +# Vivado settings sourcing +ENV PATH="/opt/Xilinx/Vivado/2025.2/bin:${PATH}" + +ENTRYPOINT ["/usr/local/bin/runner-entrypoint"] diff --git a/infra/railway-vivado-runner/README.md b/infra/railway-vivado-runner/README.md new file mode 100644 index 00000000..dc051f9d --- /dev/null +++ b/infra/railway-vivado-runner/README.md @@ -0,0 +1,130 @@ +# Railway Vivado Self-Hosted Runner + +Production runner host for Xilinx Vivado 2025.2 (x86_64 Linux), exposed to t27 via GitHub Actions self-hosted runner. Unblocks PR #604 Vivado-CI pipeline and tok/s measurement on silicon. + +## Why Railway + +- M4 Mac (228 GB SSD, 11 GB free, ARM64) cannot host Vivado: needs ~110 GB and x86_64 Linux. +- Railway provides managed x86_64 Linux containers with persistent volumes — fits Vivado install footprint without on-prem hardware. +- Tailscale already configured on Mac for local-bridge access (`https://gaia-macbook-air.tail2c3a29.ts.net`); Railway is the build/synth-side counterpart. + +## One-time setup + +### 1. Install Railway CLI + +```bash +cargo install railway-cli # or use the npm install if preferred +railway login +``` + +### 2. Create a new Railway project + +```bash +cd infra/railway-vivado-runner +railway init --name t27-vivado-runner +railway link +``` + +### 3. Provision the 3 volumes + +In Railway UI → Project → Volumes: + +| Mount path | Size | Purpose | +| ------------------------- | ------- | ------------------------------------ | +| `/opt/Xilinx` | 130 GB | Vivado install (survives redeploys) | +| `/opt/installer` | 1 GB | Drop Vivado_2025.2_Lin64.bin once | +| `/actions-runner/_work` | 20 GB | Per-job build cache | + +### 4. Upload Vivado installer + +From the Mac (where Vivado_2025.2_Lin64.bin already lives in ~/Downloads): + +```bash +# 347 MB installer — Railway volume sync via SSH or s3-style endpoint +railway volume push ~/Downloads/Vivado_2025.2_Lin64.bin /opt/installer/ +``` + +### 5. Obtain a GH Actions registration token + +GitHub → t27 → Settings → Actions → Runners → New self-hosted runner → Linux x64 → copy the token from the `./config.sh --token ` line. + +Token expires in ~1 hour; you can re-fetch and `railway redeploy` if it lapses before first successful registration. + +### 6. Set Railway env vars + +```bash +railway variables set GH_RUNNER_TOKEN= +railway variables set GH_REPO_URL=https://github.com/gHashTag/t27 +railway variables set RUNNER_NAME=railway-vivado-prod +railway variables set RUNNER_LABELS=vivado,x86_64,linux,railway +``` + +### 7. Deploy + +```bash +railway up +``` + +First boot will: +1. Build Dockerfile (~5 min) +2. Register the runner with GitHub +3. Wait for jobs + +### 8. Install Vivado (first deploy only) + +Open Railway shell into the running container and run the silent installer once: + +```bash +railway run bash +cd /opt/installer +./Vivado_2025.2_Lin64.bin --batch Install \ + --product Vivado \ + --edition "Vivado ML Standard" \ + --location /opt/Xilinx \ + --agree XilinxEULA,3rdPartyEULA \ + --batch CONFIG \ + --installconfig /opt/installer/install_config.txt +``` + +(Generate `install_config.txt` first by running `./Vivado_2025.2_Lin64.bin -- ConfigGen` interactively on a workstation, then `railway volume push` it.) + +Persistent volume means this is one-time only; subsequent redeploys reuse `/opt/Xilinx`. + +### 9. Verify + +```bash +railway logs --tail +# Should show: +# [entrypoint] Vivado env sourced from /opt/Xilinx/Vivado/2025.2/settings64.sh +# [entrypoint] Launching ./run.sh (GH Actions runner) +# √ Connected to GitHub +# Listening for Jobs +``` + +GitHub → t27 → Settings → Actions → Runners → should list `railway-vivado-prod` as Idle / Online. + +## CI integration + +`.github/workflows/vivado-self-hosted.yml` (added in this PR) targets `runs-on: [self-hosted, vivado, x86_64]`. Existing `.github/workflows/vivado-bitstream.yml` is left intact (uses Docker on github-hosted runner, but is unreliable due to disk constraints). + +## Cost estimate + +Railway Pro plan, 8 vCPU / 32 GB RAM, 150 GB volumes: +- Compute: ~$20–40/month idle (auto-scales down between jobs) +- Storage: ~$15/month for 150 GB +- Estimated total: **$35–55/month** active development + +For lower cost, scale down to 4 vCPU / 16 GB outside synth windows. + +## Maintenance + +- **Vivado upgrade**: stop the service, mount `/opt/Xilinx` to a temporary container, run `./xinstall.sh -m upgrade --product Vivado`. +- **Token rotation**: GitHub registration tokens expire after registration; the runner stays connected via long-lived auth. To re-register, delete `.runner` file in `/actions-runner/_work/` parent dir and redeploy with a fresh `GH_RUNNER_TOKEN`. +- **Graceful shutdown**: `railway down` → entrypoint trap removes the runner registration before exit. + +## Security + +- `GH_RUNNER_TOKEN` is a short-lived registration token; no long-term secrets in image. +- Runner is scoped to a single repo (not org-wide). +- Tailscale not required for Railway → GitHub direction (outbound HTTPS only). +- Vivado license: project relies on Vivado ML Standard (free) feature set; no paid license file shipped. diff --git a/infra/railway-vivado-runner/railway.toml b/infra/railway-vivado-runner/railway.toml new file mode 100644 index 00000000..2bf659af --- /dev/null +++ b/infra/railway-vivado-runner/railway.toml @@ -0,0 +1,35 @@ +# Railway service definition for Vivado self-hosted GitHub Actions runner. +# Deploy: `railway up --service vivado-runner` from this directory. +# Required Railway env vars: +# GH_RUNNER_TOKEN — registration token from Settings -> Actions -> Runners -> New self-hosted (expires ~1h, re-deploy to refresh) +# GH_REPO_URL — e.g. https://github.com/gHashTag/t27 +# RUNNER_NAME — optional, defaults to railway-vivado +# RUNNER_LABELS — optional, defaults to "vivado,x86_64,linux" + +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 3 + +# Required: persistent volume for Vivado install (~110 GB) + runner work dir +# Mount paths inside container: +# /opt/Xilinx — Vivado install (survives redeploys) +# /opt/installer — drop Vivado_2025.2_Lin64.bin here once via railway volume sync +# /actions-runner/_work — runner build cache +[[volumes]] +mountPath = "/opt/Xilinx" +sizeGB = 130 + +[[volumes]] +mountPath = "/opt/installer" +sizeGB = 1 + +[[volumes]] +mountPath = "/actions-runner/_work" +sizeGB = 20 + +# Resource hints (Railway Pro plan recommended; Vivado synth needs RAM) +# Set in Railway UI: 8 vCPU / 32 GB RAM for full synth, 4 vCPU / 16 GB for smoke diff --git a/infra/railway-vivado-runner/src/main.rs b/infra/railway-vivado-runner/src/main.rs new file mode 100644 index 00000000..817ee0b8 --- /dev/null +++ b/infra/railway-vivado-runner/src/main.rs @@ -0,0 +1,92 @@ +// Railway Vivado self-hosted GH Actions runner entrypoint. +// Required env: GH_RUNNER_TOKEN, GH_REPO_URL +// Optional env: RUNNER_NAME, RUNNER_LABELS, RUNNER_WORK +// +// This binary replaces the typical bash entrypoint.sh per project policy: +// no .sh / .py allowed; orchestration in Rust. + +use anyhow::{Context, Result, bail}; +use std::env; +use std::path::Path; +use std::process::{Command, Stdio}; + +fn env_or_default(key: &str, default: &str) -> String { + env::var(key).unwrap_or_else(|_| default.to_string()) +} + +fn env_required(key: &str) -> Result { + env::var(key).with_context(|| format!("env var {key} is required")) +} + +fn source_vivado() -> Result<()> { + let settings = "/opt/Xilinx/Vivado/2025.2/settings64.sh"; + if Path::new(settings).exists() { + // Capture env after sourcing + let out = Command::new("bash") + .arg("-c") + .arg(format!("source {settings} && env")) + .output() + .context("failed to source Vivado settings")?; + if !out.status.success() { + bail!("vivado settings source failed: {}", String::from_utf8_lossy(&out.stderr)); + } + for line in String::from_utf8_lossy(&out.stdout).lines() { + if let Some((k, v)) = line.split_once('=') { + env::set_var(k, v); + } + } + eprintln!("[entrypoint] Vivado env sourced from {settings}"); + } else { + eprintln!("[entrypoint] WARN: {settings} not found; Vivado may not be installed yet"); + } + Ok(()) +} + +fn run(cmd: &str, args: &[&str]) -> Result<()> { + let status = Command::new(cmd) + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .with_context(|| format!("spawn {cmd}"))?; + if !status.success() { + bail!("{cmd} exited with {status}"); + } + Ok(()) +} + +fn main() -> Result<()> { + let runner_dir = "/actions-runner"; + env::set_current_dir(runner_dir).context("cd /actions-runner")?; + + let token = env_required("GH_RUNNER_TOKEN")?; + let repo_url = env_required("GH_REPO_URL")?; + let runner_name = env_or_default("RUNNER_NAME", "railway-vivado"); + let labels = env_or_default("RUNNER_LABELS", "vivado,x86_64,linux"); + let work = env_or_default("RUNNER_WORK", "/actions-runner/_work"); + + // First-time registration is idempotent: skip if .runner exists. + if !Path::new(".runner").exists() { + eprintln!("[entrypoint] Registering runner '{runner_name}' against {repo_url}"); + run( + "./config.sh", + &[ + "--unattended", + "--url", &repo_url, + "--token", &token, + "--name", &runner_name, + "--labels", &labels, + "--work", &work, + "--replace", + ], + )?; + } else { + eprintln!("[entrypoint] Runner already configured; starting"); + } + + source_vivado()?; + + eprintln!("[entrypoint] Launching ./run.sh (GH Actions runner)"); + run("./run.sh", &[]) +}