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:
@@ -1,5 +1,10 @@
|
|||||||
name: Build and Test
|
name: Build and Test
|
||||||
|
|
||||||
|
# PR/push CI gate (SPEC-001): fmt, clippy -D warnings, build, test, cargo-audit.
|
||||||
|
# This workflow does NOT version, sign, or release — that is release.yml's job. The agent build
|
||||||
|
# here is a compile gate only (it produces an unsigned artifact for inspection). Release commits
|
||||||
|
# carry `[skip ci]` so this workflow does not re-run on the version-bump commit.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -77,7 +82,8 @@ jobs:
|
|||||||
uses: actions-rs/toolchain@v1
|
uses: actions-rs/toolchain@v1
|
||||||
with:
|
with:
|
||||||
toolchain: stable
|
toolchain: stable
|
||||||
target: x86_64-pc-windows-msvc
|
# Single source of truth for the Windows target used by the build below.
|
||||||
|
target: x86_64-pc-windows-gnu
|
||||||
override: true
|
override: true
|
||||||
|
|
||||||
- name: Install cross-compilation tools
|
- name: Install cross-compilation tools
|
||||||
@@ -100,7 +106,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build agent (cross-compile for Windows)
|
- name: Build agent (cross-compile for Windows)
|
||||||
run: |
|
run: |
|
||||||
rustup target add x86_64-pc-windows-gnu
|
# Target is installed by the toolchain step above (single source of truth).
|
||||||
cd agent
|
cd agent
|
||||||
cargo build --release --target x86_64-pc-windows-gnu
|
cargo build --release --target x86_64-pc-windows-gnu
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
name: Deploy to Production
|
name: Deploy to Production
|
||||||
|
|
||||||
|
# Server deployment only. Release creation and agent signing live in release.yml (SPEC-001) —
|
||||||
|
# this workflow no longer creates releases, so there is exactly one release producer in the repo.
|
||||||
|
#
|
||||||
|
# Triggers on a pushed vX.Y.Z tag (which release.yml creates) or manual dispatch. The previous
|
||||||
|
# GitHub-only `actions/create-release@v1` + GITHUB_TOKEN job has been removed; it does not work on
|
||||||
|
# Gitea. Gitea releases are produced by release.yml via the Gitea REST API.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
@@ -30,6 +37,11 @@ jobs:
|
|||||||
toolchain: stable
|
toolchain: stable
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y pkg-config libssl-dev protobuf-compiler
|
||||||
|
|
||||||
- name: Build server
|
- name: Build server
|
||||||
run: |
|
run: |
|
||||||
cd server
|
cd server
|
||||||
@@ -37,12 +49,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Create deployment package
|
- name: Create deployment package
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
mkdir -p deploy
|
mkdir -p deploy
|
||||||
cp server/target/x86_64-unknown-linux-gnu/release/guruconnect-server deploy/
|
cp server/target/x86_64-unknown-linux-gnu/release/guruconnect-server deploy/
|
||||||
cp -r server/static deploy/
|
cp -r server/static deploy/
|
||||||
cp -r server/migrations deploy/
|
cp -r server/migrations deploy/
|
||||||
|
# Ship generated changelogs so the server's /api/changelog endpoint can serve them
|
||||||
|
# (CHANGELOG_DIR points at this directory in production).
|
||||||
|
if [ -d changelogs ]; then cp -r changelogs deploy/; fi
|
||||||
cp server/.env.example deploy/.env.example
|
cp server/.env.example deploy/.env.example
|
||||||
tar -czf guruconnect-server-${{ github.ref_name }}.tar.gz -C deploy .
|
tar -czf guruconnect-server-${{ github.ref_name }}.tar.gz -C deploy .
|
||||||
|
echo "[OK] Packaged guruconnect-server-${{ github.ref_name }}.tar.gz"
|
||||||
|
|
||||||
- name: Upload deployment package
|
- name: Upload deployment package
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
@@ -54,35 +71,8 @@ jobs:
|
|||||||
- name: Deploy to server (production)
|
- name: Deploy to server (production)
|
||||||
if: github.event.inputs.environment == 'production' || startsWith(github.ref, 'refs/tags/')
|
if: github.event.inputs.environment == 'production' || startsWith(github.ref, 'refs/tags/')
|
||||||
run: |
|
run: |
|
||||||
echo "Deployment command would run here"
|
echo "[INFO] Deployment command would run here"
|
||||||
echo "SSH to 172.16.3.30 and deploy"
|
echo "[INFO] SSH to 172.16.3.30 and deploy"
|
||||||
# Actual deployment would use SSH keys and run:
|
# Actual deployment would use SSH keys and run:
|
||||||
# scp guruconnect-server-*.tar.gz guru@172.16.3.30:/tmp/
|
# scp guruconnect-server-*.tar.gz guru@172.16.3.30:/tmp/
|
||||||
# ssh guru@172.16.3.30 'bash /home/guru/guru-connect/scripts/deploy.sh'
|
# ssh guru@172.16.3.30 'bash /home/guru/guru-connect/scripts/deploy.sh'
|
||||||
|
|
||||||
create-release:
|
|
||||||
name: Create GitHub Release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: deploy-server
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Download artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
uses: actions/create-release@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref_name }}
|
|
||||||
release_name: Release ${{ github.ref_name }}
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
|
|
||||||
- name: Upload Release Assets
|
|
||||||
run: |
|
|
||||||
echo "Upload server and agent binaries to release"
|
|
||||||
# Would attach artifacts to the release here
|
|
||||||
|
|||||||
484
.gitea/workflows/release.yml
Normal file
484
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# SPEC-001 §2/§3/§4 — auto-versioning, signed Windows build, changelog generation, release.
|
||||||
|
#
|
||||||
|
# On every push to main this workflow:
|
||||||
|
# 1. version — determine the next semver from conventional commits, bump component manifests,
|
||||||
|
# commit `chore: release vX.Y.Z [skip ci]`, and create + push tag vX.Y.Z.
|
||||||
|
# 2. changelog — generate CHANGELOG.md + per-component changelogs with git-cliff (run inside
|
||||||
|
# the version job so it is part of the release commit).
|
||||||
|
# 3. build — cross-compile the Windows agent (x86_64-pc-windows-gnu) to guruconnect.exe.
|
||||||
|
# 4. sign — sign guruconnect.exe with Azure Trusted Signing via jsign (fails the job if
|
||||||
|
# signing fails — never publish unsigned).
|
||||||
|
# 5. publish — upload signed exe + .sha256 + changelog artifacts; create a Gitea release.
|
||||||
|
#
|
||||||
|
# Loop guard: the workflow skips entirely when the head commit is a release commit
|
||||||
|
# (`chore: release` / `[skip ci]`), and the release commit itself carries `[skip ci]`.
|
||||||
|
#
|
||||||
|
# All jobs run on ubuntu-latest. GuruConnect ships a single .exe (no WiX/MSI); jsign is a Java
|
||||||
|
# tool that signs PE binaries on Linux, so no Windows runner is required.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# §3 VERSION + §4 CHANGELOG
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
version:
|
||||||
|
name: Version + Changelog
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.bump.outputs.version }}
|
||||||
|
released: ${{ steps.bump.outputs.released }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout (full history + tags)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
# CI_PUSH_TOKEN is a push-capable token used to commit the bump and push the tag.
|
||||||
|
token: ${{ secrets.CI_PUSH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Loop guard - skip release commits
|
||||||
|
id: guard
|
||||||
|
run: |
|
||||||
|
HEAD_MSG="$(git log -1 --pretty=%s)"
|
||||||
|
echo "[INFO] HEAD commit subject: ${HEAD_MSG}"
|
||||||
|
if echo "${HEAD_MSG}" | grep -qiE '\[skip ci\]|^chore: release'; then
|
||||||
|
echo "[INFO] Head commit is a release/skip-ci commit; skipping release."
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install git-cliff
|
||||||
|
if: steps.guard.outputs.skip != 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
CLIFF_VERSION="2.6.1"
|
||||||
|
URL="https://github.com/orhun/git-cliff/releases/download/v${CLIFF_VERSION}/git-cliff-${CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||||
|
echo "[INFO] Downloading git-cliff ${CLIFF_VERSION}"
|
||||||
|
curl -fsSL "$URL" -o /tmp/git-cliff.tar.gz
|
||||||
|
tar -xzf /tmp/git-cliff.tar.gz -C /tmp
|
||||||
|
sudo install -m 0755 "/tmp/git-cliff-${CLIFF_VERSION}/git-cliff" /usr/local/bin/git-cliff
|
||||||
|
git-cliff --version
|
||||||
|
|
||||||
|
- name: Determine next version and bump components
|
||||||
|
id: bump
|
||||||
|
if: steps.guard.outputs.skip != 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ----- locate the last release tag (vX.Y.Z) -----
|
||||||
|
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | head -n1 || true)"
|
||||||
|
if [ -z "${LAST_TAG}" ]; then
|
||||||
|
echo "[INFO] No prior release tag found; baseline is current manifest version."
|
||||||
|
BASE_VERSION="$(grep -m1 '^version' agent/Cargo.toml | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')"
|
||||||
|
RANGE=""
|
||||||
|
else
|
||||||
|
echo "[INFO] Last release tag: ${LAST_TAG}"
|
||||||
|
BASE_VERSION="${LAST_TAG#v}"
|
||||||
|
RANGE="${LAST_TAG}..HEAD"
|
||||||
|
fi
|
||||||
|
echo "[INFO] Base version: ${BASE_VERSION}"
|
||||||
|
|
||||||
|
# ----- collect commit subjects (and full bodies) since the last tag -----
|
||||||
|
if [ -n "${RANGE}" ]; then
|
||||||
|
COMMITS="$(git log "${RANGE}" --pretty=%s || true)"
|
||||||
|
# Full messages, NUL-delimited, for BREAKING CHANGE footer detection.
|
||||||
|
BODIES="$(git log "${RANGE}" --pretty=%B || true)"
|
||||||
|
CHANGED_FILES="$(git diff --name-only "${LAST_TAG}" HEAD || true)"
|
||||||
|
else
|
||||||
|
COMMITS="$(git log --pretty=%s || true)"
|
||||||
|
BODIES="$(git log --pretty=%B || true)"
|
||||||
|
CHANGED_FILES="$(git ls-files)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----- determine bump level from conventional commits -----
|
||||||
|
# Breaking changes (pre-1.0): a `!` before the colon on any type/scope (feat!:, fix!:,
|
||||||
|
# type(scope)!:) or a `BREAKING CHANGE` footer forces at least a MINOR bump. We do NOT
|
||||||
|
# force a major bump while the project is pre-1.0; existing feat->minor, fix/perf->patch
|
||||||
|
# logic is preserved otherwise.
|
||||||
|
BUMP="none"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if [ -z "$line" ]; then continue; fi
|
||||||
|
case "$line" in
|
||||||
|
# `<type>!:` or `<type>(scope)!:` — breaking change marker in the subject.
|
||||||
|
*'!:'*)
|
||||||
|
BUMP="minor"
|
||||||
|
;;
|
||||||
|
feat:*|feat\(*)
|
||||||
|
BUMP="minor"
|
||||||
|
;;
|
||||||
|
fix:*|fix\(*|perf:*|perf\(*)
|
||||||
|
if [ "$BUMP" != "minor" ]; then BUMP="patch"; fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$COMMITS"
|
||||||
|
|
||||||
|
# `BREAKING CHANGE:` / `BREAKING-CHANGE:` footer anywhere in a commit body -> at least minor.
|
||||||
|
if printf '%s' "$BODIES" | grep -qE 'BREAKING[ -]CHANGE'; then
|
||||||
|
echo "[INFO] BREAKING CHANGE footer detected; bumping at least minor (pre-1.0)."
|
||||||
|
BUMP="minor"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If only chores/docs but code actually changed, default to patch.
|
||||||
|
if [ "$BUMP" = "none" ]; then
|
||||||
|
if echo "$CHANGED_FILES" | grep -qE '^(agent/|server/|dashboard/|proto/)'; then
|
||||||
|
echo "[INFO] Only chores in commits but code changed; defaulting to patch."
|
||||||
|
BUMP="patch"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$BUMP" = "none" ]; then
|
||||||
|
echo "[INFO] No release-worthy changes detected; skipping release."
|
||||||
|
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "[INFO] Bump level: ${BUMP}"
|
||||||
|
|
||||||
|
# ----- compute next version -----
|
||||||
|
IFS='.' read -r MAJ MIN PAT <<< "${BASE_VERSION}"
|
||||||
|
case "$BUMP" in
|
||||||
|
minor) MIN=$((MIN + 1)); PAT=0 ;;
|
||||||
|
patch) PAT=$((PAT + 1)) ;;
|
||||||
|
esac
|
||||||
|
NEXT="${MAJ}.${MIN}.${PAT}"
|
||||||
|
echo "[INFO] Next version: ${NEXT}"
|
||||||
|
echo "version=${NEXT}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# ----- determine which components changed (bump only those) -----
|
||||||
|
agent_changed=false; server_changed=false; dashboard_changed=false
|
||||||
|
echo "$CHANGED_FILES" | grep -qE '^(agent/|proto/)' && agent_changed=true || true
|
||||||
|
echo "$CHANGED_FILES" | grep -qE '^(server/|proto/)' && server_changed=true || true
|
||||||
|
echo "$CHANGED_FILES" | grep -qE '^dashboard/' && dashboard_changed=true || true
|
||||||
|
# On the very first release (no prior tag) bump all components together.
|
||||||
|
if [ -z "${RANGE}" ]; then
|
||||||
|
agent_changed=true; server_changed=true; dashboard_changed=true
|
||||||
|
fi
|
||||||
|
echo "[INFO] Changed: agent=${agent_changed} server=${server_changed} dashboard=${dashboard_changed}"
|
||||||
|
|
||||||
|
# ----- bump manifests for changed components -----
|
||||||
|
bump_cargo() {
|
||||||
|
local file="$1"
|
||||||
|
# Replace only the first top-level `version = "x.y.z"` (the [package] version).
|
||||||
|
sed -i -E "0,/^version = \"[0-9]+\.[0-9]+\.[0-9]+\"/s//version = \"${NEXT}\"/" "$file"
|
||||||
|
echo "[OK] Bumped ${file} -> ${NEXT}"
|
||||||
|
}
|
||||||
|
if [ "$agent_changed" = "true" ]; then bump_cargo agent/Cargo.toml; fi
|
||||||
|
if [ "$server_changed" = "true" ]; then bump_cargo server/Cargo.toml; fi
|
||||||
|
if [ "$dashboard_changed" = "true" ]; then
|
||||||
|
# Bump the "version" field in dashboard/package.json without extra tooling.
|
||||||
|
sed -i -E "0,/\"version\": \"[0-9]+\.[0-9]+\.[0-9]+\"/s//\"version\": \"${NEXT}\"/" dashboard/package.json
|
||||||
|
echo "[OK] Bumped dashboard/package.json -> ${NEXT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keep the workspace version in sync (Cargo.toml [workspace.package]).
|
||||||
|
if [ -f Cargo.toml ]; then
|
||||||
|
sed -i -E "0,/^version = \"[0-9]+\.[0-9]+\.[0-9]+\"/s//version = \"${NEXT}\"/" Cargo.toml || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Generate changelog (git-cliff)
|
||||||
|
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.bump.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p changelogs
|
||||||
|
|
||||||
|
# Per-version notes: only the NEW version's section (unreleased commits, tagged). Strip
|
||||||
|
# the header AND footer so this fragment is reusable for per-component files (it must not
|
||||||
|
# carry the # Changelog preamble or the [0.1.0] footer).
|
||||||
|
git-cliff --config cliff.toml --tag "v${VERSION}" --unreleased --strip all \
|
||||||
|
--output /tmp/version-notes.md
|
||||||
|
echo "[OK] Generated per-version notes"
|
||||||
|
|
||||||
|
# Fallback when the version block has no user-facing entries (chore/docs-only release):
|
||||||
|
# ensure the per-component files and release notes are never effectively empty.
|
||||||
|
if ! grep -qE '^- ' /tmp/version-notes.md; then
|
||||||
|
echo "[INFO] No user-facing changelog entries; using maintenance fallback line."
|
||||||
|
{
|
||||||
|
printf '## [%s] - %s\n\n' "${VERSION}" "$(date -u +%Y-%m-%d)"
|
||||||
|
printf -- '- Maintenance release — no user-facing changes.\n'
|
||||||
|
} > /tmp/version-notes.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Regenerate the WHOLE CHANGELOG.md over full history with --output (NOT --prepend).
|
||||||
|
# git-cliff emits: header (# Changelog preamble) -> version blocks newest-first ->
|
||||||
|
# footer ([0.1.0] carried verbatim from cliff.toml). This is idempotent and always
|
||||||
|
# well-formed; --prepend would insert the new block above the # Changelog title.
|
||||||
|
echo "[INFO] Regenerating CHANGELOG.md over full history (tag v${VERSION})"
|
||||||
|
git-cliff --config cliff.toml --tag "v${VERSION}" --output CHANGELOG.md
|
||||||
|
echo "[OK] Updated CHANGELOG.md"
|
||||||
|
|
||||||
|
# Write per-component + LATEST files for each component that changed.
|
||||||
|
write_component() {
|
||||||
|
local comp="$1"
|
||||||
|
local upper
|
||||||
|
upper="$(echo "$comp" | tr '[:lower:]' '[:upper:]')"
|
||||||
|
mkdir -p "changelogs/${comp}"
|
||||||
|
cp /tmp/version-notes.md "changelogs/${comp}/v${VERSION}.md"
|
||||||
|
cp /tmp/version-notes.md "changelogs/LATEST_${upper}.md"
|
||||||
|
echo "[OK] Wrote changelogs/${comp}/v${VERSION}.md and changelogs/LATEST_${upper}.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Re-derive the set of changed components (same logic as the bump step). On the first
|
||||||
|
# release (no prior tag) all components are considered changed.
|
||||||
|
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | head -n1 || true)"
|
||||||
|
if [ -z "${LAST_TAG}" ]; then
|
||||||
|
CHANGED_FILES="$(git ls-files)"
|
||||||
|
FIRST_RELEASE=true
|
||||||
|
else
|
||||||
|
CHANGED_FILES="$(git diff --name-only "${LAST_TAG}" HEAD || true)"
|
||||||
|
FIRST_RELEASE=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$FIRST_RELEASE" = "true" ]; then
|
||||||
|
write_component agent
|
||||||
|
write_component server
|
||||||
|
write_component dashboard
|
||||||
|
else
|
||||||
|
echo "$CHANGED_FILES" | grep -qE '^(agent/|proto/)' && write_component agent || true
|
||||||
|
echo "$CHANGED_FILES" | grep -qE '^(server/|proto/)' && write_component server || true
|
||||||
|
echo "$CHANGED_FILES" | grep -qE '^dashboard/' && write_component dashboard || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Commit release + create tag
|
||||||
|
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.bump.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "guruconnect-ci"
|
||||||
|
git config user.email "ci@azcomputerguru.com"
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "[WARNING] No changes staged for release commit; skipping commit/tag."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "chore: release v${VERSION} [skip ci]"
|
||||||
|
git tag "v${VERSION}"
|
||||||
|
|
||||||
|
# Push commit and tag using CI_PUSH_TOKEN embedded in the remote URL.
|
||||||
|
# GITHUB_REPOSITORY is provided by the Actions runner (Gitea-compatible).
|
||||||
|
REMOTE="https://${{ secrets.CI_PUSH_TOKEN }}@git.azcomputerguru.com/${GITHUB_REPOSITORY}.git"
|
||||||
|
git push "${REMOTE}" "HEAD:${GITHUB_REF_NAME}"
|
||||||
|
git push "${REMOTE}" "v${VERSION}"
|
||||||
|
echo "[OK] Pushed release commit and tag v${VERSION}"
|
||||||
|
|
||||||
|
- name: Upload changelog artifact
|
||||||
|
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: changelog
|
||||||
|
path: |
|
||||||
|
CHANGELOG.md
|
||||||
|
changelogs/
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# §2 BUILD + SIGN + PUBLISH
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
build-sign-publish:
|
||||||
|
name: Build, Sign, Publish Agent
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: version
|
||||||
|
if: needs.version.outputs.released == 'true'
|
||||||
|
steps:
|
||||||
|
- name: Checkout the release tag
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Build the exact commit that was tagged (the release commit), not the pre-bump head.
|
||||||
|
ref: v${{ needs.version.outputs.version }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: actions-rs/toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
# Single source of truth for the Windows target used by the build below.
|
||||||
|
target: x86_64-pc-windows-gnu
|
||||||
|
override: true
|
||||||
|
|
||||||
|
- name: Install cross-compilation tools
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y mingw-w64
|
||||||
|
|
||||||
|
- name: Cache Cargo dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/bin/
|
||||||
|
~/.cargo/registry/index/
|
||||||
|
~/.cargo/registry/cache/
|
||||||
|
~/.cargo/git/db/
|
||||||
|
target/
|
||||||
|
key: ${{ runner.os }}-cargo-agent-release-${{ hashFiles('agent/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-agent-release-
|
||||||
|
${{ runner.os }}-cargo-agent-
|
||||||
|
|
||||||
|
- name: Build agent (cross-compile for Windows)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Target is installed by the toolchain step above (single source of truth).
|
||||||
|
cd agent
|
||||||
|
cargo build --release --target x86_64-pc-windows-gnu
|
||||||
|
echo "[OK] Built agent for x86_64-pc-windows-gnu"
|
||||||
|
|
||||||
|
- name: Stage unsigned binary
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cp agent/target/x86_64-pc-windows-gnu/release/guruconnect.exe ./guruconnect.exe
|
||||||
|
ls -l ./guruconnect.exe
|
||||||
|
|
||||||
|
# --- §2 Azure Trusted Signing (port of sign-windows.sh) ---
|
||||||
|
- name: Acquire Azure Trusted Signing token
|
||||||
|
id: token
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "[INFO] Requesting Azure code-signing access token"
|
||||||
|
RESP="$(curl -fsS -X POST \
|
||||||
|
"https://login.microsoftonline.com/${{ secrets.AZURE_TENANT_ID }}/oauth2/v2.0/token" \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
--data-urlencode "grant_type=client_credentials" \
|
||||||
|
--data-urlencode "client_id=${{ secrets.AZURE_CLIENT_ID }}" \
|
||||||
|
--data-urlencode "client_secret=${{ secrets.AZURE_CLIENT_SECRET }}" \
|
||||||
|
--data-urlencode "scope=https://codesigning.azure.net/.default")"
|
||||||
|
TOKEN="$(printf '%s' "$RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')"
|
||||||
|
if [ -z "$TOKEN" ] || [ "$TOKEN" = "None" ]; then
|
||||||
|
echo "[ERROR] Failed to acquire Azure Trusted Signing token"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[OK] Acquired signing token"
|
||||||
|
# Mask and export the token for the sign step without printing it.
|
||||||
|
echo "::add-mask::${TOKEN}"
|
||||||
|
echo "TS_TOKEN=${TOKEN}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Install JRE + jsign
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y default-jre-headless
|
||||||
|
JSIGN_VERSION="6.0"
|
||||||
|
curl -fsSL "https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar" \
|
||||||
|
-o /tmp/jsign.jar
|
||||||
|
echo "[OK] Installed JRE and jsign ${JSIGN_VERSION}"
|
||||||
|
|
||||||
|
- name: Sign guruconnect.exe (Azure Trusted Signing)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "[INFO] Signing guruconnect.exe with Azure Trusted Signing"
|
||||||
|
java -jar /tmp/jsign.jar \
|
||||||
|
--storetype TRUSTEDSIGNING \
|
||||||
|
--keystore "${{ secrets.TS_ENDPOINT }}" \
|
||||||
|
--storepass "${TS_TOKEN}" \
|
||||||
|
--alias "${{ secrets.TS_ACCOUNT }}/${{ secrets.TS_CERT_PROFILE }}" \
|
||||||
|
--tsaurl "${{ secrets.TS_TIMESTAMP_URL }}" \
|
||||||
|
--tsmode RFC3161 \
|
||||||
|
--alg SHA-256 \
|
||||||
|
--name "GuruConnect Agent" \
|
||||||
|
--url "https://www.azcomputerguru.com" \
|
||||||
|
--replace \
|
||||||
|
guruconnect.exe
|
||||||
|
echo "[OK] Signing command completed"
|
||||||
|
|
||||||
|
- name: Verify signature present (fail release if unsigned)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "[INFO] Verifying Authenticode signature is present"
|
||||||
|
# jsign's --info on a signed PE lists the signature(s); fail if none reported.
|
||||||
|
OUT="$(java -jar /tmp/jsign.jar --info guruconnect.exe 2>&1 || true)"
|
||||||
|
echo "$OUT"
|
||||||
|
if echo "$OUT" | grep -qiE 'signature|signer|signed'; then
|
||||||
|
echo "[OK] Signature present"
|
||||||
|
else
|
||||||
|
echo "[ERROR] No signature detected on guruconnect.exe - refusing to publish unsigned binary"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Compute SHA-256 of signed binary
|
||||||
|
id: sha
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sha256sum guruconnect.exe | awk '{print $1}' > guruconnect.exe.sha256
|
||||||
|
SUM="$(cat guruconnect.exe.sha256)"
|
||||||
|
echo "[OK] SHA-256: ${SUM}"
|
||||||
|
echo "sha256=${SUM}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Download changelog artifact
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: changelog
|
||||||
|
path: changelog-artifact
|
||||||
|
|
||||||
|
- name: Upload signed agent artifacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: guruconnect-agent-signed
|
||||||
|
path: |
|
||||||
|
guruconnect.exe
|
||||||
|
guruconnect.exe.sha256
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
# --- §2/§4 PUBLISH: create a Gitea release and attach assets ---
|
||||||
|
#
|
||||||
|
# Gitea release mechanism (decision): the GitHub-only actions/create-release@v1 +
|
||||||
|
# GITHUB_TOKEN flow used by the old deploy.yml does NOT work on Gitea. We use the Gitea
|
||||||
|
# REST API directly via curl, which is guaranteed available on the ubuntu-latest runner and
|
||||||
|
# does not depend on a third-party action being registered in this Gitea instance.
|
||||||
|
# POST /api/v1/repos/{owner}/{repo}/releases (create release for the tag)
|
||||||
|
# POST /api/v1/repos/{owner}/{repo}/releases/{id}/assets (attach each asset)
|
||||||
|
# Auth: CI_PUSH_TOKEN (token=...). GITHUB_REPOSITORY / GITHUB_SERVER_URL come from the runner.
|
||||||
|
- name: Create Gitea release and upload assets
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
SHA256: ${{ steps.sha.outputs.sha256 }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.CI_PUSH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
API_BASE="https://git.azcomputerguru.com/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
echo "[INFO] Creating Gitea release ${TAG} on ${GITHUB_REPOSITORY}"
|
||||||
|
|
||||||
|
BODY="$(printf 'GuruConnect %s\n\nSHA-256 (guruconnect.exe): %s\n\nSee CHANGELOG.md and /api/changelog for details.' "${TAG}" "${SHA256}")"
|
||||||
|
|
||||||
|
# Build the JSON payload with python (handles escaping of the multi-line body safely).
|
||||||
|
CREATE_PAYLOAD="$(TAG="$TAG" BODY="$BODY" python3 -c 'import json,os; print(json.dumps({"tag_name": os.environ["TAG"], "name": "Release " + os.environ["TAG"], "body": os.environ["BODY"], "draft": False, "prerelease": False}))')"
|
||||||
|
|
||||||
|
RELEASE_JSON="$(curl -fsS -X POST \
|
||||||
|
"${API_BASE}/releases" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "${CREATE_PAYLOAD}")"
|
||||||
|
|
||||||
|
RELEASE_ID="$(printf '%s' "$RELEASE_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')"
|
||||||
|
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "None" ]; then
|
||||||
|
echo "[ERROR] Failed to create Gitea release"
|
||||||
|
echo "$RELEASE_JSON"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[OK] Created release id=${RELEASE_ID}"
|
||||||
|
|
||||||
|
upload_asset() {
|
||||||
|
local file="$1"
|
||||||
|
echo "[INFO] Uploading asset: ${file}"
|
||||||
|
curl -fsS -X POST \
|
||||||
|
"${API_BASE}/releases/${RELEASE_ID}/assets?name=$(basename "$file")" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${file}" > /dev/null
|
||||||
|
echo "[OK] Uploaded ${file}"
|
||||||
|
}
|
||||||
|
upload_asset guruconnect.exe
|
||||||
|
upload_asset guruconnect.exe.sha256
|
||||||
|
if [ -f changelog-artifact/CHANGELOG.md ]; then
|
||||||
|
upload_asset changelog-artifact/CHANGELOG.md
|
||||||
|
fi
|
||||||
|
echo "[OK] Release ${TAG} published with signed binary, checksum, and changelog"
|
||||||
16
CHANGELOG.md
Normal file
16
CHANGELOG.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to GuruConnect are documented here. Format follows
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/); the project uses semantic versioning.
|
||||||
|
|
||||||
|
Per-version entries below are generated from conventional commits (`feat:`, `fix:`, `perf:`)
|
||||||
|
by the release workflow; per-component changelogs are also written to
|
||||||
|
`changelogs/<component>/v<version>.md` and served at `/api/changelog/...`.
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-01-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial GuruConnect: Rust agent (DXGI/GDI capture, input injection, native viewer,
|
||||||
|
`guruconnect://` handler), Axum relay server, protobuf-over-WSS transport.
|
||||||
|
- Phase-1 security hardening (JWT, Argon2id, rate limiting, security headers, SEC-1..5),
|
||||||
|
systemd units, automated backups.
|
||||||
8
changelogs/.gitkeep
Normal file
8
changelogs/.gitkeep
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Generated changelog directory.
|
||||||
|
#
|
||||||
|
# Populated by .gitea/workflows/release.yml on each release:
|
||||||
|
# changelogs/<component>/v<version>.md per-component, per-version notes
|
||||||
|
# changelogs/LATEST_<COMPONENT>.md most recent notes for each component
|
||||||
|
#
|
||||||
|
# Served by the server at GET /api/changelog/:component/{latest,:version}
|
||||||
|
# (CHANGELOG_DIR env var points the server at this directory in production).
|
||||||
84
cliff.toml
Normal file
84
cliff.toml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# git-cliff configuration for GuruConnect
|
||||||
|
# Conventional-commits preset, grouped by feat / fix / perf.
|
||||||
|
# Used by .gitea/workflows/release.yml to generate CHANGELOG.md and per-component changelogs.
|
||||||
|
# Docs: https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
# Header rendered once at the very TOP of CHANGELOG.md. The release workflow regenerates the
|
||||||
|
# whole file over full history with `--output`, so this fixed preamble is always the first thing
|
||||||
|
# in the document, above the newest version block.
|
||||||
|
header = """
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to GuruConnect are documented here. Format follows
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/); the project uses semantic versioning.
|
||||||
|
|
||||||
|
Per-version entries below are generated from conventional commits (`feat:`, `fix:`, `perf:`)
|
||||||
|
by the release workflow; per-component changelogs are also written to
|
||||||
|
`changelogs/<component>/v<version>.md` and served at `/api/changelog/...`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Body template for each release. Designed to render a single version block that the workflow
|
||||||
|
# reuses verbatim (via `--strip header`) for the per-component changelog files.
|
||||||
|
body = """
|
||||||
|
{% if version %}\
|
||||||
|
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||||
|
{% else %}\
|
||||||
|
## [Unreleased]
|
||||||
|
{% endif %}\
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | upper_first }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
- {{ commit.message | upper_first }}{% if commit.id %} ({{ commit.id | truncate(length=8, end="") }}){% endif %}\
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}\n
|
||||||
|
"""
|
||||||
|
|
||||||
|
trim = true
|
||||||
|
|
||||||
|
# Footer rendered once at the BOTTOM of CHANGELOG.md, after the newest-first version blocks. The
|
||||||
|
# initial [0.1.0] release predates conventional-commit history and cannot be re-derived from the
|
||||||
|
# git log, so it is carried here verbatim. Result over full history is:
|
||||||
|
# header (# Changelog preamble) -> [newest .. ] version blocks (newest first) -> [0.1.0] footer.
|
||||||
|
footer = """
|
||||||
|
## [0.1.0] - 2026-01-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial GuruConnect: Rust agent (DXGI/GDI capture, input injection, native viewer,
|
||||||
|
`guruconnect://` handler), Axum relay server, protobuf-over-WSS transport.
|
||||||
|
- Phase-1 security hardening (JWT, Argon2id, rate limiting, security headers, SEC-1..5),
|
||||||
|
systemd units, automated backups.
|
||||||
|
"""
|
||||||
|
|
||||||
|
[git]
|
||||||
|
# Parse commits as conventional commits.
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = true
|
||||||
|
split_commits = false
|
||||||
|
|
||||||
|
# Group commits into changelog sections. Anything not matched is skipped (chores, docs, etc.).
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Added" },
|
||||||
|
{ message = "^fix", group = "Fixed" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^revert", group = "Reverted" },
|
||||||
|
{ message = "^chore\\(release\\)", skip = true },
|
||||||
|
{ message = "^chore: release", skip = true },
|
||||||
|
{ message = "^chore", skip = true },
|
||||||
|
{ message = "^docs", skip = true },
|
||||||
|
{ message = "^test", skip = true },
|
||||||
|
{ message = "^ci", skip = true },
|
||||||
|
{ message = "^build", skip = true },
|
||||||
|
{ message = "^style", skip = true },
|
||||||
|
{ message = "^refactor", skip = true },
|
||||||
|
{ body = ".*security", group = "Security" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# Skip release-bump commits so they never appear in the changelog.
|
||||||
|
filter_commits = false
|
||||||
|
|
||||||
|
# Process tags matching vMAJOR.MINOR.PATCH.
|
||||||
|
tag_pattern = "v[0-9]*"
|
||||||
|
|
||||||
|
# Sort newest first.
|
||||||
|
sort_commits = "newest"
|
||||||
81
docs/ARCHITECTURE_DECISIONS.md
Normal file
81
docs/ARCHITECTURE_DECISIONS.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# GuruConnect — Architecture Decisions
|
||||||
|
|
||||||
|
Records significant architectural decisions for the GuruConnect product. Each entry: context,
|
||||||
|
decision, options considered, rationale, consequences.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-001: GuruConnect is a Standalone Product; Integrate with GuruRMM via a Versioned Contract
|
||||||
|
|
||||||
|
**Date:** 2026-05-29
|
||||||
|
**Status:** Decided
|
||||||
|
**Deciders:** Mike Swanson
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
GuruConnect is a remote-support product that must work fully on its own, with its own repository
|
||||||
|
(`azcomputerguru/guru-connect`), build pipeline, and release cadence. GuruRMM wants to offer native
|
||||||
|
integrated remote control by driving GuruConnect.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
GuruConnect stays an independent product. It exposes a **versioned integration contract**
|
||||||
|
(`/api/integration/v1/`, capability discovery, embedded-viewer protocol) that GuruRMM consumes as a
|
||||||
|
broker. The two products do not share build pipelines or release in lockstep. GuruConnect owns the
|
||||||
|
contract; GuruRMM does no active development on GuruConnect.
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
- Preserves GuruConnect as a sellable standalone product.
|
||||||
|
- Avoids coupling two independently-evolving codebases; integration changes go through the contract.
|
||||||
|
- Mirrors the GuruRMM-side decision (GuruRMM ADR-008).
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- The integration surface is semver'd; breaking changes require a major bump.
|
||||||
|
- See `docs/specs/native-remote-control/` for the contract spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-002: Release Engineering — Gitea Actions Pipeline with Azure Trusted Signing
|
||||||
|
|
||||||
|
**Date:** 2026-05-29
|
||||||
|
**Status:** Decided
|
||||||
|
**Deciders:** Mike Swanson
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
GuruConnect needs operational parity with GuruRMM: signed Windows binaries, automatic versioning,
|
||||||
|
changelogs, and tracking. GuruRMM achieves this with a Gitea **webhook → shell-script** pipeline on
|
||||||
|
shared build hosts (Saturn + Pluto) and signs via Azure Trusted Signing (`jsign`) using credentials
|
||||||
|
in `/etc/gururmm-signing.env`.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
GuruConnect implements its release engineering **entirely in Gitea Actions** (not the webhook/script
|
||||||
|
model), and **reuses GuruRMM's existing Azure Trusted Signing certificate profile** (same account +
|
||||||
|
service principal) to sign the Windows agent `.exe`.
|
||||||
|
|
||||||
|
### Options Considered
|
||||||
|
|
||||||
|
- **A — Gitea Actions, reuse RMM cert profile (chosen):** self-contained workflows; `jsign` runs on
|
||||||
|
the `ubuntu-latest` runner; no Pluto/webhook dependency. GC ships a single `.exe` (no WiX/MSI), so
|
||||||
|
no Windows runner is needed.
|
||||||
|
- **B — Mirror RMM's webhook + shell scripts:** maximal parity but adds Pluto/webhook coupling and a
|
||||||
|
build host to maintain.
|
||||||
|
- **C — Separate Azure Trusted Signing account for GC:** cleaner attribution, more Azure setup.
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
- `jsign` is cross-platform (Java) and signs PE binaries on Linux — no Windows runner required.
|
||||||
|
- Reusing RMM's cert profile means zero new Azure provisioning; GC binaries are signed by the same
|
||||||
|
ACG identity.
|
||||||
|
- Actions are self-contained and versioned with the repo, simpler than maintaining build-host scripts.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- The Azure Trusted Signing service-principal secrets must be added to the `guru-connect` repo's
|
||||||
|
Gitea Actions secrets (values come from `/etc/gururmm-signing.env` / the SOPS vault). See SPEC-001.
|
||||||
|
- Windows binaries will be attributed to GuruRMM's cert profile until/unless a GuruConnect-specific
|
||||||
|
profile is provisioned (a future, low-effort change).
|
||||||
|
- Implementation: `docs/specs/SPEC-001-operational-tooling-parity.md`.
|
||||||
60
docs/FEATURE_ROADMAP.md
Normal file
60
docs/FEATURE_ROADMAP.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# GuruConnect — Feature Roadmap
|
||||||
|
|
||||||
|
> Living roadmap for the GuruConnect product. Status markers: `[ ]` planned · `[~]` in
|
||||||
|
> consideration · `[x]` shipped. Priorities: P1 (blocking/MVP) · P2 (important) · P3 (nice-to-have).
|
||||||
|
> Specs live in `docs/specs/SPEC-NNN-<slug>.md`. Decisions in `docs/ARCHITECTURE_DECISIONS.md`.
|
||||||
|
|
||||||
|
GuruConnect is a standalone remote-support product (ScreenConnect/Splashtop-class) on our own Rust
|
||||||
|
stack. It ships independently of GuruRMM and integrates with it via a versioned contract (see
|
||||||
|
`specs/native-remote-control/` and ADR-001).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational Tooling & Release Engineering
|
||||||
|
|
||||||
|
Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](specs/SPEC-001-operational-tooling-parity.md).
|
||||||
|
|
||||||
|
- [ ] **Code signing — Azure Trusted Signing in CI** — P1 — sign the Windows agent `.exe` via `jsign` (TRUSTEDSIGNING) in Gitea Actions, reusing the shared ACG cert profile. (SPEC-001 §2)
|
||||||
|
- [ ] **Automatic versioning** — P1 — conventional-commit-driven version bump across agent/server/dashboard, embedded via `build.rs`. (SPEC-001 §3)
|
||||||
|
- [ ] **Changelog generation & API** — P2 — `CHANGELOG.md` + per-version changelogs from conventional commits, served at `/api/changelog/...`. (SPEC-001 §4)
|
||||||
|
- [ ] **Feature-request workflow** — P2 — `/gc-feature-request` skill producing `docs/specs/SPEC-NNN-*.md` and updating this roadmap. (SPEC-001 §1)
|
||||||
|
- [ ] **Roadmap / ADR / spec tracking** — P1 — this file + `ARCHITECTURE_DECISIONS.md` + `docs/specs/`. (SPEC-001 §5) — *bootstrapped*
|
||||||
|
- [ ] **Coord-API registration** — P3 — register `guruconnect` project_key + components (`server`, `agent`, `dashboard`) in the coordination API. (SPEC-001 §6)
|
||||||
|
- [~] **Release distribution / update channels** — P3 — beta→stable rollout with health metrics (mirrors RMM `updates.rs`). Deferred — larger subsystem, post-parity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Remote Control
|
||||||
|
|
||||||
|
- [x] Screen capture (DXGI primary, GDI fallback)
|
||||||
|
- [x] Input injection (mouse/keyboard)
|
||||||
|
- [x] Native viewer + `guruconnect://` protocol handler
|
||||||
|
- [x] Support-code (attended) and persistent (unattended) agent modes
|
||||||
|
- [x] Protobuf-over-WSS transport, Zstd frame compression
|
||||||
|
- [~] React/TS web viewer (`dashboard/src/components/RemoteViewer.tsx`) — embeddable session viewer
|
||||||
|
- [ ] Multi-monitor switching — P2
|
||||||
|
- [ ] File transfer — P3 (out of scope for native-remote-control v1)
|
||||||
|
- [ ] Session recording — P3 (out of scope for native-remote-control v1)
|
||||||
|
|
||||||
|
## GuruRMM Integration
|
||||||
|
|
||||||
|
- [ ] **Native remote control via broker** — P2 — versioned integration contract so GuruRMM can launch/embed GC sessions on managed endpoints. Full spec: [`specs/native-remote-control/`](specs/native-remote-control/). (Contract owned by GC; RMM consumes it.)
|
||||||
|
- [ ] `/api/integration/v1/` namespace + capability discovery — P2 (part of native-remote-control)
|
||||||
|
- [ ] Per-machine agent keys (replace shared `AGENT_API_KEY`) — P2
|
||||||
|
- [ ] Embedded-viewer framing allowlist (scoped `frame-ancestors`) — P2
|
||||||
|
|
||||||
|
## Server / API
|
||||||
|
|
||||||
|
- [x] JWT auth, Argon2id passwords, rate limiting, security headers
|
||||||
|
- [x] Sessions / machines / support-codes / events
|
||||||
|
- [ ] Programmatic session pre-create + viewer-token (integration contract) — P2
|
||||||
|
|
||||||
|
## Security & Infrastructure
|
||||||
|
|
||||||
|
- [x] Phase-1 security hardening (SEC-1..5), systemd units, backups
|
||||||
|
- [ ] CI security audit gate (`cargo audit`) wired to release — P2
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
- [ ] macOS / Linux remote-control agents — P3
|
||||||
|
- [ ] Auto-update for the agent — P3
|
||||||
117
docs/specs/SPEC-001-operational-tooling-parity.md
Normal file
117
docs/specs/SPEC-001-operational-tooling-parity.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# SPEC-001: Operational Tooling Parity with GuruRMM
|
||||||
|
|
||||||
|
**Status:** Proposed
|
||||||
|
**Priority:** P1
|
||||||
|
**Requested By:** Mike (2026-05-29)
|
||||||
|
**Estimated Effort:** Large
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Bring GuruConnect's release engineering and project tooling to parity with GuruRMM: signed Windows
|
||||||
|
binaries, automatic versioning, changelog generation, a feature-request workflow, and roadmap/spec
|
||||||
|
tracking. Per ADR-002, this is implemented **entirely in Gitea Actions** (not RMM's webhook/script
|
||||||
|
model), and reuses RMM's existing **Azure Trusted Signing** certificate profile.
|
||||||
|
|
||||||
|
GC ships a single Windows `.exe` (no WiX/MSI), so all jobs run on `ubuntu-latest` — `jsign` is a
|
||||||
|
Java tool that signs PE binaries on Linux. No Windows runner or Pluto dependency.
|
||||||
|
|
||||||
|
**Success criteria:** a push to `main` produces an auto-versioned, signed `guruconnect.exe` with a
|
||||||
|
generated changelog entry; `/gc-feature-request` produces a SPEC and updates the roadmap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §1 — Feature-request workflow (skill)
|
||||||
|
|
||||||
|
**Deliverable:** `/gc-feature-request` skill (in `.claude/commands/gc-feature-request.md`, claudetools repo).
|
||||||
|
|
||||||
|
Adapted from RMM's `/feature-request`:
|
||||||
|
- Reads `docs/FEATURE_ROADMAP.md`, `docs/ARCHITECTURE_DECISIONS.md`, this repo's `CLAUDE.md`.
|
||||||
|
- Ollama classification (qwen3.6 JSON) → section/priority; prose spec via qwen3:14b (fallback: self).
|
||||||
|
- Writes `docs/specs/SPEC-NNN-<slug>.md` (next number), updates the roadmap, commits in the GC repo.
|
||||||
|
- GC architecture vocabulary: **agent / relay-server / viewer / dashboard** (not RMM's agent/server/dashboard).
|
||||||
|
- Coord messages use `project_key: "guruconnect"` (see §6).
|
||||||
|
|
||||||
|
## §2 — Code signing (Azure Trusted Signing in Actions)
|
||||||
|
|
||||||
|
**Deliverable:** a reusable signing step/job in `.gitea/workflows/`.
|
||||||
|
|
||||||
|
Mirror RMM's `sign-windows.sh` logic inside Actions:
|
||||||
|
1. Acquire token: `POST https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`
|
||||||
|
with `grant_type=client_credentials`, `client_id`, `client_secret`,
|
||||||
|
`scope=https://codesigning.azure.net/.default`.
|
||||||
|
2. Install `jsign` (+ JRE) on the runner.
|
||||||
|
3. Sign:
|
||||||
|
```
|
||||||
|
jsign --storetype TRUSTEDSIGNING --keystore "$TS_ENDPOINT" --storepass "$TOKEN" \
|
||||||
|
--alias "${TS_ACCOUNT}/${TS_CERT_PROFILE}" --tsaurl "$TS_TIMESTAMP_URL" \
|
||||||
|
--tsmode RFC3161 --alg SHA-256 --name "GuruConnect Agent" \
|
||||||
|
--url "https://www.azcomputerguru.com" --replace guruconnect.exe
|
||||||
|
```
|
||||||
|
4. Emit `[OK] signed` / on failure fail the release job (do NOT publish unsigned for releases).
|
||||||
|
|
||||||
|
**Required Gitea Actions secrets on the `guru-connect` repo** (values reused from RMM —
|
||||||
|
`/etc/gururmm-signing.env` on the build host / SOPS vault; do not hardcode):
|
||||||
|
`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `TS_ENDPOINT`, `TS_ACCOUNT`,
|
||||||
|
`TS_CERT_PROFILE`, `TS_TIMESTAMP_URL`.
|
||||||
|
|
||||||
|
## §3 — Automatic versioning
|
||||||
|
|
||||||
|
**Deliverable:** a version-bump job in the release workflow.
|
||||||
|
|
||||||
|
- Canonical versions: `agent/Cargo.toml`, `server/Cargo.toml`, `dashboard/package.json` (all `0.1.0` today).
|
||||||
|
- On push to `main`, determine the next version from conventional commits since the last release tag
|
||||||
|
(`feat:` → minor, `fix:`/`perf:` → patch); bump only components whose files changed.
|
||||||
|
- Commit the bump (`chore: release vX.Y.Z [skip ci]`) and tag `vX.Y.Z`. The agent embeds the version
|
||||||
|
via the existing `agent/build.rs` (already reads `CARGO_PKG_VERSION` + git hash) — no change needed there.
|
||||||
|
- Requires a push-capable token secret for the runner (`CI_PUSH_TOKEN`) to commit the bump/tag back.
|
||||||
|
|
||||||
|
## §4 — Changelog generation & API
|
||||||
|
|
||||||
|
**Deliverables:** changelog generation in the release workflow + a server endpoint.
|
||||||
|
|
||||||
|
- Generate from conventional commits (git-cliff or equivalent) into `CHANGELOG.md` and
|
||||||
|
`changelogs/<component>/v<version>.md`, plus `changelogs/LATEST_<COMPONENT>.md`.
|
||||||
|
- Server: add `GET /api/changelog/:component/latest` and `GET /api/changelog/:component/:version`
|
||||||
|
(mirror RMM `server/src/api/changelog.rs`), reading from a `CHANGELOG_DIR` env (default
|
||||||
|
`server/changelogs` or the deployed downloads path).
|
||||||
|
|
||||||
|
## §5 — Roadmap / ADR / spec tracking (bootstrapped)
|
||||||
|
|
||||||
|
**Deliverables (this spec's commit establishes them):**
|
||||||
|
- `docs/FEATURE_ROADMAP.md` — living roadmap, `[ ]/[~]/[x]` + P1–P3.
|
||||||
|
- `docs/ARCHITECTURE_DECISIONS.md` — ADR-NNN log (ADR-001 standalone+contract, ADR-002 release eng).
|
||||||
|
- `docs/specs/SPEC-NNN-*.md` — numbered specs (this is SPEC-001).
|
||||||
|
- `CHANGELOG.md` — Keep-a-Changelog seed.
|
||||||
|
|
||||||
|
## §6 — Coord-API registration
|
||||||
|
|
||||||
|
**Deliverable:** register GC in the coordination API.
|
||||||
|
|
||||||
|
- Add `guruconnect` project_key with components `server`, `agent`, `dashboard` (states:
|
||||||
|
`building`, `built`, `deploying`, `deployed`, `degraded`).
|
||||||
|
- Update root `.claude/CLAUDE.md` "Project keys" table to include `guruconnect`.
|
||||||
|
- `/gc-feature-request` and CI can then POST component state updates after deploys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. §5 docs scaffold (this commit) + §1 skill.
|
||||||
|
2. §2 signing + §3 versioning + §4 changelog as Gitea Actions workflows (depends on the repo secrets
|
||||||
|
in §2 being added first).
|
||||||
|
3. §4 server changelog endpoint.
|
||||||
|
4. §6 coord registration.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Server version: auto-bump like agent, or keep manual (RMM keeps server manual)? Default: auto-bump all.
|
||||||
|
- Long term: provision a dedicated "GuruConnect" Azure Trusted Signing cert profile for correct
|
||||||
|
publisher attribution (ADR-002 notes current reuse of RMM's profile).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- ADR-002 (this repo), GuruRMM `sign-windows.sh` (Azure Trusted Signing via jsign),
|
||||||
|
RMM `scripts/build-agents.sh` (auto-version), `scripts/generate-changelog.sh`,
|
||||||
|
`server/src/api/changelog.rs`. Roadmap: `docs/FEATURE_ROADMAP.md`.
|
||||||
@@ -22,6 +22,12 @@ LISTEN_ADDR=0.0.0.0:3002
|
|||||||
# If set, persistent agents must provide this key to connect
|
# If set, persistent agents must provide this key to connect
|
||||||
AGENT_API_KEY=
|
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 mode (enables verbose logging)
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
|
|
||||||
|
|||||||
125
server/src/api/changelog.rs
Normal file
125
server/src/api/changelog.rs
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod auth_logout;
|
pub mod auth_logout;
|
||||||
|
pub mod changelog;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod releases;
|
pub mod releases;
|
||||||
pub mod downloads;
|
pub mod downloads;
|
||||||
|
|||||||
@@ -304,6 +304,11 @@ async fn main() -> Result<()> {
|
|||||||
.route("/api/releases/:version", put(api::releases::update_release))
|
.route("/api/releases/:version", put(api::releases::update_release))
|
||||||
.route("/api/releases/:version", delete(api::releases::delete_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)
|
// Agent downloads (no auth - public download links)
|
||||||
.route("/api/download/viewer", get(api::downloads::download_viewer))
|
.route("/api/download/viewer", get(api::downloads::download_viewer))
|
||||||
.route("/api/download/support", get(api::downloads::download_support))
|
.route("/api/download/support", get(api::downloads::download_support))
|
||||||
|
|||||||
Reference in New Issue
Block a user