From 1f163926be1676ffb601406444748cd5617ecc5b Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 10 Jun 2026 15:16:15 -0600 Subject: [PATCH 1/2] ci: add staging RC releases and automated back/forward-merge PRs Staging now cuts vX.Y.Z-rc.N prereleases via release-please with its own config/manifest pair (simple release-type, separate changelog, pyproject untouched to stay PEP 440 valid). Stable releases on production trigger an automatic production->staging back-merge PR that syncs the staging manifest; hotfix releases trigger an automatic hotfix->production forward-merge PR. Flow documented in docs/release-flow.md. Co-Authored-By: Claude Fable 5 --- .github/workflows/forward-merge.yml | 141 ++++++++++++++++++++++++++ .github/workflows/hotfix-start.yml | 9 +- .github/workflows/release-please.yml | 30 ++++-- .release-please-manifest.staging.json | 3 + docs/release-flow.md | 107 +++++++++++++++++++ release-please-config.json | 4 +- release-please-config.staging.json | 31 ++++++ 7 files changed, 312 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/forward-merge.yml create mode 100644 .release-please-manifest.staging.json create mode 100644 docs/release-flow.md create mode 100644 release-please-config.staging.json diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml new file mode 100644 index 00000000..66b0f28e --- /dev/null +++ b/.github/workflows/forward-merge.yml @@ -0,0 +1,141 @@ +name: forward-merge + +# Keeps the release branches converged automatically: +# - source_branch = production -> open a back-merge PR production -> staging, +# syncing .release-please-manifest.staging.json to the released stable +# version so the next staging RC computes from the new baseline. +# - source_branch = hotfix/vX.Y.Z -> open a forward-merge PR into production. +# (After that PR merges, run this workflow manually with +# source_branch=production to propagate the hotfix on to staging — no +# release is cut on production by the hotfix merge, so the automatic +# trigger doesn't fire.) +# +# Invoked via workflow_call from release-please.yml after a release is cut, or +# manually via workflow_dispatch. +# +# NOTE: PRs created with the default GITHUB_TOKEN do not trigger +# `pull_request` workflows (tests will not run on the PR). Set the +# FORWARD_MERGE_TOKEN secret (fine-grained PAT or GitHub App token with +# contents:write + pull-requests:write) to get CI on these PRs; without it the +# workflow falls back to GITHUB_TOKEN and you can close/reopen the PR to kick +# CI. + +on: + workflow_call: + inputs: + tag_name: + description: Release tag that was just cut (e.g. v1.2.0) + required: true + type: string + source_branch: + description: Branch the release was cut on (production or hotfix/vX.Y.Z) + required: true + type: string + workflow_dispatch: + inputs: + tag_name: + description: Release tag driving the merge (e.g. v1.2.0) + required: true + type: string + source_branch: + description: Branch to merge from (production or hotfix/vX.Y.Z) + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + back-merge-to-staging: + if: ${{ inputs.source_branch == 'production' }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.FORWARD_MERGE_TOKEN || github.token }} + TAG: ${{ inputs.tag_name }} + steps: + - uses: actions/checkout@v6.0.3 + with: + fetch-depth: 0 + token: ${{ secrets.FORWARD_MERGE_TOKEN || github.token }} + + - name: Set up git user + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Create merge branch off staging and merge production + id: merge + run: | + VERSION="${TAG#v}" + BRANCH="merge/production-into-staging-${VERSION}" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + git checkout -b "$BRANCH" origin/staging + if ! git merge --no-ff "origin/production" -m "merge: production ${TAG} into staging"; then + { + echo "### Back-merge conflict" + echo "" + echo "Merging \`production\` (${TAG}) into \`staging\` conflicts." + echo "Resolve manually:" + echo '```' + echo "git checkout -b $BRANCH origin/staging" + echo "git merge origin/production # resolve conflicts" + echo "git push origin $BRANCH" + echo "gh pr create --base staging --head $BRANCH" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + - name: Sync staging release-please manifest to released version + run: | + VERSION="${TAG#v}" + jq --arg v "$VERSION" '."." = $v' .release-please-manifest.staging.json > manifest.tmp + mv manifest.tmp .release-please-manifest.staging.json + if ! git diff --quiet -- .release-please-manifest.staging.json; then + git add .release-please-manifest.staging.json + git commit -m "chore: sync staging release-please manifest to ${TAG}" + fi + + - name: Push branch and open PR + run: | + BRANCH="${{ steps.merge.outputs.branch }}" + git push origin "$BRANCH" + { + echo "Automated back-merge after release \`${TAG}\`." + echo "" + echo "- Brings the release commit and tag history into \`staging\`." + echo "- Syncs \`.release-please-manifest.staging.json\` to \`${TAG#v}\` so the next staging RC versions from the new stable baseline." + echo "" + echo "If version files conflict with in-flight staging work, accept either side — release-please rewrites them on the next Release PR." + } > pr-body.md + gh pr create \ + --base staging \ + --head "$BRANCH" \ + --title "merge: production ${TAG} into staging" \ + --body-file pr-body.md + + forward-merge-to-production: + if: ${{ startsWith(inputs.source_branch, 'hotfix/') }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.FORWARD_MERGE_TOKEN || github.token }} + TAG: ${{ inputs.tag_name }} + SOURCE: ${{ inputs.source_branch }} + steps: + - uses: actions/checkout@v6.0.3 + + - name: Open PR hotfix -> production + run: | + { + echo "Automated forward-merge of hotfix release \`${TAG}\`." + echo "" + echo "- Brings the hotfix commits and release bookkeeping into \`production\`." + echo "- No new release is cut by this merge (the \`${TAG}\` release commit is already included)." + echo "- After merging, run the \`forward-merge\` workflow manually with \`source_branch=production\` and \`tag_name=${TAG}\` to propagate the hotfix on to \`staging\`." + } > pr-body.md + gh pr create \ + --base production \ + --head "$SOURCE" \ + --title "merge: ${SOURCE} (${TAG}) into production" \ + --body-file pr-body.md diff --git a/.github/workflows/hotfix-start.yml b/.github/workflows/hotfix-start.yml index 095bcd74..7bb5ddfc 100644 --- a/.github/workflows/hotfix-start.yml +++ b/.github/workflows/hotfix-start.yml @@ -5,8 +5,9 @@ name: hotfix-start # 1. Run this workflow (optionally pin base_tag; default = latest v*.*.*). # 2. Push fix commit(s) to the new hotfix/vX.Y.(Z+1) branch via PR. # 3. release-please opens a Release PR on the hotfix branch. -# 4. Merge it -> tag vX.Y.(Z+1) -> CD (Production) deploys. -# 5. Open a forward-merge PR from hotfix/vX.Y.(Z+1) back into production. +# 4. Merge it -> tag vX.Y.(Z+1) -> CD (Production) deploys, and a +# forward-merge PR into production is opened automatically +# (forward-merge.yml). on: workflow_dispatch: @@ -85,6 +86,6 @@ jobs: echo "Next steps:" echo "1. Open a fix PR targeting \`${{ steps.next.outputs.branch }}\` (Conventional Commit title, \`fix:\` prefix)." echo "2. After merge, release-please will open a Release PR on the hotfix branch." - echo "3. Merge the Release PR -> tag \`v${{ steps.next.outputs.version }}\` -> CD (Production) deploys." - echo "4. Open a forward-merge PR \`${{ steps.next.outputs.branch }}\` -> \`production\`." + echo "3. Merge the Release PR -> tag \`v${{ steps.next.outputs.version }}\` -> CD (Production) deploys and a forward-merge PR \`${{ steps.next.outputs.branch }}\` -> \`production\` opens automatically." + echo "4. After merging that PR, run the \`forward-merge\` workflow (source_branch=production) to propagate the hotfix to \`staging\`." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index f6cd5bb7..9269a3b1 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -4,6 +4,7 @@ on: push: branches: - production + - staging - 'hotfix/v*' permissions: @@ -17,23 +18,40 @@ jobs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} steps: + # staging uses its own config/manifest pair: prerelease (rc) versioning, + # separate changelog, and its own version state so the rc line never + # collides with the stable line tracked in .release-please-manifest.json. - id: release uses: googleapis/release-please-action@v5 with: - config-file: release-please-config.json - manifest-file: .release-please-manifest.json + config-file: ${{ github.ref_name == 'staging' && 'release-please-config.staging.json' || 'release-please-config.json' }} + manifest-file: ${{ github.ref_name == 'staging' && '.release-please-manifest.staging.json' || '.release-please-manifest.json' }} target-branch: ${{ github.ref_name }} - # When release-please actually cuts a release, deploy it. The release is - # created with GITHUB_TOKEN, whose events don't trigger other workflows, so we - # invoke the production deploy inline (same run) instead of relying on the + # When release-please cuts a stable or hotfix release, deploy it. RC releases + # on staging never deploy production. The release is created with + # GITHUB_TOKEN, whose events don't trigger other workflows, so we invoke the + # production deploy inline (same run) instead of relying on the # `release: published` event reaching CD_production. deploy-production: needs: release-please - if: ${{ needs.release-please.outputs.release_created == 'true' }} + if: ${{ needs.release-please.outputs.release_created == 'true' && github.ref_name != 'staging' }} permissions: contents: read uses: ./.github/workflows/CD_production.yml with: tag_name: ${{ needs.release-please.outputs.tag_name }} secrets: inherit + + # Keep branches converged after a release: + # - stable release on production -> open PR production -> staging + # (carries the release commit/tag history and syncs the staging manifest) + # - hotfix release on hotfix/v* -> open PR hotfix/vX.Y.Z -> production + forward-merge: + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' && github.ref_name != 'staging' }} + uses: ./.github/workflows/forward-merge.yml + with: + tag_name: ${{ needs.release-please.outputs.tag_name }} + source_branch: ${{ github.ref_name }} + secrets: inherit diff --git a/.release-please-manifest.staging.json b/.release-please-manifest.staging.json new file mode 100644 index 00000000..5fdd8830 --- /dev/null +++ b/.release-please-manifest.staging.json @@ -0,0 +1,3 @@ +{ + ".": "1.1.0" +} diff --git a/docs/release-flow.md b/docs/release-flow.md new file mode 100644 index 00000000..3e08e569 --- /dev/null +++ b/docs/release-flow.md @@ -0,0 +1,107 @@ +# Release Flow + +How code moves from a feature branch to production, how versions are cut, and +how hotfixes work. The mechanics live in `.github/workflows/`; this doc is the +map. + +## Branch roles + +| Branch | Role | Deploys to | Versioning | +|---|---|---|---| +| `jir*` | feature / ticket branches | `ocotillo-api-testing` (every push) | none | +| `staging` | integration branch (default) | `ocotillo-api-staging` (every push) | `vX.Y.Z-rc.N` prereleases via release-please | +| `production` | release branch | `ocotillo-api` (on release tag) | `vX.Y.Z` stable releases via release-please | +| `hotfix/vX.Y.Z` | emergency patch off a release tag | `ocotillo-api` (on release tag) | `vX.Y.Z` patch release via release-please | + +## The flow + +``` +feature (jir*) ──PR──▶ staging ──promotion PR──▶ production ──release PR──▶ tag vX.Y.Z ──▶ prod deploy + │ │ ▲ │ + ▼ ▼ │ auto forward-merge PR ▼ auto back-merge PR +testing svc staging svc deploy hotfix/vX.Y.(Z+1) production → staging + + RC release PR (off tag, via (syncs staging manifest) + → tag vX.Y.Z-rc.N hotfix-start.yml) +``` + +### 1. Feature → staging (RC line) + +1. Branch `jir*` off `staging`; every push deploys the testing service + (`CD_testing.yml`). +2. Merge the PR into `staging` (Conventional Commit title — `feat:`, `fix:`, + etc.; enforced by `pr-title-lint.yml`). +3. Every push to `staging` deploys the staging service (`CD_staging.yml`) — + continuous, unversioned, date-stamped tag. +4. release-please (staging config) maintains an **RC Release PR** + (`chore(staging): release X.Y.Z-rc.N`). Merging it tags `vX.Y.Z-rc.N` and + publishes a GitHub **prerelease**. This is a versioned checkpoint of what's + on staging — it never deploys production. +5. Successive merges after an RC bump only the `rc.N` counter + (`versioning: prerelease`), so the target version is stable until promoted. + +Cut an RC (merge the RC Release PR) when staging is in a state you intend to +promote — the RC tag is the thing you tested. + +### 2. Staging → production (stable release) + +1. Open a **promotion PR** `staging → production` (manual; this is the + "we want to ship what's on staging" decision). +2. Merging it makes release-please (production config) open a **Release PR** + (`chore(production): release X.Y.Z`). +3. Merging the Release PR tags `vX.Y.Z`, publishes the GitHub release, and the + same workflow run invokes `CD_production.yml` via `workflow_call` + (releases created with `GITHUB_TOKEN` don't emit events that trigger other + workflows, hence the inline call). +4. `forward-merge.yml` then opens an automatic **back-merge PR + `production → staging`**, which also syncs + `.release-please-manifest.staging.json` to the released version so the next + RC computes from the new stable baseline. Merge it promptly. + +### 3. Hotfix + +1. Run the `hotfix-start` workflow (optionally pinning `base_tag`). It creates + `hotfix/vX.Y.(Z+1)` off the release tag. +2. Open a fix PR targeting the hotfix branch (`fix:` title). +3. release-please (production config, hotfix branch) opens a Release PR; + merging it tags `vX.Y.(Z+1)` and deploys production. +4. `forward-merge.yml` automatically opens **`hotfix/vX.Y.(Z+1)` → + `production`**. Merge it. No new release is cut (the release commit is + already in the branch). +5. Propagate to staging: run the `forward-merge` workflow manually with + `source_branch=production` and the hotfix tag (the hotfix merge doesn't cut + a release on production, so the automatic trigger doesn't fire). + +## Version-file ownership + +| File | Written by | Lives meaningfully on | +|---|---|---| +| `.release-please-manifest.json` | release-please on `production` / `hotfix/v*` | production | +| `.release-please-manifest.staging.json` | release-please on `staging`; synced by back-merge PRs | staging | +| `pyproject.toml` version | release-please stable releases only (python release-type) | production | +| `CHANGELOG.md` | stable releases | production | +| `CHANGELOG-rc.md` | RC releases | staging | +| `version.txt` | RC releases (simple release-type bookkeeping) | staging | + +RC releases deliberately do **not** touch `pyproject.toml`: `1.2.0-rc.1` is +not a valid PEP 440 version and would break `uv export` during staging +deploys, and skipping it avoids promotion-merge conflicts. + +**Conflict rule:** if any of these files conflict during a merge, accept +either side and move on — release-please rewrites them on the next Release PR. +The manifests are the only state that matters, and the back-merge PR syncs the +staging one explicitly. + +## Caveats + +- **CI on automated PRs:** PRs created with the default `GITHUB_TOKEN` do not + trigger `pull_request` workflows. Set the `FORWARD_MERGE_TOKEN` repo secret + (fine-grained PAT or GitHub App token, `contents: write` + + `pull-requests: write`) so back-merge/forward-merge PRs get CI. Without it, + close and reopen the PR to kick CI. +- **Workflow changes go live per branch:** release-please and the deploy + workflows resolve at the pushed branch's commit. A workflow fix merged to + `staging` does nothing for production releases until it reaches + `production`. +- **Tag visibility:** the staging release-please needs the last stable tag's + commit in `staging` history to bound its commit scan — another reason to + merge back-merge PRs promptly after each release. diff --git a/release-please-config.json b/release-please-config.json index 4011c4f8..2b810a65 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -21,9 +21,7 @@ ], "packages": { ".": { - "package-name": "OcotilloAPI", - "prerelease": true, - "prerelease-type": "rc" + "package-name": "OcotilloAPI" } } } diff --git a/release-please-config.staging.json b/release-please-config.staging.json new file mode 100644 index 00000000..8236f48e --- /dev/null +++ b/release-please-config.staging.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-v-in-tag": true, + "include-component-in-tag": false, + "bump-minor-pre-major": false, + "bump-patch-for-minor-pre-major": false, + "versioning": "prerelease", + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "deps", "section": "Dependencies" }, + { "type": "revert", "section": "Reverts" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "chore", "section": "Chores", "hidden": true }, + { "type": "refactor", "section": "Refactors", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build", "hidden": true }, + { "type": "ci", "section": "CI", "hidden": true }, + { "type": "style", "section": "Style", "hidden": true } + ], + "packages": { + ".": { + "package-name": "OcotilloAPI", + "prerelease": true, + "prerelease-type": "rc", + "changelog-path": "CHANGELOG-rc.md" + } + } +} From b7e1cf3321a9190ca8203164b59b1aedac2572b3 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 10 Jun 2026 15:33:47 -0600 Subject: [PATCH 2/2] ci: make forward-merge PRs retry-safe and title-lint compliant Skip cleanly when a back/forward-merge PR is already open, push the merge branch with --force-with-lease so reruns survive a leftover branch, and use the chore: prefix so pr-title-lint passes on the automated PRs. Co-Authored-By: Claude Fable 5 --- .github/workflows/forward-merge.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml index 66b0f28e..9567229a 100644 --- a/.github/workflows/forward-merge.yml +++ b/.github/workflows/forward-merge.yml @@ -81,7 +81,7 @@ jobs: echo "git checkout -b $BRANCH origin/staging" echo "git merge origin/production # resolve conflicts" echo "git push origin $BRANCH" - echo "gh pr create --base staging --head $BRANCH" + echo "gh pr create --base staging --head $BRANCH --title 'chore: merge production ${TAG} into staging'" echo '```' } >> "$GITHUB_STEP_SUMMARY" exit 1 @@ -97,10 +97,17 @@ jobs: git commit -m "chore: sync staging release-please manifest to ${TAG}" fi + # Retry-safe: skip if a PR is already open for this merge; force-with-lease + # handles a branch left behind by a previous partial run. - name: Push branch and open PR run: | BRANCH="${{ steps.merge.outputs.branch }}" - git push origin "$BRANCH" + EXISTING="$(gh pr list --base staging --head "$BRANCH" --state open --json number --jq '.[0].number // empty')" + if [ -n "$EXISTING" ]; then + echo "Back-merge PR #$EXISTING already open for $BRANCH; nothing to do." + exit 0 + fi + git push --force-with-lease origin "$BRANCH" { echo "Automated back-merge after release \`${TAG}\`." echo "" @@ -112,7 +119,7 @@ jobs: gh pr create \ --base staging \ --head "$BRANCH" \ - --title "merge: production ${TAG} into staging" \ + --title "chore: merge production ${TAG} into staging" \ --body-file pr-body.md forward-merge-to-production: @@ -125,8 +132,14 @@ jobs: steps: - uses: actions/checkout@v6.0.3 + # Retry-safe: skip if a PR is already open from this hotfix branch. - name: Open PR hotfix -> production run: | + EXISTING="$(gh pr list --base production --head "$SOURCE" --state open --json number --jq '.[0].number // empty')" + if [ -n "$EXISTING" ]; then + echo "Forward-merge PR #$EXISTING already open for $SOURCE; nothing to do." + exit 0 + fi { echo "Automated forward-merge of hotfix release \`${TAG}\`." echo "" @@ -137,5 +150,5 @@ jobs: gh pr create \ --base production \ --head "$SOURCE" \ - --title "merge: ${SOURCE} (${TAG}) into production" \ + --title "chore: merge ${SOURCE} (${TAG}) into production" \ --body-file pr-body.md