From 359bb937b298fda8bdc2ed10582dbdf70576bf08 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 14:40:05 -0500 Subject: [PATCH 1/2] style(sidecar): apply rustfmt Format the sidecar source with `cargo fmt` so the new `cargo fmt --check` CI gate passes on the first run. No behavior change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- sidecar/src/config.rs | 5 ++++- sidecar/src/main.rs | 6 +++++- sidecar/src/rpc.rs | 5 +---- sidecar/src/store.rs | 11 ++++++++--- sidecar/src/web.rs | 29 ++++++++++++++++++++--------- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/sidecar/src/config.rs b/sidecar/src/config.rs index 984f640..846fd3a 100644 --- a/sidecar/src/config.rs +++ b/sidecar/src/config.rs @@ -142,7 +142,10 @@ fn persist_token(path: &str, token: &str) -> anyhow::Result<()> { let text = fs::read_to_string(path)?; let line = format!("auth_token = \"{token}\""); - if text.lines().any(|l| l.trim_start().starts_with("auth_token")) { + if text + .lines() + .any(|l| l.trim_start().starts_with("auth_token")) + { let out: String = text .lines() .map(|l| { diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 5bf2e41..61d3066 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -96,7 +96,11 @@ async fn main() -> anyhow::Result<()> { // Persist, then broadcast. `pong` and `ws.hello` are ephemeral chatter, not history. if ev.kind != "pong" { - let t = ev.value.get("t").and_then(|v| v.as_i64()).unwrap_or_else(now_ms); + let t = ev + .value + .get("t") + .and_then(|v| v.as_i64()) + .unwrap_or_else(now_ms); let text = ev.value.to_string(); if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await { tracing::warn!(error = %e, "failed to persist event"); diff --git a/sidecar/src/rpc.rs b/sidecar/src/rpc.rs index d4b09be..2f120c9 100644 --- a/sidecar/src/rpc.rs +++ b/sidecar/src/rpc.rs @@ -56,10 +56,7 @@ impl Rpc { ) -> Result { let (tx, rx) = oneshot::channel(); - self.pending - .lock() - .await - .insert(corr_val.to_string(), tx); + self.pending.lock().await.insert(corr_val.to_string(), tx); if !shard.send(command.to_string()).await { self.pending.lock().await.remove(corr_val); diff --git a/sidecar/src/store.rs b/sidecar/src/store.rs index 945f166..64ad25c 100644 --- a/sidecar/src/store.rs +++ b/sidecar/src/store.rs @@ -21,8 +21,8 @@ pub struct Store { impl Store { /// Opens (creating if absent) the SQLite database and ensures the schema exists. pub async fn open(path: &str) -> anyhow::Result { - let opts = SqliteConnectOptions::from_str(&format!("sqlite://{path}"))? - .create_if_missing(true); + let opts = + SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?.create_if_missing(true); let pool = SqlitePoolOptions::new() .max_connections(4) @@ -78,7 +78,12 @@ impl Store { self.recent(Some("economy.supply"), limit).await } - pub async fn record_link(&self, account: &str, website_user_id: &str, t: i64) -> anyhow::Result<()> { + pub async fn record_link( + &self, + account: &str, + website_user_id: &str, + t: i64, + ) -> anyhow::Result<()> { sqlx::query( "INSERT INTO links (account, website_user_id, linked_t) VALUES (?, ?, ?) ON CONFLICT(account) DO UPDATE SET website_user_id = excluded.website_user_id, linked_t = excluded.linked_t", diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 4f0c9dd..e8675e3 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -123,7 +123,8 @@ fn iso_ms(ms: i64) -> Option { if ms <= 0 { return None; } - chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + chrono::DateTime::from_timestamp_millis(ms) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) } // ---- gate: protocol check + auth ---- @@ -174,8 +175,15 @@ async fn gate(State(st): State, req: Request, next: Next) -> Response fn extract_token(req: &Request) -> Option { // Authorization: Bearer - if let Some(v) = req.headers().get("authorization").and_then(|h| h.to_str().ok()) { - if let Some(rest) = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")) { + if let Some(v) = req + .headers() + .get("authorization") + .and_then(|h| h.to_str().ok()) + { + if let Some(rest) = v + .strip_prefix("Bearer ") + .or_else(|| v.strip_prefix("bearer ")) + { return Some(rest.trim().to_string()); } } @@ -349,7 +357,10 @@ async fn page_respond( Path(id): Path, Json(body): Json, ) -> impl IntoResponse { - let message = body.get("message").and_then(|m| m.as_str()).unwrap_or_default(); + let message = body + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or_default(); if message.trim().is_empty() { return ( StatusCode::BAD_REQUEST, @@ -439,7 +450,10 @@ async fn vendors(State(st): State, Path(account): Path) -> imp /// Body: {"code":"AB12CD","websiteUserId":"9931"}. Correlated on `code`. async fn link_confirm(State(st): State, Json(body): Json) -> impl IntoResponse { - let code = body.get("code").and_then(|c| c.as_str()).unwrap_or_default(); + let code = body + .get("code") + .and_then(|c| c.as_str()) + .unwrap_or_default(); let web_id = body .get("websiteUserId") .and_then(|w| w.as_str()) @@ -501,10 +515,7 @@ async fn towncrier_add(State(st): State, Json(body): Json) -> i respond(st.rpc.call(&st.shard, cmd, &id).await) } -async fn towncrier_remove( - State(st): State, - Path(id): Path, -) -> impl IntoResponse { +async fn towncrier_remove(State(st): State, Path(id): Path) -> impl IntoResponse { let cmd = json!({"kind":"towncrier.remove","id":id}); respond(st.rpc.call(&st.shard, cmd, &id).await) } From 9df337e186bdf59c10d89c5d2efcc9df28987bff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 14:40:05 -0500 Subject: [PATCH 2/2] ci(sidecar): add Gitea Actions release workflow Auto-build and release the Rust sidecar on every push to main. A language-agnostic "release engine" computes the next version from conventional-commit subjects since the last v* tag (feat!/BREAKING -> major, feat -> minor, fix|perf -> patch; first run ships the current Cargo.toml version). An isolated "Rust adapter" runs cargo fmt --check / test, builds x86_64-unknown-linux-gnu, and cross-builds x86_64-pc-windows-gnu via MinGW (libsqlite3-sys is the only native dep). It then commits the version bump ([skip ci]), tags vX.Y.Z, pushes, and creates the Gitea release with the linux binary, windows .exe, and SHA256SUMS. Reuses the REGISTRY_USER / REGISTRY_TOKEN secrets; the token additionally needs write:repository scope and main must accept a direct push. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- .gitea/workflows/release.yml | 237 +++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 .gitea/workflows/release.yml diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..5fef5ef --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,237 @@ +# Automated build + release for the uo-link Rust sidecar. +# +# Trigger: every push to `main` (i.e. every merged PR). +# +# Flow (two conceptual halves, kept separate on purpose): +# +# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐ +# │ reads: latest v* git tag + conventional-commit subjects │ +# │ produces: next version, changelog, and (at the end) the release │ +# └───────────────────────────────────────────────────────────────────┘ +# ┌── RUST ADAPTER (the only Rust-specific part) ─────────────────────┐ +# │ consumes: the version │ +# │ produces: the artifacts (linux bin, windows exe, SHA256SUMS) │ +# └───────────────────────────────────────────────────────────────────┘ +# +# To retarget this engine at a C#/Node/Docker/static project later, only the +# "Rust adapter" steps change — the plan + release steps consume just +# {version, changelog, artifacts} and know nothing about Rust. +# +# Version bump (conventional commits since the last v* tag): +# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch +# nothing releasable -> no release is cut +# (first ever run, no tag) -> releases the current Cargo.toml version as-is +# +# Prerequisites (Settings → Actions → Secrets on UOM/link): +# REGISTRY_USER — Gitea username the token below belongs to +# REGISTRY_TOKEN — Gitea access token. For image builds it needed +# write:package; THIS workflow additionally needs +# `write:repository` so it can push the bump commit + tag +# and create the release. Grant that scope to the token. +# Also: `main` must accept a direct push from that user (disable branch +# protection for it, or add it as an exception) — the bump commit lands on main. +# +# The bump commit carries `[skip ci]`, so it does not re-trigger this workflow. + +name: Release sidecar + +on: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: release-sidecar + cancel-in-progress: false + +env: + GITEA_HOST: gitea.whitlocktech.com + REPO: UOM/link + WORKDIR: sidecar + BIN: uo-link-sidecar + LINUX_TARGET: x86_64-unknown-linux-gnu + WINDOWS_TARGET: x86_64-pc-windows-gnu + +jobs: + release: + runs-on: ubuntu-latest + # Don't loop on our own bump commit (belt-and-suspenders with [skip ci]). + if: ${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }} + steps: + - name: Check out full history (need tags + commit log for the bump) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ── RELEASE ENGINE: decide the next version + changelog ────────────── + - name: Plan the release (version + changelog) + id: plan + run: | + set -euo pipefail + mkdir -p dist + git fetch --tags --force >/dev/null 2>&1 || true + + CARGO_VERSION="$(grep -m1 '^version' "${WORKDIR}/Cargo.toml" | sed -E 's/.*"([^"]+)".*/\1/')" + LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)" + if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi + + SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)" + BODIES="$(git log --no-merges --format='%B' $RANGE || true)" + + BUMP=none + if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi + if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi + if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi + if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi + + bump() { # -> bumped + IFS=. read -r MA MI PA <<< "$1" + case "$2" in + major) echo "$((MA+1)).0.0" ;; + minor) echo "${MA}.$((MI+1)).0" ;; + patch) echo "${MA}.${MI}.$((PA+1))" ;; + esac + } + + RELEASE=true + if [ -z "$LAST_TAG" ]; then + VERSION="$CARGO_VERSION" # first release: ship what's in Cargo.toml + elif [ "$BUMP" = none ]; then + RELEASE=false # no feat/fix/breaking since last tag + VERSION="${LAST_TAG#v}" + else + VERSION="$(bump "${LAST_TAG#v}" "$BUMP")" + fi + + if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + echo "Tag v${VERSION} already exists — nothing to release." + RELEASE=false + fi + + { + echo "## ${BIN} v${VERSION}" + echo + FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)" + FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)" + [ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; } + [ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; } + echo "### All changes" + if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi + echo "$SUBJECTS" | sed 's/^/- /' + } > dist/CHANGELOG.md + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" + echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} last_tag=${LAST_TAG:-}" + + # ── RUST ADAPTER: toolchain + cross-compile deps ───────────────────── + - name: Install Rust toolchain, Windows target, and MinGW linker + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" + $SUDO apt-get update + $SUDO apt-get install -y --no-install-recommends \ + build-essential gcc-mingw-w64-x86-64 curl ca-certificates git jq + + if ! command -v cargo >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain stable + fi + echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH" + export PATH="${HOME}/.cargo/bin:${PATH}" + rustup component add rustfmt + rustup target add "${WINDOWS_TARGET}" + + - name: Set the crate version to match the release + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + VERSION="${{ steps.plan.outputs.version }}" + # Replace only the [package] version (the first `version = "..."`). + sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"${VERSION}\"/" "${WORKDIR}/Cargo.toml" + grep -m1 '^version' "${WORKDIR}/Cargo.toml" + + # ── RUST ADAPTER: gates ────────────────────────────────────────────── + - name: cargo fmt --check + if: ${{ steps.plan.outputs.release == 'true' }} + working-directory: sidecar + run: cargo fmt --check + + - name: cargo test + if: ${{ steps.plan.outputs.release == 'true' }} + working-directory: sidecar + run: cargo test --locked + + # ── RUST ADAPTER: build both targets ───────────────────────────────── + - name: cargo build --release (Linux) + if: ${{ steps.plan.outputs.release == 'true' }} + working-directory: sidecar + run: cargo build --release --locked --target "${LINUX_TARGET}" + + - name: cargo build --release (Windows, cross via MinGW) + if: ${{ steps.plan.outputs.release == 'true' }} + working-directory: sidecar + env: + CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc + CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc + AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar + run: cargo build --release --locked --target "${WINDOWS_TARGET}" + + # ── RUST ADAPTER: package artifacts (+ checksums) ──────────────────── + - name: Package artifacts and SHA256SUMS + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + cp "${WORKDIR}/target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64" + cp "${WORKDIR}/target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe" + ( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" > SHA256SUMS ) + ls -l dist && echo "----" && cat dist/SHA256SUMS + + # ── RELEASE ENGINE: commit the bump, tag, push ─────────────────────── + - name: Commit version bump and push tag + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + VERSION="${{ steps.plan.outputs.version }}" + TAG="${{ steps.plan.outputs.tag }}" + git config user.name "uo-link-ci" + git config user.email "ci@whitlocktech.com" + git remote set-url origin \ + "https://${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}@${GITEA_HOST}/${REPO}.git" + + git add "${WORKDIR}/Cargo.toml" "${WORKDIR}/Cargo.lock" + if ! git diff --cached --quiet; then + git commit -m "chore(release): bump version to ${TAG} [skip ci]" + git push origin "HEAD:main" + else + echo "Version unchanged (first release) — no bump commit needed." + fi + git tag "${TAG}" + git push origin "${TAG}" + + # ── RELEASE ENGINE: create the Gitea release + upload assets ───────── + - name: Create Gitea release and upload assets + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + TAG="${{ steps.plan.outputs.tag }}" + API="https://${GITEA_HOST}/api/v1/repos/${REPO}" + BODY="$(cat dist/CHANGELOG.md)" + + REL_ID="$(curl -sSf -X POST "${API}/releases" \ + -H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \ + '{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \ + | jq -r '.id')" + echo "Created release ${TAG} (id=${REL_ID})" + + for f in "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do + curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ + -H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \ + -F "attachment=@dist/${f}" >/dev/null + echo " uploaded ${f}" + done