Files
guru-connect/.gitea/workflows/release.yml
Mike Swanson 5727ccf39e
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 5m9s
Build and Test / Build Server (Linux) (push) Successful in 9m23s
Build and Test / Security Audit (push) Successful in 4m19s
Build and Test / Build Summary (push) Successful in 15s
fix: drop broken jsign --info verify step in release
jsign 7.1 signs guruconnect.exe successfully via Azure Trusted Signing, but the separate
verify step called `jsign --info` (not a real jsign subcommand) and wrongly failed the job.
jsign's non-zero exit under `set -euo pipefail` already gates signing fail-closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:27:08 -07:00

517 lines
24 KiB
YAML

name: Release
# SPEC-001 §2/§3/§4 — auto-versioning, signed Windows build, changelog generation, release.
#
# When manually dispatched (gated — not on every push), 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 — natively build the Windows agent (x86_64-pc-windows-msvc) to guruconnect.exe
# on the Pluto Gitea Actions runner (windows-msvc), upload it as an artifact.
# 4. sign — on Linux, download the Windows artifact and 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]`.
#
# The agent is built NATIVELY on the windows-msvc runner (no mingw cross-compile). Signing and
# publishing run on ubuntu-latest: jsign is a Java tool that signs PE binaries on Linux, so the
# signed-binary handoff is Windows-build-job -> artifact -> Linux-sign-job.
on:
# Gated: releases are deliberate, NOT automatic on every push to main.
# Trigger manually (Actions -> Release -> Run workflow). Auto-versioning still
# computes the next semver from conventional commits at dispatch time.
# build-and-test.yml remains the automatic PR/push CI gate.
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 (native Windows on Pluto windows-msvc runner)
# ---------------------------------------------------------------------------
build-agent-windows:
name: Build Agent (Windows, native)
# Native build on the Pluto Gitea Actions runner (host-mode, Windows Server 2019).
# The MSVC toolchain (x86_64-pc-windows-msvc target + crt-static via .cargo/config.toml)
# is pre-installed under the Administrator profile; the runner itself runs as SYSTEM, so
# the job points CARGO_HOME/RUSTUP_HOME at the Administrator homes.
runs-on: windows-msvc
needs: version
if: needs.version.outputs.released == 'true'
env:
CARGO_HOME: C:\Users\Administrator\.cargo
RUSTUP_HOME: C:\Users\Administrator\.rustup
# prost-build (agent build.rs) needs protoc; set explicitly, don't rely on machine-env inherit.
PROTOC: C:\protoc\bin\protoc.exe
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: Add toolchain dirs to PATH
shell: pwsh
run: |
# Make cargo/rustc (Administrator toolchain) and protoc visible to later steps.
"C:\Users\Administrator\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
"C:\protoc\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Toolchain sanity check
shell: pwsh
run: |
# Fail early with a clear marker if the pre-installed toolchain is not reachable.
cargo --version
rustc --version
- name: Build agent (native x86_64-pc-windows-msvc)
shell: pwsh
run: |
# crt-static and the default target come from .cargo/config.toml; we pass --target
# explicitly so the artifact path is deterministic regardless of host defaults.
Set-Location agent
cargo build --release --target x86_64-pc-windows-msvc
Write-Host "[OK] Built agent for x86_64-pc-windows-msvc"
- name: Stage unsigned binary
shell: pwsh
run: |
# Cargo workspace: binary is in the workspace-root target/, not agent/target/.
Copy-Item target\x86_64-pc-windows-msvc\release\guruconnect.exe .\guruconnect.exe
Get-Item .\guruconnect.exe | Format-List Name, Length
- name: Upload unsigned agent binary
uses: actions/upload-artifact@v3
with:
name: guruconnect-agent-unsigned
path: guruconnect.exe
retention-days: 90
# ---------------------------------------------------------------------------
# §2 SIGN + §2/§4 PUBLISH (Linux: jsign + Gitea REST)
# ---------------------------------------------------------------------------
build-sign-publish:
name: Sign, Publish Agent
runs-on: ubuntu-latest
needs: [version, build-agent-windows]
if: needs.version.outputs.released == 'true'
steps:
- name: Checkout the release tag
uses: actions/checkout@v4
with:
# Checked out for the Gitea publish step (repo metadata); the binary itself comes
# from the windows artifact downloaded below, not from a Linux build.
ref: v${{ needs.version.outputs.version }}
fetch-depth: 0
- name: Download unsigned agent binary
uses: actions/download-artifact@v3
with:
name: guruconnect-agent-unsigned
path: .
- name: Verify unsigned binary present
run: |
set -euo pipefail
if [ ! -f ./guruconnect.exe ]; then
echo "[ERROR] guruconnect.exe not found after artifact download"
exit 1
fi
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 >= 7.0 is required for the TRUSTEDSIGNING (Azure Trusted Signing) storetype;
# 6.0 only supports AZUREKEYVAULT. 7.1 matches the version on the build host.
JSIGN_VERSION="7.1"
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] guruconnect.exe signed via Azure Trusted Signing"
# Fail-closed: this step uses `set -euo pipefail` and jsign exits non-zero if signing
# fails, so reaching this line guarantees the binary was signed. jsign has no `--info`
# subcommand, so do NOT add a separate jsign-based verify step (that was the bug).
- 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"