From 87f229509b6910b77b84db49cfd3c6706ed40690 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Tue, 2 Jun 2026 07:56:17 -0700 Subject: [PATCH] ci(release): add signed beta/test release channel 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.) 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) --- .gitea/workflows/release.yml | 89 +++++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 0a2f687..fd825f6 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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" \