From a7d42f52d04a8b620eb66ea15f137912da20854c Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:58:58 -0700 Subject: [PATCH] chore(ci): remove staging_tag promotion path from release-production (#3179) --- .github/workflows/release-production.yml | 218 +++-------------------- .github/workflows/release-staging.yml | 5 +- 2 files changed, 28 insertions(+), 195 deletions(-) diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index 337f1d88c..2fd9036b8 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -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` 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 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 diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index a0175a72c..b08c80245 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -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-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` tag at the same commit. # ========================================================================= prepare-build: name: Prepare build context