ci(release): add signed beta/test release channel
Some checks failed
Build and Test / Build Server (Linux) (push) Has started running
Build and Test / Build Agent (Windows) (push) Has started running
Build and Test / Security Audit (push) Has been cancelled
Build and Test / Build Summary (push) Has been cancelled

Add a `channel: stable | beta` workflow_dispatch input to release.yml.
`stable` is unchanged (byte-for-byte). `beta` produces a Windows agent
binary signed by the identical fail-closed Azure Trusted Signing path,
but skips the semver bump, changelog, and release commit, and publishes
a prerelease-tagged Gitea release (vX.Y.Z-beta.<run_number>) at HEAD.

So every binary handed to a tester is signed, not just formal releases.

- prerelease tags excluded from stable LAST_TAG detection (both lookups)
  so a beta tag can't corrupt the next stable version computation
- beta tag force-created/pushed -> idempotent on failed-run re-runs
- changelog download gated to stable; release prerelease flag plumbed
  through to the Gitea REST payload

Reviewed-by: Code Review Agent (APPROVE WITH NITS; N1 hardened)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 07:56:17 -07:00
parent 40c7d860cc
commit 87f229509b

View File

@@ -27,6 +27,15 @@ on:
# computes the next semver from conventional commits at dispatch time.
# build-and-test.yml remains the automatic PR/push CI gate.
workflow_dispatch:
inputs:
channel:
description: 'Release channel (stable = full versioned release; beta = signed prerelease test build, no version bump/changelog)'
required: true
default: 'stable'
type: choice
options:
- stable
- beta
jobs:
# ---------------------------------------------------------------------------
@@ -36,8 +45,11 @@ jobs:
name: Version + Changelog
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.version }}
released: ${{ steps.bump.outputs.released }}
# Coalesce across the stable (bump) and beta (beta) paths: exactly one of them runs per
# dispatch, so the first non-empty value wins. prerelease is 'true' only on the beta path.
version: ${{ steps.bump.outputs.version || steps.beta.outputs.version }}
released: ${{ steps.bump.outputs.released || steps.beta.outputs.released }}
prerelease: ${{ steps.beta.outputs.prerelease || 'false' }}
steps:
- name: Checkout (full history + tags)
uses: actions/checkout@v4
@@ -59,7 +71,8 @@ jobs:
fi
- name: Install git-cliff
if: steps.guard.outputs.skip != 'true'
# Stable-only: beta produces no changelog, so git-cliff is unnecessary on the beta path.
if: steps.guard.outputs.skip != 'true' && github.event.inputs.channel == 'stable'
run: |
set -euo pipefail
CLIFF_VERSION="2.6.1"
@@ -72,12 +85,16 @@ jobs:
- name: Determine next version and bump components
id: bump
if: steps.guard.outputs.skip != 'true'
# Stable-only: the beta path (id: beta) handles versioning without a manifest bump/commit.
if: steps.guard.outputs.skip != 'true' && github.event.inputs.channel == 'stable'
run: |
set -euo pipefail
# ----- locate the last release tag (vX.Y.Z) -----
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | head -n1 || true)"
# Match ONLY strict final-release tags (vMAJOR.MINOR.PATCH). Beta tags look like
# v0.3.0-beta.7; if one of those were picked up here it would corrupt the next stable
# base version, so prerelease tags are explicitly excluded from this lookup.
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | 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/')"
@@ -186,8 +203,39 @@ jobs:
sed -i -E "0,/^version = \"[0-9]+\.[0-9]+\.[0-9]+\"/s//version = \"${NEXT}\"/" Cargo.toml || true
fi
- name: Beta channel - tag prerelease build (no bump, no commit, no changelog)
id: beta
# Beta-only path. Reuses the IDENTICAL downstream build + sign + publish jobs, but does
# NOT compute a semver bump, mutate any manifest, generate a changelog, or make a release
# commit. It just tags the CURRENT HEAD with a unique prerelease version so the Windows
# build job can check out `ref: v${VER}` exactly as it does for stable.
if: github.event.inputs.channel == 'beta' && steps.guard.outputs.skip != 'true'
run: |
set -euo pipefail
# Base version is read straight from the agent manifest — NOT bumped, NOT written back.
BASE="$(grep -m1 '^version' agent/Cargo.toml | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')"
# GITHUB_RUN_NUMBER guarantees a unique prerelease suffix without counting existing tags.
VER="${BASE}-beta.${GITHUB_RUN_NUMBER}"
echo "[INFO] Beta build version: ${VER} (base ${BASE}, run ${GITHUB_RUN_NUMBER})"
# Tag the current HEAD (no release commit). Push the tag so build-agent-windows can
# check out ref: v${VER}.
git config user.name "guruconnect-ci"
git config user.email "ci@azcomputerguru.com"
# Beta tags are disposable test markers; force makes re-running a failed beta dispatch idempotent (re-run reuses GITHUB_RUN_NUMBER, so the tag already exists).
git tag -f "v${VER}"
REMOTE="https://${{ secrets.CI_PUSH_TOKEN }}@git.azcomputerguru.com/${GITHUB_REPOSITORY}.git"
git push --force "${REMOTE}" "v${VER}"
echo "[OK] Pushed beta prerelease tag v${VER}"
echo "version=${VER}" >> "$GITHUB_OUTPUT"
echo "released=true" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
- name: Generate changelog (git-cliff)
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
# Stable-only: beta produces no changelog artifact.
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true' && github.event.inputs.channel == 'stable'
env:
VERSION: ${{ steps.bump.outputs.version }}
run: |
@@ -232,7 +280,10 @@ jobs:
# 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)"
# Match ONLY strict final-release tags (vMAJOR.MINOR.PATCH); exclude beta prerelease
# tags (v0.3.0-beta.7) so the changelog diff range is taken against the last real
# release, not an intervening beta build.
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)"
if [ -z "${LAST_TAG}" ]; then
CHANGED_FILES="$(git ls-files)"
FIRST_RELEASE=true
@@ -252,7 +303,8 @@ jobs:
fi
- name: Commit release + create tag
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
# Stable-only: beta tags HEAD directly in the beta step and never makes a release commit.
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true' && github.event.inputs.channel == 'stable'
env:
VERSION: ${{ steps.bump.outputs.version }}
run: |
@@ -276,7 +328,8 @@ jobs:
echo "[OK] Pushed release commit and tag v${VERSION}"
- name: Upload changelog artifact
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
# Stable-only: there is no changelog on the beta path, so nothing to upload.
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true' && github.event.inputs.channel == 'stable'
uses: actions/upload-artifact@v3
with:
name: changelog
@@ -445,6 +498,9 @@ jobs:
echo "sha256=${SUM}" >> "$GITHUB_OUTPUT"
- name: Download changelog artifact
# Stable-only: the beta path uploads no `changelog` artifact. The release-creation step
# already guards on `[ -f changelog-artifact/CHANGELOG.md ]`, so skipping this is safe.
if: github.event.inputs.channel == 'stable'
uses: actions/download-artifact@v3
with:
name: changelog
@@ -472,17 +528,26 @@ jobs:
env:
VERSION: ${{ needs.version.outputs.version }}
SHA256: ${{ steps.sha.outputs.sha256 }}
# PRERELEASE is 'true' on the beta path, 'false' on stable; drives the Gitea release flag.
PRERELEASE: ${{ needs.version.outputs.prerelease }}
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}"
echo "[INFO] Creating Gitea release ${TAG} on ${GITHUB_REPOSITORY} (prerelease=${PRERELEASE})"
BODY="$(printf 'GuruConnect %s\n\nSHA-256 (guruconnect.exe): %s\n\nSee CHANGELOG.md and /api/changelog for details.' "${TAG}" "${SHA256}")"
# Beta builds get a clear "prerelease test build" note in the body; the -beta.N suffix
# is already carried in TAG, so the release name "Release v..." needs no extra handling.
if [ "${PRERELEASE}" = "true" ]; then
BODY="$(printf 'GuruConnect %s (PRERELEASE / beta test build)\n\nSHA-256 (guruconnect.exe): %s\n\nSigned via Azure Trusted Signing. Not a stable release — no changelog/version bump.' "${TAG}" "${SHA256}")"
else
BODY="$(printf 'GuruConnect %s\n\nSHA-256 (guruconnect.exe): %s\n\nSee CHANGELOG.md and /api/changelog for details.' "${TAG}" "${SHA256}")"
fi
# 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}))')"
# prerelease is derived from the PRERELEASE env var (beta -> true, stable -> false).
CREATE_PAYLOAD="$(TAG="$TAG" BODY="$BODY" PRERELEASE="$PRERELEASE" 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": os.environ.get("PRERELEASE","false") == "true"}))')"
RELEASE_JSON="$(curl -fsS -X POST \
"${API_BASE}/releases" \