chore(ci): remove staging_tag promotion path from release-production (#3179)

This commit is contained in:
Steven Enamakel
2026-06-01 21:58:58 -07:00
committed by GitHub
parent 57865fd4a3
commit a7d42f52d0
2 changed files with 28 additions and 195 deletions
+27 -191
View File
@@ -3,37 +3,16 @@ name: Release Production
on:
workflow_dispatch:
inputs:
release_source:
description: |
Source ref for the production build.
- staging_tag: build the latest (or explicit) staging tag — recommended; the artifact
QA already exercised gets promoted to production.
- main_head: build main @ HEAD with a fresh version bump (escape hatch for hotfixes).
required: true
type: choice
default: staging_tag
options: [staging_tag, main_head]
staging_tag:
description:
Specific staging tag to promote (e.g. v1.2.4-staging). Leave empty to use the
latest matching tag. Ignored when release_source = main_head.
required: false
type: string
default: ""
release_type:
description:
Version increment type. Only consulted when release_source = main_head;
staging_tag promotions inherit the staging tag's version verbatim.
description: Version increment type for the production release.
required: false
default: patch
type: choice
options: [patch, minor, major]
commit_sha:
description:
Build from a specific commit SHA instead of main HEAD. Only
consulted when release_source = main_head; staging_tag promotions
resolve the commit from the tag. The commit must be reachable
from main. Leave empty to use main HEAD.
Build from a specific commit SHA instead of main HEAD. The commit
must be reachable from main. Leave empty to use main HEAD.
required: false
type: string
default: ""
@@ -49,8 +28,8 @@ permissions:
contents: write
packages: write
concurrency:
# Distinct group from release-staging.yml so promotions and staging cuts can
# run independently. The two workflows never touch the same tags or refs.
# Distinct group from release-staging.yml so production and staging cuts can
# run independently.
group: release-production
cancel-in-progress: false
# ---------------------------------------------------------------------------
@@ -80,52 +59,34 @@ concurrency:
# ---------------------------------------------------------------------------
jobs:
# =========================================================================
# Phase 0: Manual approval gate (main_head only).
# Phase 0: Manual approval gate.
#
# The `main_head` path bumps the version, COMMITS to `main`, and pushes that
# commit + tag using a GitHub App token that bypasses branch protection
# (prepare-build → "Commit, push and tag (main_head)"). If that App private
# key (secrets.XGITHUB_APP_PRIVATE_KEY) ever leaked, an attacker could push
# arbitrary commits to the default branch — CWE-250. This job parks the run
# on the `Release-Approval` environment (configured with required reviewers
# in repo settings) so a human must explicitly approve before prepare-build
# is allowed to run its push step. A rejected/cancelled approval leaves
# prepare-build skipped, so nothing is ever pushed.
#
# Scoped to `main_head` only: the `staging_tag` promotion path pushes just an
# immutable `v<version>` tag at an already-validated staging commit — it does
# NOT push a commit to `main` — so it stays gated only by the existing
# `Production` environment and is intentionally skipped here (no regression).
# This workflow bumps the version, COMMITS to `main`, and pushes that
# commit + tag using a GitHub App token that bypasses branch protection.
# If that App private key (secrets.XGITHUB_APP_PRIVATE_KEY) ever leaked,
# an attacker could push arbitrary commits to the default branch — CWE-250.
# This job parks the run on the `Release-Approval` environment (configured
# with required reviewers in repo settings) so a human must explicitly
# approve before prepare-build is allowed to run its push step.
# =========================================================================
review-approval:
name: Manual approval gate (main_head)
name: Manual approval gate
runs-on: ubuntu-latest
environment: Release-Approval
if: inputs.release_source == 'main_head'
steps:
- name: Record approval
run: |
echo "[release-production] main_head push to main approved by a required reviewer."
echo "[release-production] Push to main approved by a required reviewer."
echo "Proceeding to version bump + commit/tag push on prepare-build."
# =========================================================================
# Phase 1: Resolve build ref and (for main_head) bump version + create tag
# Phase 1: Bump version on main, commit, push, and optionally tag
# =========================================================================
prepare-build:
name: Prepare build context
runs-on: ubuntu-latest
environment: Production
needs: [review-approval]
# The push-to-main step lives inside this job (main_head path), so gating
# prepare-build on review-approval gates the push. `!cancelled()` (not
# `always()`) so a cancelled run never pushes; the success/skipped guard
# lets the `staging_tag` path through (review-approval is skipped there)
# while requiring an explicit approval success for `main_head`. A rejected
# approval yields result == 'failure', failing the guard → no push.
if: >-
!cancelled()
&& (needs.review-approval.result == 'success'
|| needs.review-approval.result == 'skipped')
outputs:
version: ${{ steps.resolve.outputs.version }}
tag: ${{ steps.resolve.outputs.tag }}
@@ -141,10 +102,6 @@ jobs:
base_url: ${{ steps.resolve.outputs.base_url }}
steps:
- name: Enforce main branch
# Both flows operate against main: main_head bumps and tags from main
# HEAD; staging_tag promotion creates the production tag from main
# (the tag's commit object lives in the same repo, so the tag is
# reachable via direct ref regardless of where it sits in history).
if: github.ref != 'refs/heads/main'
run: |
echo "This workflow can only run from main. Current ref: $GITHUB_REF"
@@ -172,14 +129,13 @@ jobs:
- name: Configure Git
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
RELEASE_SOURCE: ${{ inputs.release_source }}
COMMIT_SHA: ${{ inputs.commit_sha }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git remote set-url origin https://${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git
git fetch origin --tags --prune --prune-tags
if [ "$RELEASE_SOURCE" = "main_head" ] && [ -n "$COMMIT_SHA" ]; then
if [ -n "$COMMIT_SHA" ]; then
[[ "$COMMIT_SHA" =~ ^[0-9a-fA-F]{7,40}$ ]] || {
echo "Invalid commit_sha: must be a hex SHA (7-40 chars)"
exit 1
@@ -194,27 +150,17 @@ jobs:
git checkout main
git pull origin main --ff-only
fi
# ── Path A: main_head ────────────────────────────────────────────────
# Bump version on main, commit, push, tag.
- name: Compute next version and sync release files (main_head)
if: inputs.release_source == 'main_head'
- name: Compute next version and sync release files
id: bump
run: node scripts/release/bump-version.js "${{ inputs.release_type }}"
- name: Verify release version sync (main_head)
if: inputs.release_source == 'main_head'
- name: Verify release version sync
run: node scripts/release/verify-version-sync.js "${{ steps.bump.outputs.version }}"
- name: Refresh Cargo.lock files (main_head)
if: inputs.release_source == 'main_head'
# Cargo.toml [package].version is bumped above but the matching
# entries in both Cargo.lock files are not — without this step the
# lockfiles stay pinned to the previous version and the next local
# `cargo` invocation rewrites them, leaving an uncommitted diff.
- name: Refresh Cargo.lock files
run: |
cargo update --workspace --manifest-path Cargo.toml
cargo update --workspace --manifest-path app/src-tauri/Cargo.toml
- name: Ensure tag does not already exist (main_head)
if: inputs.release_source == 'main_head' && inputs.create_release
- name: Ensure tag does not already exist
if: inputs.create_release
env:
TAG: ${{ steps.bump.outputs.tag }}
run: |
@@ -226,8 +172,7 @@ jobs:
echo "Tag already exists on origin: $TAG"
exit 1
fi
- name: Commit and push version bump (main_head)
if: inputs.release_source == 'main_head'
- name: Commit and push version bump
id: push
env:
VERSION: ${{ steps.bump.outputs.version }}
@@ -245,136 +190,27 @@ jobs:
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# ── Path B: staging_tag ──────────────────────────────────────────────
# Resolve a previously cut staging tag, derive the production version
# from its package.json (no further bump — patch was applied during the
# staging cut), and create the production v<version> tag at the same
# commit. The artifact contents are byte-identical to what staging
# validated, modulo build-time env (BASE_URL, OPENHUMAN_APP_ENV, etc.).
- name: Resolve staging tag (staging_tag)
if: inputs.release_source == 'staging_tag'
id: stagingtag
env:
EXPLICIT_TAG: ${{ inputs.staging_tag }}
run: |
set -euo pipefail
if [ -n "$EXPLICIT_TAG" ]; then
STAGING_TAG="$EXPLICIT_TAG"
else
STAGING_TAG="$(git tag -l 'v*-staging' --sort=-v:refname | head -n 1)"
fi
if [ -z "$STAGING_TAG" ]; then
echo "No staging tags found matching v*-staging."
exit 1
fi
# Reject anything that isn't a `vX.Y.Z-staging` tag we cut
# ourselves — keeps an operator from accidentally promoting a
# hand-pushed ref or a stray pre-release tag.
if ! [[ "$STAGING_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-staging$ ]]; then
echo "Invalid staging tag format: $STAGING_TAG (expected vX.Y.Z-staging)"
exit 1
fi
if ! git rev-parse --verify "refs/tags/$STAGING_TAG" >/dev/null 2>&1; then
echo "Staging tag not present locally: $STAGING_TAG"
exit 1
fi
STAGING_SHA="$(git rev-list -n 1 "$STAGING_TAG")"
# Strip the -staging suffix to get the production version.
PROD_VERSION="${STAGING_TAG#v}"
PROD_VERSION="${PROD_VERSION%-staging}"
PROD_TAG="v${PROD_VERSION}"
# Sanity-check every authoritative version source on the staging
# commit. They must all agree with PROD_VERSION — if they drift,
# the bundled installer reports a different version than the
# GitHub Release tag, and the Sentry release tag stops matching
# what the running binary emits.
read_json_version() {
git show "${STAGING_TAG}:$1" | node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>process.stdout.write(JSON.parse(d).version||""))'
}
read_cargo_version() {
git show "${STAGING_TAG}:$1" | sed -n '/^\[package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p; }' | head -n 1
}
for src in \
"app/package.json" \
"app/src-tauri/tauri.conf.json" \
"app/src-tauri/Cargo.toml" \
"Cargo.toml"; do
case "$src" in
*.json) actual="$(read_json_version "$src")" ;;
*.toml) actual="$(read_cargo_version "$src")" ;;
esac
if [ "$actual" != "$PROD_VERSION" ]; then
echo "Staging tag $STAGING_TAG version mismatch: $src reports '$actual' but tag implies '$PROD_VERSION'"
exit 1
fi
done
echo "staging_tag=$STAGING_TAG" >> "$GITHUB_OUTPUT"
echo "staging_sha=$STAGING_SHA" >> "$GITHUB_OUTPUT"
echo "prod_version=$PROD_VERSION" >> "$GITHUB_OUTPUT"
echo "prod_tag=$PROD_TAG" >> "$GITHUB_OUTPUT"
- name: Ensure production tag does not already exist (staging_tag)
if: inputs.release_source == 'staging_tag' && inputs.create_release
env:
TAG: ${{ steps.stagingtag.outputs.prod_tag }}
run: |
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag already exists locally: $TAG"
exit 1
fi
if git ls-remote --tags origin "refs/tags/$TAG" | grep -q .; then
echo "Tag already exists on origin: $TAG"
exit 1
fi
- name: Create production tag at staging commit (staging_tag)
if: inputs.release_source == 'staging_tag' && inputs.create_release
id: promote
env:
STAGING_TAG: ${{ steps.stagingtag.outputs.staging_tag }}
STAGING_SHA: ${{ steps.stagingtag.outputs.staging_sha }}
PROD_TAG: ${{ steps.stagingtag.outputs.prod_tag }}
run: |
git tag -a "$PROD_TAG" "$STAGING_SHA" -m "Release $PROD_TAG (promoted from $STAGING_TAG)"
git push origin "$PROD_TAG"
echo "sha=$STAGING_SHA" >> "$GITHUB_OUTPUT"
- name: Resolve build outputs
id: resolve
shell: bash
env:
RELEASE_SOURCE: ${{ inputs.release_source }}
CREATE_RELEASE: ${{ inputs.create_release }}
BUMP_VERSION: ${{ steps.bump.outputs.version }}
BUMP_TAG: ${{ steps.bump.outputs.tag }}
PUSH_SHA: ${{ steps.push.outputs.sha }}
PROMOTE_VERSION: ${{ steps.stagingtag.outputs.prod_version }}
PROMOTE_TAG: ${{ steps.stagingtag.outputs.prod_tag }}
PROMOTE_SHA: ${{ steps.promote.outputs.sha }}
STAGING_SHA: ${{ steps.stagingtag.outputs.staging_sha }}
VERSION: ${{ steps.bump.outputs.version }}
TAG: ${{ steps.bump.outputs.tag }}
SHA: ${{ steps.push.outputs.sha }}
run: |
if [ "$RELEASE_SOURCE" = "main_head" ]; then
VERSION="$BUMP_VERSION"
TAG="$BUMP_TAG"
SHA="$PUSH_SHA"
else
VERSION="$PROMOTE_VERSION"
TAG="$PROMOTE_TAG"
# When create_release=false on staging_tag path, promote step
# is skipped so PROMOTE_SHA is empty — fall back to staging SHA.
SHA="${PROMOTE_SHA:-$STAGING_SHA}"
fi
if [ "$CREATE_RELEASE" = "true" ]; then
BUILD_REF="$TAG"
else
BUILD_REF="$SHA"
fi
BASE_URL="https://api.tinyhumans.ai/"
SHORT_SHA="${SHA:0:12}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
echo "build_ref=$BUILD_REF" >> "$GITHUB_OUTPUT"
echo "base_url=$BASE_URL" >> "$GITHUB_OUTPUT"
echo "base_url=https://api.tinyhumans.ai/" >> "$GITHUB_OUTPUT"
# =========================================================================
# Phase 2: Create draft GitHub release
+1 -4
View File
@@ -48,12 +48,9 @@ concurrency:
# ---------------------------------------------------------------------------
jobs:
# =========================================================================
# Phase 1: Patch-bump on `main` and create the immutable
# Phase 1: Patch-bump on `main` and optionally create the immutable
# `v<version>-staging` tag at that commit. The build matrix below then
# checks out the tag (not main HEAD) so reruns reproduce byte-for-byte.
# Production promotion (`release-production.yml`,
# `release_source = staging_tag`) reads this tag verbatim and creates a
# `v<version>` tag at the same commit.
# =========================================================================
prepare-build:
name: Prepare build context