From 1dcab8d81aae23ede2e44c6446d5218abaa9dd90 Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Fri, 18 Sep 2026 15:46:03 +0530 Subject: [PATCH 1/6] feat: auto-merge chart release and support release-v* hotfixes --- .../build-and-prepare-chart-release.yml | 62 +++++++++++++- .github/workflows/release-chart.yml | 4 +- .github/workflows/release.yml | 7 +- RELEASING.md | 84 +++++++++++++------ 4 files changed, 124 insertions(+), 33 deletions(-) diff --git a/.github/workflows/build-and-prepare-chart-release.yml b/.github/workflows/build-and-prepare-chart-release.yml index 654c172b2..8d711f11e 100644 --- a/.github/workflows/build-and-prepare-chart-release.yml +++ b/.github/workflows/build-and-prepare-chart-release.yml @@ -18,6 +18,11 @@ on: required: false type: boolean default: false + merge_chart_pr: + description: After opening the chart PR, wait for CI and squash-merge it + required: false + type: boolean + default: true workflow_dispatch: inputs: app_version: @@ -29,9 +34,14 @@ on: required: false type: boolean default: false + merge_chart_pr: + description: After opening the chart PR, wait for CI and squash-merge it + required: false + type: boolean + default: false concurrency: - group: release-image-and-chart + group: release-image-and-chart-${{ github.ref_name }} cancel-in-progress: false permissions: {} @@ -45,6 +55,8 @@ jobs: image_tag: ${{ steps.resolve.outputs.image_tag }} update_app_version: ${{ steps.resolve.outputs.update_app_version }} short_sha: ${{ steps.resolve.outputs.short_sha }} + chart_base: ${{ steps.resolve.outputs.chart_base }} + merge_chart_pr: ${{ steps.resolve.outputs.merge_chart_pr }} permissions: contents: read steps: @@ -61,6 +73,8 @@ jobs: env: INPUT_APP_VERSION: ${{ inputs.app_version }} INPUT_UPDATE_APP_VERSION: ${{ inputs.update_app_version }} + INPUT_MERGE_CHART_PR: ${{ inputs.merge_chart_pr }} + REF_NAME: ${{ github.ref_name }} run: | set -euo pipefail CHART_APP=$(yq -r '.appVersion' charts/trueforge/Chart.yaml | tr -d '"') @@ -86,11 +100,24 @@ jobs: else UPDATE_OUT=false fi + MERGE_IN="${INPUT_MERGE_CHART_PR:-false}" + if [[ "$MERGE_IN" == "true" ]]; then + MERGE_OUT=true + else + MERGE_OUT=false + fi + if [[ "$REF_NAME" == release-v* ]]; then + CHART_BASE="$REF_NAME" + else + CHART_BASE=main + fi echo "app_version=$APP_VERSION" >> "$GITHUB_OUTPUT" echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT" echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" echo "update_app_version=$UPDATE_OUT" >> "$GITHUB_OUTPUT" - echo "Resolved APP_VERSION=$APP_VERSION IMAGE_TAG=$IMAGE_TAG update_app_version=$UPDATE_OUT" + echo "chart_base=$CHART_BASE" >> "$GITHUB_OUTPUT" + echo "merge_chart_pr=$MERGE_OUT" >> "$GITHUB_OUTPUT" + echo "Resolved APP_VERSION=$APP_VERSION IMAGE_TAG=$IMAGE_TAG update_app_version=$UPDATE_OUT chart_base=$CHART_BASE merge_chart_pr=$MERGE_OUT" build: name: Build and push server image @@ -174,11 +201,12 @@ jobs: echo "app_version=$APP_VERSION" >> "$GITHUB_OUTPUT" - name: Open or update chart-release PR + id: open-pr uses: peter-evans/create-pull-request@v8 with: token: ${{ steps.app-token.outputs.token }} branch: ${{ env.PR_BRANCH }} - base: main + base: ${{ needs.resolve.outputs.chart_base }} commit-message: 'release(chart): ${{ steps.chart.outputs.version }}' title: 'release(chart): ${{ steps.chart.outputs.version }} (image ${{ needs.resolve.outputs.image_tag }})' body: | @@ -189,9 +217,37 @@ jobs: | Chart version | `${{ steps.chart.outputs.version }}` | | appVersion | `${{ steps.chart.outputs.app_version }}` | | image.tag | `${{ needs.resolve.outputs.image_tag }}` | + | Base | `${{ needs.resolve.outputs.chart_base }}` | Merging this PR creates `charts/trueforge@${{ steps.chart.outputs.version }}`, creates a GitHub Release, and publishes the OCI Helm chart. add-paths: | charts/trueforge/Chart.yaml charts/trueforge/values.yaml + + - name: Wait for CI and merge chart PR + if: >- + needs.resolve.outputs.merge_chart_pr == 'true' && + steps.open-pr.outputs.pull-request-number != '' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR_NUMBER: ${{ steps.open-pr.outputs.pull-request-number }} + PR_BRANCH: ${{ env.PR_BRANCH }} + run: | + set -euo pipefail + HEAD_REF=$(gh pr view "$PR_NUMBER" --json headRefName --jq .headRefName) + if [[ "$HEAD_REF" != "$PR_BRANCH" ]]; then + echo "Refusing to merge PR #$PR_NUMBER with unexpected head $HEAD_REF" >&2 + exit 1 + fi + # Chart PRs typically register + sleep 20 + for _ in $(seq 1 30); do + gh pr checks "$PR_NUMBER" >/dev/null 2>&1 && break + sleep 5 + done + gh pr checks "$PR_NUMBER" --watch --fail-fast + if ! gh pr merge "$PR_NUMBER" --squash; then + echo "Merge blocked. Add limited trueforge-dev-bot ruleset bypass (pull_request mode; keep required checks on a no-bypass ruleset)." >&2 + exit 1 + fi diff --git a/.github/workflows/release-chart.yml b/.github/workflows/release-chart.yml index 3a51464e7..b4998880a 100644 --- a/.github/workflows/release-chart.yml +++ b/.github/workflows/release-chart.yml @@ -5,7 +5,9 @@ name: Publish Helm chart on: pull_request: types: [closed] - branches: [main] + branches: + - main + - 'release-v*' push: tags: - 'charts/trueforge@*' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b701f955..2f6851715 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ # npm and PyPI trusted publishers are bound to `release.yml`. # Do not rename this file without updating every package on npmjs.com and PyPI. # -# On every push to main, changesets/action/select-mode chooses: +# On every push to main or release-v*, changesets/action/select-mode chooses: # - pending `.changeset/*.md` → version job opens/updates the Version Packages PR # (install only; that PR is gated by CI.yml). When @truefoundry/trueforge-sdk # moves, scripts/version.mjs mirrors that version into python/trueforge_sdk and rebakes Fern. @@ -19,7 +19,9 @@ name: Release on: push: - branches: [main] + branches: + - main + - 'release-v*' workflow_dispatch: concurrency: @@ -329,3 +331,4 @@ jobs: with: app_version: ${{ needs.publish.outputs.trueforge_version }} update_app_version: true + merge_chart_pr: true diff --git a/RELEASING.md b/RELEASING.md index b52b2b6ae..148b338bc 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,10 +5,10 @@ sandbox image, and optional from-source **dev** images. | What | Trigger | Workflow | | ----------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| npm packages | Push to `main` (Changesets) | [`release.yml`](.github/workflows/release.yml) | +| npm packages | Push to `main` or `release-v*` (Changesets) | [`release.yml`](.github/workflows/release.yml) | | PyPI `trueforge-sdk` | Same `mode=publish` run as npm (parallel OIDC job) | [`release.yml`](.github/workflows/release.yml) | | Prod image + chart-release PR | After `@truefoundry/trueforge` npm publish (reusable workflow), or manual dispatch | [`build-and-prepare-chart-release.yml`](.github/workflows/build-and-prepare-chart-release.yml) | -| Chart tag, GitHub Release, OCI push | Merge of `release-chart/trueforge`, or push/dispatch of `charts/trueforge@*` | [`release-chart.yml`](.github/workflows/release-chart.yml) | +| Chart tag, GitHub Release, OCI push | Auto-merge (or manual merge) of `release-chart/trueforge`, or tag/dispatch | [`release-chart.yml`](.github/workflows/release-chart.yml) | | Sandbox image + pin PR | Push to `main` when `scripts/sandbox/**` changes, or dispatch | [`push-sandbox-image.yml`](.github/workflows/push-sandbox-image.yml) | | Dev (from-source) image | Manual `workflow_dispatch` | [`build-dev-image.yml`](.github/workflows/build-dev-image.yml) | @@ -48,23 +48,24 @@ helm install trueforge oci://tfy.jfrog.io/tfy-helm/trueforge --version `). SDK regen already adds `@truefoundry/trueforge-sdk` via `pnpm changeset:sdk-regen`. -2. Merge to `main`. Pending changesets → **Version Packages** PR - (`pnpm run version`). When `@truefoundry/trueforge-sdk` moves, - `scripts/version.mjs` mirrors that version into `python/trueforge_sdk` and - regenerates both SDKs. Review and merge. +2. Merge to the release branch (`main` or `release-v*`). Pending changesets → + **Version Packages** PR targeting that branch (`pnpm run version`). When + `@truefoundry/trueforge-sdk` moves, `scripts/version.mjs` mirrors that version + into `python/trueforge_sdk` and regenerates both SDKs. Review and merge. 3. With no pending changesets, **pack** (build/test) and **Windows npx smoke** run in parallel, then **npm publish** and **PyPI publish** run in parallel via trusted publishing (OIDC; no `NPM_TOKEN` / `PYPI_TOKEN`). PyPI skips when that `pyproject.toml` version is already published. 4. If `@truefoundry/trueforge` was published, **Release** calls **Build and - prepare chart release** as a reusable workflow on the same commit (so a - newer `main` push cannot change the Dockerfile / shortSha). GitHub's - `workflow_dispatch` API only accepts a branch or tag name, not a SHA. + prepare chart release** on the same commit: image build, chart bot PR, then + (by default) wait for CI and squash-merge so OCI publish runs without a + human merge. GitHub's `workflow_dispatch` API only accepts a branch or tag + name, not a SHA. 5. Pin dependents to exact versions during early `0.x`. `workflow_dispatch` on **Release** re-runs the same workflow. @@ -134,12 +135,13 @@ pnpm clean && pnpm build && pnpm standalone:start npm publish @truefoundry/trueforge@X.Y.Z → call build-and-prepare-chart-release (same commit as publish) → build Dockerfile (APP_VERSION=X.Y.Z) → push X.Y.Z- - → open/update PR on branch release-chart/trueforge - → merge PR → tag + GH Release + OCI push (release-chart.yml) + → open/update PR on branch release-chart/trueforge (base = main or release-v*) + → wait for CI check → squash-merge (merge_chart_pr=true) + → tag + GH Release + OCI push (release-chart.yml) -manual rebuild (same or other app version) - → workflow_dispatch build-and-prepare-chart-release - → same PR path +manual rebuild (inspect without merge) + → workflow_dispatch build-and-prepare-chart-release (merge_chart_pr=false) + → same PR path; merge by hand when ready chart-only → human PR bumps Chart.yaml version @@ -147,6 +149,30 @@ chart-only → release-chart.yml publishes OCI (no image rebuild) ``` +## Hotfix release branches + +Cut a line from a shipped commit so a patch does not take tip-of-`main`: + +```bash +git fetch origin +git checkout -b release-vX.Y.Z +# or from a chart tag: +# git checkout -b release-vX.Y.Z charts/trueforge@A.B.C +git push -u origin release-vX.Y.Z +``` + +Then cherry-pick the fix + changeset onto that branch, merge the Version Packages +PR that targets `release-vX.Y.Z`, and let **Release** publish npm/PyPI and auto +chart OCI. Pass the resulting chart SemVer to helm-charts +`release-start` as `trueforge_chart_version` (control-plane pin). + +Org rules still require human approval on Version Packages PRs into `release-v*`. +Chart auto-merge needs the limited `trueforge-dev-bot` ruleset bypass +(`pull_request` mode; required CI checks on a no-bypass ruleset). + +Smoke-test the first hotfix npm/PyPI publish: trusted publishers bind to +`release.yml` with no Environment name. + ## Dockerfiles | File | Role | @@ -162,13 +188,15 @@ even when `main` has moved on. [`build-and-prepare-chart-release.yml`](.github/workflows/build-and-prepare-chart-release.yml) (`workflow_call` from **Release**, or manual `workflow_dispatch`): -| Input | Default | Meaning | -| -------------------- | ------------------------- | -------------------------------------------------------------------- | -| `app_version` | `Chart.yaml` `appVersion` | npm version to install into the image | -| `update_app_version` | `false` | Also write that version into `Chart.yaml` `appVersion` on the bot PR | +| Input | Default | Meaning | +| -------------------- | ---------------------------------- | -------------------------------------------------------------------- | +| `app_version` | `Chart.yaml` `appVersion` | npm version to install into the image | +| `update_app_version` | `false` | Also write that version into `Chart.yaml` `appVersion` on the bot PR | +| `merge_chart_pr` | `true` (call) / `false` (dispatch) | Wait for CI `check` and squash-merge the chart bot PR | Always: build/push `{appVersion}-{shortSha}`, patch-bump chart `version`, set `image.tag`, -open/update one PR on `release-chart/trueforge`. +open/update one PR on `release-chart/trueforge` (base = `main` or the current +`release-v*` branch). ```bash gh workflow run build-and-prepare-chart-release.yml @@ -176,23 +204,25 @@ gh workflow run build-and-prepare-chart-release.yml -f app_version=0.1.0 # after npm publish of a new app version: gh workflow run build-and-prepare-chart-release.yml \ -f app_version=0.1.0 -f update_app_version=true +# inspect without auto-merge: +gh workflow run build-and-prepare-chart-release.yml -f merge_chart_pr=false ``` You may edit chart SemVer (minor/major) on the PR before merging; the tag follows `Chart.yaml` `version` at merge time. Each run rebuilds the `release-chart/trueforge` branch -from `main`, but a chart `version` on the branch that outranks the patch bump is carried over, +from the chart base, but a chart `version` on the branch that outranks the patch bump is carried over, so a manual bump survives later image rebuilds. Other manual edits on that branch do not — -commit them to `main` instead. +commit them to the base branch instead. ## Publish Helm chart [`release-chart.yml`](.github/workflows/release-chart.yml) is one job with three entry points: -| Trigger | What it does | -| --------------------------------------------------------- | ------------------------------------------------------------------- | -| Merged PR from `release-chart/trueforge` (same repo only) | Create `charts/trueforge@` + GitHub Release, then OCI push | -| Push of tag `charts/trueforge@*` | OCI push only (tag already exists) | -| `workflow_dispatch` with `tag=` | OCI push for an existing tag (retry) | +| Trigger | What it does | +| -------------------------------------------------------------------- | ------------------------------------------------------------------- | +| Merged PR from `release-chart/trueforge` into `main` or `release-v*` | Create `charts/trueforge@` + GitHub Release, then OCI push | +| Push of tag `charts/trueforge@*` | OCI push only (tag already exists) | +| `workflow_dispatch` with `tag=` | OCI push for an existing tag (retry) | Only the `release-chart/trueforge` branch auto-tags. Ordinary merges never create chart tags. From 29d4b1e18322c6f9481a6f1e0eec86c4bcd105ae Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Fri, 18 Sep 2026 16:06:59 +0530 Subject: [PATCH 2/6] fix: shared bot branch collision --- .../workflows/build-and-prepare-chart-release.yml | 11 ++++++++--- .github/workflows/release-chart.yml | 5 ++++- RELEASING.md | 12 ++++++------ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-and-prepare-chart-release.yml b/.github/workflows/build-and-prepare-chart-release.yml index 8d711f11e..43165ae45 100644 --- a/.github/workflows/build-and-prepare-chart-release.yml +++ b/.github/workflows/build-and-prepare-chart-release.yml @@ -56,6 +56,7 @@ jobs: update_app_version: ${{ steps.resolve.outputs.update_app_version }} short_sha: ${{ steps.resolve.outputs.short_sha }} chart_base: ${{ steps.resolve.outputs.chart_base }} + pr_branch: ${{ steps.resolve.outputs.pr_branch }} merge_chart_pr: ${{ steps.resolve.outputs.merge_chart_pr }} permissions: contents: read @@ -108,16 +109,20 @@ jobs: fi if [[ "$REF_NAME" == release-v* ]]; then CHART_BASE="$REF_NAME" + # One bot head per base so main and hotfix runs cannot clobber each other. + PR_BRANCH="release-chart/trueforge-${CHART_BASE}" else CHART_BASE=main + PR_BRANCH=release-chart/trueforge fi echo "app_version=$APP_VERSION" >> "$GITHUB_OUTPUT" echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT" echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" echo "update_app_version=$UPDATE_OUT" >> "$GITHUB_OUTPUT" echo "chart_base=$CHART_BASE" >> "$GITHUB_OUTPUT" + echo "pr_branch=$PR_BRANCH" >> "$GITHUB_OUTPUT" echo "merge_chart_pr=$MERGE_OUT" >> "$GITHUB_OUTPUT" - echo "Resolved APP_VERSION=$APP_VERSION IMAGE_TAG=$IMAGE_TAG update_app_version=$UPDATE_OUT chart_base=$CHART_BASE merge_chart_pr=$MERGE_OUT" + echo "Resolved APP_VERSION=$APP_VERSION IMAGE_TAG=$IMAGE_TAG update_app_version=$UPDATE_OUT chart_base=$CHART_BASE pr_branch=$PR_BRANCH merge_chart_pr=$MERGE_OUT" build: name: Build and push server image @@ -150,7 +155,7 @@ jobs: contents: write pull-requests: write env: - PR_BRANCH: release-chart/trueforge + PR_BRANCH: ${{ needs.resolve.outputs.pr_branch }} steps: - id: app-token uses: actions/create-github-app-token@v3 @@ -240,7 +245,7 @@ jobs: echo "Refusing to merge PR #$PR_NUMBER with unexpected head $HEAD_REF" >&2 exit 1 fi - # Chart PRs typically register + # Checks usually appear within ~10s of PR open. sleep 20 for _ in $(seq 1 30); do gh pr checks "$PR_NUMBER" >/dev/null 2>&1 && break diff --git a/.github/workflows/release-chart.yml b/.github/workflows/release-chart.yml index b4998880a..9c5f46f28 100644 --- a/.github/workflows/release-chart.yml +++ b/.github/workflows/release-chart.yml @@ -31,7 +31,10 @@ jobs: github.event_name != 'pull_request' || ( github.event.pull_request.merged == true && - github.event.pull_request.head.ref == 'release-chart/trueforge' && + ( + github.event.pull_request.head.ref == 'release-chart/trueforge' || + startsWith(github.event.pull_request.head.ref, 'release-chart/trueforge-') + ) && github.event.pull_request.head.repo.full_name == github.repository ) runs-on: ubuntu-latest diff --git a/RELEASING.md b/RELEASING.md index 148b338bc..c40af5235 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -135,7 +135,7 @@ pnpm clean && pnpm build && pnpm standalone:start npm publish @truefoundry/trueforge@X.Y.Z → call build-and-prepare-chart-release (same commit as publish) → build Dockerfile (APP_VERSION=X.Y.Z) → push X.Y.Z- - → open/update PR on branch release-chart/trueforge (base = main or release-v*) + → open/update chart bot PR (release-chart/trueforge or release-chart/trueforge-release-v*) → wait for CI check → squash-merge (merge_chart_pr=true) → tag + GH Release + OCI push (release-chart.yml) @@ -195,9 +195,8 @@ even when `main` has moved on. | `merge_chart_pr` | `true` (call) / `false` (dispatch) | Wait for CI `check` and squash-merge the chart bot PR | Always: build/push `{appVersion}-{shortSha}`, patch-bump chart `version`, set `image.tag`, -open/update one PR on `release-chart/trueforge` (base = `main` or the current -`release-v*` branch). - +open/update one PR on `release-chart/trueforge` (base `main`) or +`release-chart/trueforge-` (hotfix base). ```bash gh workflow run build-and-prepare-chart-release.yml gh workflow run build-and-prepare-chart-release.yml -f app_version=0.1.0 @@ -220,11 +219,12 @@ commit them to the base branch instead. | Trigger | What it does | | -------------------------------------------------------------------- | ------------------------------------------------------------------- | -| Merged PR from `release-chart/trueforge` into `main` or `release-v*` | Create `charts/trueforge@` + GitHub Release, then OCI push | +| Merged PR from `release-chart/trueforge` or `release-chart/trueforge-*` into `main` / `release-v*` | Create `charts/trueforge@` + GitHub Release, then OCI push | | Push of tag `charts/trueforge@*` | OCI push only (tag already exists) | | `workflow_dispatch` with `tag=` | OCI push for an existing tag (retry) | -Only the `release-chart/trueforge` branch auto-tags. Ordinary merges never create chart tags. +Only `release-chart/trueforge` (main) and `release-chart/trueforge-*` (hotfix) +heads auto-tag. Ordinary merges never create chart tags. Chart-only example: From e04405728a96c3647166852aeb48d835407230c4 Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Fri, 18 Sep 2026 16:11:09 +0530 Subject: [PATCH 3/6] fix: stale chart version vs published tags --- .../build-and-prepare-chart-release.yml | 19 ++++++++++++++++++- RELEASING.md | 4 +++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-prepare-chart-release.yml b/.github/workflows/build-and-prepare-chart-release.yml index 43165ae45..161322648 100644 --- a/.github/workflows/build-and-prepare-chart-release.yml +++ b/.github/workflows/build-and-prepare-chart-release.yml @@ -167,6 +167,8 @@ jobs: uses: actions/checkout@v7 with: token: ${{ steps.app-token.outputs.token }} + # Full history so charts/trueforge@* tags are available for the version baseline. + fetch-depth: 0 - name: Install yq run: | @@ -191,10 +193,25 @@ jobs: git fetch --depth=1 origin "$PR_BRANCH" PR_VERSION=$(git show FETCH_HEAD:charts/trueforge/Chart.yaml | yq -r '.version') fi - VERSION=$(bash scripts/resolve-chart-version.sh "$CURRENT" "$APP_VERSION" "$PR_VERSION") + + # Hotfix release-v* branches are often cut from an old SHA whose + # Chart.yaml lags tags already published from main. Bumping only from + # Chart.yaml would reuse a charts/trueforge@* tag and fail at publish. + # Floor on the highest published tag too (e.g. Chart.yaml 0.2.0-rc.5, + # tag 0.2.0-rc.10 → bump from 0.2.0-rc.10). + HIGHEST_TAG=$(git tag -l 'charts/trueforge@*' | sed 's|^charts/trueforge@||' | sort -V | tail -1 || true) + BASELINE=$(printf '%s\n%s\n' "$CURRENT" "$HIGHEST_TAG" | sort -V | tail -1) + + VERSION=$(bash scripts/resolve-chart-version.sh "$BASELINE" "$APP_VERSION" "$PR_VERSION") + + if [[ "$BASELINE" != "$CURRENT" ]]; then + echo "Using published tag baseline $BASELINE (Chart.yaml was $CURRENT)" + fi + if [[ -n "$PR_VERSION" && "$VERSION" == "$PR_VERSION" ]]; then echo "Keeping chart version $PR_VERSION from $PR_BRANCH" fi + export VERSION IMAGE_TAG APP_VERSION yq -i '.version = strenv(VERSION)' charts/trueforge/Chart.yaml yq -i '.image.tag = strenv(IMAGE_TAG)' charts/trueforge/values.yaml diff --git a/RELEASING.md b/RELEASING.md index c40af5235..ec25fd2e3 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -196,7 +196,9 @@ even when `main` has moved on. Always: build/push `{appVersion}-{shortSha}`, patch-bump chart `version`, set `image.tag`, open/update one PR on `release-chart/trueforge` (base `main`) or -`release-chart/trueforge-` (hotfix base). +`release-chart/trueforge-` (hotfix base). Chart version baseline is +`max(Chart.yaml, highest charts/trueforge@* tag)` so hotfix cuts cannot reuse a +published tag. ```bash gh workflow run build-and-prepare-chart-release.yml gh workflow run build-and-prepare-chart-release.yml -f app_version=0.1.0 From 9e350d41699fc6af83a45a232d27fa1abf94d685 Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Fri, 18 Sep 2026 16:43:12 +0530 Subject: [PATCH 4/6] fix: Parallel releases collide on chart version --- .../build-and-prepare-chart-release.yml | 20 +++++++++++++++++++ RELEASING.md | 4 +++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-prepare-chart-release.yml b/.github/workflows/build-and-prepare-chart-release.yml index 161322648..6a3b49612 100644 --- a/.github/workflows/build-and-prepare-chart-release.yml +++ b/.github/workflows/build-and-prepare-chart-release.yml @@ -151,6 +151,12 @@ jobs: name: Open chart release PR needs: [resolve, build] runs-on: ubuntu-latest + # Chart SemVer is global (charts/trueforge@*). Per-ref workflow concurrency + # allows main and release-v* to assign the same next version; serialize this + # job so only one run bumps/opens/merges at a time. + concurrency: + group: release-chart-version-and-pr + cancel-in-progress: false permissions: contents: write pull-requests: write @@ -199,6 +205,7 @@ jobs: # Chart.yaml would reuse a charts/trueforge@* tag and fail at publish. # Floor on the highest published tag too (e.g. Chart.yaml 0.2.0-rc.5, # tag 0.2.0-rc.10 → bump from 0.2.0-rc.10). + git fetch --tags --force origin HIGHEST_TAG=$(git tag -l 'charts/trueforge@*' | sed 's|^charts/trueforge@||' | sort -V | tail -1 || true) BASELINE=$(printf '%s\n%s\n' "$CURRENT" "$HIGHEST_TAG" | sort -V | tail -1) @@ -255,6 +262,7 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_NUMBER: ${{ steps.open-pr.outputs.pull-request-number }} PR_BRANCH: ${{ env.PR_BRANCH }} + CHART_VERSION: ${{ steps.chart.outputs.version }} run: | set -euo pipefail HEAD_REF=$(gh pr view "$PR_NUMBER" --json headRefName --jq .headRefName) @@ -273,3 +281,15 @@ jobs: echo "Merge blocked. Add limited trueforge-dev-bot ruleset bypass (pull_request mode; keep required checks on a no-bypass ruleset)." >&2 exit 1 fi + # Hold job concurrency until release-chart.yml creates the tag, so the + # next queued run's baseline includes this SemVer. + TAG="charts/trueforge@${CHART_VERSION}" + for _ in $(seq 1 60); do + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Tag ${TAG} is visible on origin" + exit 0 + fi + sleep 10 + done + echo "Timed out waiting for ${TAG} after merge" >&2 + exit 1 diff --git a/RELEASING.md b/RELEASING.md index c8add5648..3ce1ad708 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -198,7 +198,9 @@ Always: build/push `{appVersion}-{shortSha}`, patch-bump chart `version`, set `i open/update one PR on `release-chart/trueforge` (base `main`) or `release-chart/trueforge-` (hotfix base). Chart version baseline is `max(Chart.yaml, highest charts/trueforge@* tag)` so hotfix cuts cannot reuse a -published tag. +published tag. Image builds may run per-ref in parallel; chart version assign + +PR open/merge is globally serialized, and auto-merge waits until the +`charts/trueforge@*` tag exists before the next run starts. ```bash gh workflow run build-and-prepare-chart-release.yml From 76adeb2179cb9cb52b8fda3aebfb36fb85fd2a5a Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Fri, 18 Sep 2026 17:03:25 +0530 Subject: [PATCH 5/6] handle for hotfixes --- .../build-and-prepare-chart-release.yml | 27 ++-- RELEASING.md | 15 +- package.json | 2 +- scripts/highest-chart-tag-on-line.sh | 45 ++++++ .../scripts/highest-chart-tag-on-line.test.sh | 130 ++++++++++++++++++ 5 files changed, 201 insertions(+), 18 deletions(-) create mode 100755 scripts/highest-chart-tag-on-line.sh create mode 100755 tests/scripts/highest-chart-tag-on-line.test.sh diff --git a/.github/workflows/build-and-prepare-chart-release.yml b/.github/workflows/build-and-prepare-chart-release.yml index 6a3b49612..82de81ac9 100644 --- a/.github/workflows/build-and-prepare-chart-release.yml +++ b/.github/workflows/build-and-prepare-chart-release.yml @@ -151,9 +151,8 @@ jobs: name: Open chart release PR needs: [resolve, build] runs-on: ubuntu-latest - # Chart SemVer is global (charts/trueforge@*). Per-ref workflow concurrency - # allows main and release-v* to assign the same next version; serialize this - # job so only one run bumps/opens/merges at a time. + # Chart SemVer lines can advance in parallel in theory, but tag publish and + # PR merge still race on shared git state; serialize this job. concurrency: group: release-chart-version-and-pr cancel-in-progress: false @@ -200,19 +199,25 @@ jobs: PR_VERSION=$(git show FETCH_HEAD:charts/trueforge/Chart.yaml | yq -r '.version') fi - # Hotfix release-v* branches are often cut from an old SHA whose - # Chart.yaml lags tags already published from main. Bumping only from - # Chart.yaml would reuse a charts/trueforge@* tag and fail at publish. - # Floor on the highest published tag too (e.g. Chart.yaml 0.2.0-rc.5, - # tag 0.2.0-rc.10 → bump from 0.2.0-rc.10). + # Floor on the highest published tag on this line only: + # - RC Chart.yaml X.Y.Z-rc.* → max X.Y.Z-rc.* (same RC cycle) + # - stable Chart.yaml X.Y.* → max X.Y.* stable (not a newer major/RC) + # Stale hotfix cuts still avoid tag reuse without jumping lines. git fetch --tags --force origin - HIGHEST_TAG=$(git tag -l 'charts/trueforge@*' | sed 's|^charts/trueforge@||' | sort -V | tail -1 || true) - BASELINE=$(printf '%s\n%s\n' "$CURRENT" "$HIGHEST_TAG" | sort -V | tail -1) + HIGHEST_TAG=$( + git tag -l 'charts/trueforge@*' | + sed 's|^charts/trueforge@||' | + bash scripts/highest-chart-tag-on-line.sh "$CURRENT" || true + ) + BASELINE=$CURRENT + if [[ -n "$HIGHEST_TAG" ]]; then + BASELINE=$(printf '%s\n%s\n' "$CURRENT" "$HIGHEST_TAG" | sort -V | tail -1) + fi VERSION=$(bash scripts/resolve-chart-version.sh "$BASELINE" "$APP_VERSION" "$PR_VERSION") if [[ "$BASELINE" != "$CURRENT" ]]; then - echo "Using published tag baseline $BASELINE (Chart.yaml was $CURRENT)" + echo "Using line tag baseline $BASELINE (Chart.yaml was $CURRENT)" fi if [[ -n "$PR_VERSION" && "$VERSION" == "$PR_VERSION" ]]; then diff --git a/RELEASING.md b/RELEASING.md index 3ce1ad708..60f81bc66 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -163,8 +163,9 @@ git push -u origin release-vX.Y.Z Then cherry-pick the fix + changeset onto that branch, merge the Version Packages PR that targets `release-vX.Y.Z`, and let **Release** publish npm/PyPI and auto -chart OCI. Pass the resulting chart SemVer to helm-charts -`release-start` as `trueforge_chart_version` (control-plane pin). +chart OCI (chart SemVer stays on that line: same `X.Y.Z-rc.*` or stable `X.Y.*`). +Pass the resulting chart SemVer to helm-charts `release-start` as +`trueforge_chart_version` (control-plane pin). Org rules still require human approval on Version Packages PRs into `release-v*`. Chart auto-merge needs the limited `trueforge-dev-bot` ruleset bypass @@ -197,10 +198,12 @@ even when `main` has moved on. Always: build/push `{appVersion}-{shortSha}`, patch-bump chart `version`, set `image.tag`, open/update one PR on `release-chart/trueforge` (base `main`) or `release-chart/trueforge-` (hotfix base). Chart version baseline is -`max(Chart.yaml, highest charts/trueforge@* tag)` so hotfix cuts cannot reuse a -published tag. Image builds may run per-ref in parallel; chart version assign + -PR open/merge is globally serialized, and auto-merge waits until the -`charts/trueforge@*` tag exists before the next run starts. +`max(Chart.yaml, highest tag on the same line)`: same `X.Y.Z-rc.*` cycle stays +monotonic; a stable `X.Y.*` hotfix ignores newer majors/RC lines (e.g. `0.2.0` +→ `0.2.1` while `main` is on `0.3.0-rc.*`). Image builds may run per-ref in +parallel; chart version assign + PR open/merge is globally serialized, and +auto-merge waits until the `charts/trueforge@*` tag exists before the next run +starts. ```bash gh workflow run build-and-prepare-chart-release.yml diff --git a/package.json b/package.json index 75d5cac3d..b498cadb1 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "start:controller": "cross-env STANDALONE=false NODE_ENV=production pnpm --filter @truefoundry/trueforge start:controller", "push-sandbox-image-to-daytona": "pnpm --filter @truefoundry/trueforge push-sandbox-image-to-daytona", "test:frontend": "pnpm --filter frontend test", - "test:chart-version": "bash tests/scripts/resolve-chart-version.test.sh", + "test:chart-version": "bash tests/scripts/resolve-chart-version.test.sh && bash tests/scripts/highest-chart-tag-on-line.test.sh", "test:trueforge-core": "pnpm --filter @truefoundry/trueforge-core test", "test:trueforge": "pnpm --filter @truefoundry/trueforge test", "test:local-sandbox:contract": "pnpm --filter @truefoundry/trueforge test:local-sandbox:contract", diff --git a/scripts/highest-chart-tag-on-line.sh b/scripts/highest-chart-tag-on-line.sh new file mode 100755 index 000000000..e8c865420 --- /dev/null +++ b/scripts/highest-chart-tag-on-line.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Highest charts/trueforge SemVer on the same line as CURRENT (stdin: one version per line). +# +# Line rules +# CURRENT X.Y.Z-rc.N → tags matching X.Y.Z-rc.* only +# CURRENT X.Y.Z → tags matching X.Y.* with no prerelease +# +# Examples (CURRENT → highest matching tag) +# 0.2.0-rc.3 + 0.2.0-rc.10,0.3.0-rc.1,0.2.0 → 0.2.0-rc.10 +# 0.2.0 + 0.2.1,0.3.0-rc.5,0.2.0-rc.14 → 0.2.1 +# +# Workflow then uses max(Chart.yaml, this output) before resolve-chart-version.sh. +set -euo pipefail + +CURRENT=${1:?current chart version is required} +SEMVER='^([0-9]+)\.([0-9]+)\.([0-9]+)(-rc\.[0-9]+)?$' + +if [[ ! "$CURRENT" =~ $SEMVER ]]; then + echo "Current chart version '$CURRENT' is not supported semver" >&2 + exit 1 +fi + +MAJOR=${BASH_REMATCH[1]} +MINOR=${BASH_REMATCH[2]} +PATCH=${BASH_REMATCH[3]} +PRERELEASE=${BASH_REMATCH[4]:-} + +if [[ -n "$PRERELEASE" ]]; then + FILTER="^${MAJOR}\\.${MINOR}\\.${PATCH}-rc\\.[0-9]+$" +else + FILTER="^${MAJOR}\\.${MINOR}\\.[0-9]+$" +fi + +HIGHEST="" +while IFS= read -r tag || [[ -n "$tag" ]]; do + [[ -z "$tag" || ! "$tag" =~ $FILTER ]] && continue + if [[ -z "$HIGHEST" ]] || + [[ "$(printf '%s\n%s\n' "$HIGHEST" "$tag" | sort -V | tail -1)" == "$tag" ]]; then + HIGHEST=$tag + fi +done + +if [[ -n "$HIGHEST" ]]; then + printf '%s\n' "$HIGHEST" +fi diff --git a/tests/scripts/highest-chart-tag-on-line.test.sh b/tests/scripts/highest-chart-tag-on-line.test.sh new file mode 100755 index 000000000..138656793 --- /dev/null +++ b/tests/scripts/highest-chart-tag-on-line.test.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/../.." + +picker=scripts/highest-chart-tag-on-line.sh +failures=0 + +assert_highest() { + local expected=$1 + local current=$2 + shift 2 + local actual + if (($# > 0)); then + actual=$(printf '%s\n' "$@" | bash "$picker" "$current") + else + actual=$(bash "$picker" "$current" }" "${*:-none}" >&2 + failures=$((failures + 1)) + fi +} + +assert_rejects() { + local current=$1 + if printf '' | bash "$picker" "$current" >/dev/null 2>&1; then + printf 'FAIL: expected reject for current=%s\n' "$current" >&2 + failures=$((failures + 1)) + fi +} + +# --- RC line: same X.Y.Z-rc.* --- + +# Lagging Chart.yaml; floor on highest RC of that core. +assert_highest 0.2.0-rc.10 0.2.0-rc.3 \ + 0.2.0-rc.5 0.2.0-rc.10 0.3.0-rc.1 0.2.0 0.2.1 + +# Chart.yaml already at the highest published RC on the line. +assert_highest 0.2.0-rc.10 0.2.0-rc.10 \ + 0.2.0-rc.5 0.2.0-rc.10 + +# Only lower RCs published; return that max (workflow maxes with Chart.yaml). +assert_highest 0.2.0-rc.2 0.2.0-rc.9 \ + 0.2.0-rc.1 0.2.0-rc.2 + +# sort -V: rc.9 < rc.10 even if listed first. +assert_highest 0.2.0-rc.10 0.2.0-rc.1 \ + 0.2.0-rc.10 0.2.0-rc.9 0.2.0-rc.2 + +# Duplicate tags are fine. +assert_highest 0.2.0-rc.4 0.2.0-rc.1 \ + 0.2.0-rc.4 0.2.0-rc.4 0.2.0-rc.3 + +# Blank stdin lines ignored. +assert_highest 0.2.0-rc.3 0.2.0-rc.1 \ + '' 0.2.0-rc.3 '' + +# Other patch-core RCs (0.2.1-rc.*) are a different line. +assert_highest 0.2.0-rc.4 0.2.0-rc.1 \ + 0.2.1-rc.9 0.2.0-rc.4 + +# Other minor/major RCs ignored. +assert_highest 0.2.0-rc.1 0.2.0-rc.1 \ + 0.2.0-rc.1 0.3.0-rc.99 0.1.0-rc.50 + +# Stable tags on the same X.Y do not join an RC line. +assert_highest "" 0.2.0-rc.1 \ + 0.2.0 0.2.1 0.1.9-rc.0 0.3.0-rc.1 + +# No tags at all. +assert_highest "" 0.2.0-rc.3 + +# Single matching tag equals CURRENT core. +assert_highest 0.2.0-rc.0 0.2.0-rc.0 \ + 0.2.0-rc.0 + +# --- Stable line: X.Y.* without prerelease --- + +# Patch floor within X.Y; ignore newer major/RC and older minor. +assert_highest 0.2.1 0.2.0 \ + 0.2.0 0.2.1 0.3.0-rc.5 0.2.0-rc.14 0.1.9 + +# Higher patch already shipped. +assert_highest 0.2.5 0.2.0 \ + 0.2.0 0.2.3 0.2.5 + +# CURRENT ahead of tags; return tag max only. +assert_highest 0.2.1 0.2.9 \ + 0.2.0 0.2.1 + +# Newer major stable ignored. +assert_highest "" 0.2.0 \ + 0.3.0 0.3.1 + +# Newer major RC ignored. +assert_highest "" 0.2.0 \ + 0.3.0-rc.1 0.3.0-rc.5 + +# Same-minor RCs ignored (post-stable leftover tags). +assert_highest "" 0.2.0 \ + 0.2.0-rc.14 0.2.1-rc.0 + +# Older minor stable ignored. +assert_highest 0.2.0 0.2.0 \ + 0.1.9 0.2.0 + +# Empty tag list. +assert_highest "" 0.2.0 + +# sort -V across multi-digit patches. +assert_highest 0.2.10 0.2.0 \ + 0.2.9 0.2.10 0.2.2 + +# Mixed noise; only stable X.Y survive. +assert_highest 0.2.2 0.2.1 \ + 0.2.0-rc.1 0.2.2 0.3.0 0.2.1-rc.3 0.1.99 0.2.1 + +# --- Reject unsupported CURRENT --- + +assert_rejects 'not-a-version' +assert_rejects '0.2.0-beta.1' +assert_rejects '0.2' + +if ((failures > 0)); then + exit 1 +fi + +echo "Highest chart tag on line tests passed" From f9cf0a1d7fcd4220769938cfdd95d5b246c95a3c Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Fri, 18 Sep 2026 18:27:56 +0530 Subject: [PATCH 6/6] fix: Queued chart jobs can be cancelled --- .github/workflows/build-and-prepare-chart-release.yml | 5 ++++- RELEASING.md | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-and-prepare-chart-release.yml b/.github/workflows/build-and-prepare-chart-release.yml index 82de81ac9..95e82c36a 100644 --- a/.github/workflows/build-and-prepare-chart-release.yml +++ b/.github/workflows/build-and-prepare-chart-release.yml @@ -152,10 +152,13 @@ jobs: needs: [resolve, build] runs-on: ubuntu-latest # Chart SemVer lines can advance in parallel in theory, but tag publish and - # PR merge still race on shared git state; serialize this job. + # PR merge still race on shared git state; serialize this job. Default + # queue is a single pending slot — a third ref would cancel the waiter — + # so queue: max keeps main + multiple release-v* runs lined up. concurrency: group: release-chart-version-and-pr cancel-in-progress: false + queue: max permissions: contents: write pull-requests: write diff --git a/RELEASING.md b/RELEASING.md index 60f81bc66..8e6773517 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -201,9 +201,9 @@ open/update one PR on `release-chart/trueforge` (base `main`) or `max(Chart.yaml, highest tag on the same line)`: same `X.Y.Z-rc.*` cycle stays monotonic; a stable `X.Y.*` hotfix ignores newer majors/RC lines (e.g. `0.2.0` → `0.2.1` while `main` is on `0.3.0-rc.*`). Image builds may run per-ref in -parallel; chart version assign + PR open/merge is globally serialized, and -auto-merge waits until the `charts/trueforge@*` tag exists before the next run -starts. +parallel; chart version assign + PR open/merge is globally serialized with a +multi-run pending queue (`queue: max`), and auto-merge waits until the +`charts/trueforge@*` tag exists before the next run starts. ```bash gh workflow run build-and-prepare-chart-release.yml