Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions .github/workflows/forward-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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 --title 'chore: merge production ${TAG} into staging'"
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

# 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 }}"
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 ""
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 "chore: 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

# 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 ""
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 "chore: merge ${SOURCE} (${TAG}) into production" \
--body-file pr-body.md
Comment thread
jirhiker marked this conversation as resolved.
9 changes: 5 additions & 4 deletions .github/workflows/hotfix-start.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
30 changes: 24 additions & 6 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- production
- staging
- 'hotfix/v*'

permissions:
Expand All @@ -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
3 changes: 3 additions & 0 deletions .release-please-manifest.staging.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "1.1.0"
}
107 changes: 107 additions & 0 deletions docs/release-flow.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 1 addition & 3 deletions release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@
],
"packages": {
".": {
"package-name": "OcotilloAPI",
"prerelease": true,
"prerelease-type": "rc"
"package-name": "OcotilloAPI"
}
}
}
31 changes: 31 additions & 0 deletions release-please-config.staging.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Loading