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 # `!:` or `(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 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 cargo bin to PATH shell: pwsh run: | # Make cargo/rustc from the Administrator toolchain visible to later steps. "C:\Users\Administrator\.cargo\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: | Copy-Item agent\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_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"