feat: operational tooling — signing, versioning, changelog, roadmap (SPEC-001)

Establish GuruConnect's release engineering and project tracking (SPEC-001):
- docs/ scaffold: FEATURE_ROADMAP, ARCHITECTURE_DECISIONS (ADR-001 standalone+contract,
  ADR-002 Gitea Actions + Azure Trusted Signing), docs/specs/SPEC-001, CHANGELOG.
- .gitea/workflows/release.yml: conventional-commit auto-versioning, git-cliff changelog,
  Windows agent build, Azure Trusted Signing via jsign (reusing the shared ACG cert profile),
  Gitea release via REST API. build-and-test.yml is the PR/push gate; deploy.yml de-duplicated.
- server: GET /api/changelog/:component/:version (latest + by-version), path-traversal hardened.
- cliff.toml; server/.env.example documents CHANGELOG_DIR.

Reviewed (Code Review Agent): axum route-conflict blocker fixed; CHANGELOG ordering, toolchain
target, breaking-change parsing, empty-changelog fallback addressed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 07:19:29 -07:00
parent e3e95f8fa7
commit 60519be28a
13 changed files with 1014 additions and 31 deletions

View File

@@ -22,6 +22,12 @@ LISTEN_ADDR=0.0.0.0:3002
# If set, persistent agents must provide this key to connect
AGENT_API_KEY=
# Optional: directory containing generated changelog files served at /api/changelog/...
# Must point at the deployed `changelogs/` directory produced by the release workflow
# (containing `LATEST_<COMPONENT>.md` and `<component>/v<version>.md`).
# Defaults to ./changelogs, resolved relative to the server's working directory (CWD) when unset.
CHANGELOG_DIR=./changelogs
# Debug mode (enables verbose logging)
DEBUG=false

125
server/src/api/changelog.rs Normal file
View File

@@ -0,0 +1,125 @@
//! Changelog endpoints — serve per-component release notes from the changelog directory.
//!
//! `GET /api/changelog/:component/:version` serves changelog Markdown for a component:
//! - `version == "latest"` → most recent changelog (`LATEST_AGENT.md`, etc.)
//! - `version == "0.1.0"` (or `v0.1.0`) → a specific version (`agent/v0.1.0.md`)
//!
//! A single route is used because axum 0.7 / matchit 0.7 panics at router construction when a
//! static segment (`latest`) and a path param (`:version`) occupy the same position; the handler
//! dispatches on the literal value `latest` instead.
//!
//! This endpoint is public (no auth), mirroring `GET /api/version`. The base directory is read
//! from the `CHANGELOG_DIR` env var (default `./changelogs`). Both the `component` and `version`
//! path parameters are validated/sanitized to prevent path traversal.
use axum::{
extract::Path,
http::{header, StatusCode},
response::{IntoResponse, Response},
Json,
};
use std::path::PathBuf;
use super::auth::ErrorResponse;
/// Resolve the base changelog directory from the `CHANGELOG_DIR` env var.
///
/// Defaults to `./changelogs` (relative to the server's working directory) when unset.
fn changelog_dir() -> PathBuf {
std::env::var("CHANGELOG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("./changelogs"))
}
/// Whether `component` is a recognized GuruConnect component.
///
/// Restricting to a fixed allow-list is the primary defense against path traversal: an
/// attacker cannot smuggle `..` or absolute paths through this parameter.
fn valid_component(component: &str) -> bool {
matches!(component, "agent" | "server" | "dashboard")
}
/// Build a `404 Not Found` response using the project's standard error envelope.
fn not_found(message: &str) -> Response {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: message.to_string(),
}),
)
.into_response()
}
/// Build a `200 OK` Markdown response.
fn markdown_response(content: String) -> Response {
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/markdown; charset=utf-8")],
content,
)
.into_response()
}
/// `GET /api/changelog/:component/:version`
///
/// Serves changelog Markdown for `component`. The `version` segment is dispatched as follows:
/// - `latest` → `changelogs/LATEST_<COMPONENT>.md` (uppercase component)
/// - a semver string (with or without a leading `v`, e.g. `0.1.0` or `v0.1.0`)
/// → `changelogs/<component>/v<version>.md`
///
/// `component` is validated against the allow-list FIRST; the version (when not `latest`) is
/// sanitized to digits and dots only, rejecting any path-traversal attempt before touching the
/// filesystem.
pub async fn get(Path((component, version)): Path<(String, String)>) -> Response {
// Allow-list check first: the primary defense against path traversal via `component`.
if !valid_component(&component) {
return not_found("Unknown component");
}
// The literal "latest" selects the most-recent changelog file for the component.
if version == "latest" {
let file = changelog_dir().join(format!("LATEST_{}.md", component.to_uppercase()));
return match tokio::fs::read_to_string(&file).await {
Ok(content) => markdown_response(content),
Err(e) => {
tracing::warn!(
"Changelog latest not found for component '{}' ({}): {}",
component,
file.display(),
e
);
not_found("Changelog not found")
}
};
}
// Otherwise treat `version` as a specific release. Accept it with or without a leading 'v';
// work with the bare numeric form.
let bare = version.strip_prefix('v').unwrap_or(&version);
// Sanitize: a valid version is non-empty and contains only digits and dots. This rejects any
// path-traversal attempt (slashes, `..`, backslashes) before touching the filesystem.
let safe = !bare.is_empty()
&& bare.chars().all(|c| c.is_ascii_digit() || c == '.')
&& !bare.contains("..");
if !safe {
return not_found("Invalid version");
}
let file = changelog_dir().join(&component).join(format!("v{bare}.md"));
match tokio::fs::read_to_string(&file).await {
Ok(content) => markdown_response(content),
Err(e) => {
tracing::warn!(
"Changelog not found for component '{}' version 'v{}' ({}): {}",
component,
bare,
file.display(),
e
);
not_found("Changelog not found")
}
}
}

View File

@@ -2,6 +2,7 @@
pub mod auth;
pub mod auth_logout;
pub mod changelog;
pub mod users;
pub mod releases;
pub mod downloads;

View File

@@ -304,6 +304,11 @@ async fn main() -> Result<()> {
.route("/api/releases/:version", put(api::releases::update_release))
.route("/api/releases/:version", delete(api::releases::delete_release))
// Changelog (no auth - public, like /api/version)
// Single route: version == "latest" selects the latest file; axum 0.7 / matchit 0.7
// panics if a static segment and a path param share this position, so do not split it.
.route("/api/changelog/:component/:version", get(api::changelog::get))
// Agent downloads (no auth - public download links)
.route("/api/download/viewer", get(api::downloads::download_viewer))
.route("/api/download/support", get(api::downloads::download_support))