Skip to content

Commit cf41d3f

Browse files
committed
chore: update release strategy
1 parent 346a9f6 commit cf41d3f

10 files changed

Lines changed: 510 additions & 86 deletions

File tree

.github/CLAUDE.md

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This file provides comprehensive guidance for Claude Code and human engineers wo
66

77
The Aignostics Python SDK uses a **sophisticated multi-stage CI/CD pipeline** built on GitHub Actions with:
88

9-
* **19 workflow files** (8 entry points + 11 reusable workflows)
9+
* **Multiple workflow files**, including both entry-point and reusable workflows
1010
* **Reusable workflow architecture** for modularity and maintainability
1111
* **Environment-based testing** (staging/production with scheduled validation)
1212
* **Multi-category test execution** (unit, integration, e2e, long_running, very_long_running, scheduled)
@@ -21,7 +21,7 @@ The Aignostics Python SDK uses a **sophisticated multi-stage CI/CD pipeline** bu
2121
```text
2222
┌─────────────────────────────────────────────────────────────────────┐
2323
│ ci-cd.yml (Main Orchestrator) │
24-
Triggered on: push to main, PR, release, tag v*.*.*
24+
│ Triggered on: push to main/release/v*, PR, release, tag v*.*.* │
2525
├─────────────────────────────────────────────────────────────────────┤
2626
│ │
2727
│ ┌────────┐ ┌───────┐ ┌────────────────┐ ┌────────┐ │
@@ -56,6 +56,9 @@ The Aignostics Python SDK uses a **sophisticated multi-stage CI/CD pipeline** bu
5656
┌───────────────────────────────────────────────────────────────┐
5757
│ Parallel Entry Points │
5858
├───────────────────────────────────────────────────────────────┤
59+
│ prepare-release.yml → Create release branch │
60+
│ publish-release.yml → Tag + changelog → CI/CD publish │
61+
│ merge-release.yml → Merge branch into main │
5962
│ build-native-only.yml → Native executables (6 platforms) │
6063
│ claude-code-*.yml → PR reviews + interactive sessions │
6164
│ test-scheduled-*.yml → Staging (6h) + Production (24h) │
@@ -70,7 +73,10 @@ The Aignostics Python SDK uses a **sophisticated multi-stage CI/CD pipeline** bu
7073

7174
| Workflow | Triggers | Purpose | Calls |
7275
|----------|----------|---------|-------|
73-
| **ci-cd.yml** | push(main), PR, release, tag | Main CI/CD pipeline | _lint,_audit, _test,_codeql, _ketryx,_package-publish, _docker-publish |
76+
| **ci-cd.yml** | push(main, release/v*), PR, release, tag | Main CI/CD pipeline | _lint,_audit, _test,_codeql, _ketryx,_package-publish, _docker-publish |
77+
| **prepare-release.yml** | workflow_dispatch | Create release branch + bump version ||
78+
| **publish-release.yml** | workflow_dispatch | Generate changelog, tag, push → CI/CD ||
79+
| **merge-release.yml** | workflow_dispatch | Merge release branch into main ||
7480
| **build-native-only.yml** | push, PR, release (if msg contains `build:native:only`) | Native executable builds | _build-native-only |
7581
| **claude-code-interactive.yml** | workflow_dispatch (manual) | Manual Claude sessions | _claude-code (interactive) |
7682
| **claude-code-automation-pr-review.yml** | PR opened/sync (excludes bots) | Automated PR reviews | _claude-code (automation) |
@@ -379,7 +385,6 @@ uv run pytest -m "(scheduled or scheduled_only)" -v
379385
* `build:native:only` - Only build native executables
380386
* `skip:test:long_running` - Skip long-running tests
381387
* `enable:test:very_long_running` - Enable very long running tests
382-
* `Bump version:` - Skip CI (version bump commits)
383388

384389
**Usage**:
385390

@@ -398,6 +403,7 @@ git commit -m "fix: issue skip:test:long_running"
398403
**Triggers**:
399404

400405
* `push` to `main` branch
406+
* `push` to `release/v*` branches (release branch CI)
401407
* `pull_request` to `main` (opened, synchronize, reopened)
402408
* `release` created
403409
* `tags` matching `v*.*.*`
@@ -415,7 +421,6 @@ Cancels in-progress runs when new commits are pushed to same PR/branch.
415421
416422
* Commit message contains `skip:ci`
417423
* Commit message contains `build:native:only`
418-
* Commit starts with `Bump version:`
419424
* PR has label `skip:ci` or `build:native:only`
420425

421426
**Job Dependencies**:
@@ -1006,26 +1011,39 @@ make dist_native
10061011

10071012
### Releasing a Version
10081013

1009-
1. Ensure `main` branch is clean and all tests pass
1010-
2. Run version bump:
1014+
Releases use a four-phase workflow triggered from the developer's machine via `gh workflow run`. This lets Ketryx compliance approvals be collected *before* the tag (and thus before publishing to PyPI).
10111015

1012-
```bash
1013-
make bump patch # or minor, major
1014-
```
1016+
**Phase 1 — Prepare the release branch** (triggers `prepare-release.yml`):
10151017

1016-
3. This creates a commit and git tag
1017-
4. Push with tags:
1018+
```bash
1019+
make prepare-release 1.2.3 # explicit version
1020+
```
1021+
1022+
Creates `release/vX.Y.Z` from `main`, commits version bump + `uv.lock`, pushes. CI runs on the branch automatically.
1023+
1024+
**Phase 2 — Collect Ketryx approvals:**
1025+
1026+
Point the Ketryx release to `release/vX.Y.Z` and collect approvals. Ensure CI is green.
1027+
1028+
**Phase 3 — Publish** (triggers `publish-release.yml`):
1029+
1030+
```bash
1031+
make publish-release # auto-detects release/v* branch
1032+
make publish-release release/v1.2.3 # explicit branch
1033+
```
1034+
1035+
Generates `CHANGELOG.md`, creates annotated `vX.Y.Z` tag, pushes → CI/CD fires on tag → Ketryx check must pass before PyPI publish.
1036+
1037+
**Phase 4 — Merge back to main** (triggers `merge-release.yml`):
1038+
1039+
```bash
1040+
make merge-release # auto-detects release/v* branch
1041+
make merge-release release/v1.2.3 # explicit branch
1042+
```
10181043

1019-
```bash
1020-
git push --follow-tags
1021-
```
1044+
Merges `release/vX.Y.Z` into `main` with `--no-ff`, pushes `main`, deletes the release branch.
10221045

1023-
5. CI detects tag and triggers:
1024-
* Full CI pipeline (lint, audit, test, CodeQL)
1025-
* Package build and publish to PyPI
1026-
* Docker image build and publish
1027-
* GitHub release creation
1028-
* Slack notification to team
1046+
**Note on branch protection**: `release/v*` branches should be protected so that only the GitHub Actions bot (`aignostics-release-bot[bot]`) can push to them. This enforces the server-side workflow. Configure in GitHub Settings → Branches → Branch protection rules.
10291047

10301048
### Manual Testing with Claude
10311049

@@ -1070,6 +1088,9 @@ make dist_native
10701088
| File | Type | Purpose | Duration |
10711089
|------|------|---------|----------|
10721090
| `ci-cd.yml` | Entry | Main pipeline orchestration | ~20 min |
1091+
| `prepare-release.yml` | Entry | Create release branch + bump version | ~2 min |
1092+
| `publish-release.yml` | Entry | Generate changelog, create tag, push | ~2 min |
1093+
| `merge-release.yml` | Entry | Merge release branch into main | ~1 min |
10731094
| `build-native-only.yml` | Entry | Native build trigger | ~60 min (6 platforms) |
10741095
| `claude-code-interactive.yml` | Entry | Manual Claude sessions | varies |
10751096
| `claude-code-automation-pr-review.yml` | Entry | Automated PR reviews | ~10 min |

.github/workflows/ci-cd.yml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
push:
55
branches:
66
- "main"
7+
- "release/v*"
78
tags:
89
- "v*.*.*"
910
pull_request:
@@ -68,7 +69,6 @@ jobs:
6869
if: |
6970
(!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) &&
7071
(!contains(needs.get-commit-message.outputs.commit_message, 'build:native:only')) &&
71-
!(github.ref_type == 'branch' && startsWith(needs.get-commit-message.outputs.commit_message, 'Bump version')) &&
7272
(!contains(github.event.pull_request.labels.*.name, 'skip:ci')) &&
7373
(!contains(github.event.pull_request.labels.*.name, 'build:native:only'))
7474
uses: ./.github/workflows/_lint.yml
@@ -82,7 +82,6 @@ jobs:
8282
if: |
8383
(!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) &&
8484
(!contains(needs.get-commit-message.outputs.commit_message, 'build:native:only')) &&
85-
!(github.ref_type == 'branch' && startsWith(needs.get-commit-message.outputs.commit_message, 'Bump version')) &&
8685
(!contains(github.event.pull_request.labels.*.name, 'skip:ci')) &&
8786
(!contains(github.event.pull_request.labels.*.name, 'build:native:only'))
8887
uses: ./.github/workflows/_audit.yml
@@ -96,7 +95,6 @@ jobs:
9695
if: |
9796
(!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) &&
9897
(!contains(needs.get-commit-message.outputs.commit_message, 'build:native:only')) &&
99-
!(github.ref_type == 'branch' && startsWith(needs.get-commit-message.outputs.commit_message, 'Bump version:')) &&
10098
(!contains(github.event.pull_request.labels.*.name, 'skip:ci')) &&
10199
(!contains(github.event.pull_request.labels.*.name, 'build:native:only'))
102100
uses: ./.github/workflows/_test.yml
@@ -123,7 +121,6 @@ jobs:
123121
if: |
124122
(!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) &&
125123
(!contains(needs.get-commit-message.outputs.commit_message, 'build:native:only')) &&
126-
!(github.ref_type == 'branch' && startsWith(needs.get-commit-message.outputs.commit_message, 'Bump version:')) &&
127124
(!contains(github.event.pull_request.labels.*.name, 'skip:ci')) &&
128125
(!contains(github.event.pull_request.labels.*.name, 'build:native:only'))
129126
uses: ./.github/workflows/_codeql.yml
@@ -138,7 +135,6 @@ jobs:
138135
if: |
139136
(!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) &&
140137
(!contains(needs.get-commit-message.outputs.commit_message, 'build:native:only')) &&
141-
!(github.ref_type == 'branch' && startsWith(needs.get-commit-message.outputs.commit_message, 'Bump version:')) &&
142138
(!contains(github.event.pull_request.labels.*.name, 'skip:ci')) &&
143139
(!contains(github.event.pull_request.labels.*.name, 'build:native:only'))
144140
runs-on: ubuntu-latest
@@ -165,7 +161,6 @@ jobs:
165161
github.actor != 'dependabot[bot]' &&
166162
(!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) &&
167163
(!contains(needs.get-commit-message.outputs.commit_message, 'build:native:only')) &&
168-
!(github.ref_type == 'branch' && startsWith(needs.get-commit-message.outputs.commit_message, 'Bump version:')) &&
169164
(!contains(github.event.pull_request.labels.*.name, 'skip:ci')) &&
170165
(!contains(github.event.pull_request.labels.*.name, 'build:native:only'))
171166
uses: ./.github/workflows/_ketryx_report_and_check.yml
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
name: "Merge Release"
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
branch:
7+
description: 'Release branch to merge (e.g. "release/v1.1.0"). Auto-detected if omitted.'
8+
required: false
9+
type: string
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
merge-release:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Generate GitHub App token
19+
id: app-token
20+
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
21+
with:
22+
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
23+
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
24+
25+
- name: Configure git identity
26+
run: |
27+
git config --global user.name "aignostics-release-bot[bot]"
28+
git config --global user.email "aignostics-release-bot[bot]@users.noreply.github.com"
29+
30+
- name: Checkout main
31+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
32+
with:
33+
token: ${{ steps.app-token.outputs.token }}
34+
ref: main
35+
fetch-depth: 0
36+
37+
- name: Resolve release branch
38+
id: branch
39+
env:
40+
INPUT: ${{ inputs.branch }}
41+
run: |
42+
if [ -n "$INPUT" ]; then
43+
BRANCH="$INPUT"
44+
else
45+
MATCHES=$(git ls-remote --heads origin 'release/v*' | awk '{print $2}' | sed 's|refs/heads/||')
46+
if [ -z "$MATCHES" ]; then
47+
echo "❌ No release/v* branches found."
48+
exit 1
49+
fi
50+
COUNT=$(echo "$MATCHES" | wc -l | tr -d ' ')
51+
if [ "$COUNT" -gt 1 ]; then
52+
echo "❌ Multiple release/v* branches found. Specify one explicitly:"
53+
echo "$MATCHES"
54+
exit 1
55+
fi
56+
BRANCH="$MATCHES"
57+
fi
58+
# Validate branch name pattern
59+
if ! echo "$BRANCH" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then
60+
echo "❌ Branch '${BRANCH}' does not match 'release/vX.Y.Z'. Aborting."
61+
exit 1
62+
fi
63+
# Validate branch exists on origin
64+
if ! git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then
65+
echo "❌ Branch '${BRANCH}' does not exist on origin. Aborting."
66+
exit 1
67+
fi
68+
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
69+
echo "✅ Using release branch: ${BRANCH}"
70+
71+
- name: Fetch remote
72+
run: git fetch origin
73+
74+
- name: Merge release branch into main
75+
run: |
76+
BRANCH="${{ steps.branch.outputs.branch }}"
77+
git merge --no-ff "origin/${BRANCH}" -m "chore: merge ${BRANCH} into main"
78+
79+
- name: Push main
80+
run: git push origin main
81+
82+
- name: Delete remote release branch
83+
run: git push origin --delete "${{ steps.branch.outputs.branch }}"
84+
85+
- name: Print job summary
86+
run: |
87+
BRANCH="${{ steps.branch.outputs.branch }}"
88+
cat >> "$GITHUB_STEP_SUMMARY" << EOF
89+
## ✅ Release merged
90+
91+
| | |
92+
|---|---|
93+
| **Branch** | \`${BRANCH}\` |
94+
| **Merged into** | \`main\` |
95+
96+
The release branch has been merged into \`main\` and deleted.
97+
EOF
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
name: "Prepare Release"
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
version:
7+
description: 'Version to release (e.g. "1.3.0")'
8+
required: true
9+
type: string
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
prepare-release:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Generate GitHub App token
19+
id: app-token
20+
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
21+
with:
22+
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
23+
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
24+
25+
- name: Configure git identity
26+
run: |
27+
git config --global user.name "aignostics-release-bot[bot]"
28+
git config --global user.email "aignostics-release-bot[bot]@users.noreply.github.com"
29+
30+
- name: Checkout main
31+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
32+
with:
33+
token: ${{ steps.app-token.outputs.token }}
34+
ref: main
35+
fetch-depth: 0
36+
37+
- name: Install uv
38+
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
39+
with:
40+
version-file: "pyproject.toml"
41+
enable-cache: true
42+
cache-dependency-glob: uv.lock
43+
44+
- name: Validate version
45+
id: version
46+
env:
47+
INPUT: ${{ inputs.version }}
48+
run: |
49+
# Must be an explicit x.y.z semver
50+
if ! echo "$INPUT" | grep -qE '^\d+\.\d+\.\d+$'; then
51+
echo "❌ Invalid version: '$INPUT'. Must be an explicit semver like '1.3.0'."
52+
exit 1
53+
fi
54+
55+
CURRENT=$(cat VERSION)
56+
BRANCH_NAME="release/v${INPUT}"
57+
echo "new_version=${INPUT}" >> "$GITHUB_OUTPUT"
58+
echo "branch_name=${BRANCH_NAME}" >> "$GITHUB_OUTPUT"
59+
echo "current_version=${CURRENT}" >> "$GITHUB_OUTPUT"
60+
61+
- name: Validate release refs do not already exist
62+
run: |
63+
BRANCH_NAME="${{ steps.version.outputs.branch_name }}"
64+
TAG_NAME="v${{ steps.version.outputs.new_version }}"
65+
66+
if git ls-remote --exit-code --heads origin "${BRANCH_NAME}" > /dev/null 2>&1; then
67+
echo "::error::Release branch '${BRANCH_NAME}' already exists on origin."
68+
exit 1
69+
fi
70+
71+
if git ls-remote --exit-code --tags origin "${TAG_NAME}" > /dev/null 2>&1; then
72+
echo "::error::Release tag '${TAG_NAME}' already exists on origin."
73+
exit 1
74+
fi
75+
76+
- name: Create release branch
77+
run: git checkout -b "${{ steps.version.outputs.branch_name }}"
78+
79+
- name: Bump version
80+
run: uv run --frozen bump-my-version bump --new-version "${{ steps.version.outputs.new_version }}"
81+
82+
- name: Push release branch
83+
run: git push -u origin "${{ steps.version.outputs.branch_name }}"
84+
85+
- name: Print next-steps summary
86+
run: |
87+
cat >> "$GITHUB_STEP_SUMMARY" << EOF
88+
## ✅ Release branch created
89+
90+
| | |
91+
|---|---|
92+
| **Branch** | \`${{ steps.version.outputs.branch_name }}\` |
93+
| **Version** | ${{ steps.version.outputs.current_version }} → ${{ steps.version.outputs.new_version }} |
94+
95+
### Next steps
96+
97+
1. Point your Ketryx release to branch \`${{ steps.version.outputs.branch_name }}\` and collect approvals.
98+
2. Once approvals are in place, run:
99+
\`\`\`bash
100+
make publish-release
101+
\`\`\`
102+
3. After the tag CI pipeline completes successfully and the package is published, merge back:
103+
\`\`\`bash
104+
make merge-release
105+
\`\`\`
106+
EOF

0 commit comments

Comments
 (0)