From 6cb59080508eb261bd2a8caa837fc93de4bb7be6 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Mon, 20 Apr 2026 15:24:27 +0200 Subject: [PATCH 1/2] chore: update release strategy --- .github/CLAUDE.md | 63 +++++++---- .github/workflows/ci-cd.yml | 7 +- .github/workflows/merge-release.yml | 97 +++++++++++++++++ .github/workflows/prepare-release.yml | 106 ++++++++++++++++++ .github/workflows/publish-release.yml | 148 ++++++++++++++++++++++++++ CLAUDE.md | 79 +++++++++----- CONTRIBUTING.md | 32 ++++-- Makefile | 41 ++++++- noxfile.py | 15 --- pyproject.toml | 8 +- 10 files changed, 510 insertions(+), 86 deletions(-) create mode 100644 .github/workflows/merge-release.yml create mode 100644 .github/workflows/prepare-release.yml create mode 100644 .github/workflows/publish-release.yml diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md index ef50aad07..354bc8414 100644 --- a/.github/CLAUDE.md +++ b/.github/CLAUDE.md @@ -6,7 +6,7 @@ This file provides comprehensive guidance for Claude Code and human engineers wo The Aignostics Python SDK uses a **sophisticated multi-stage CI/CD pipeline** built on GitHub Actions with: -* **19 workflow files** (8 entry points + 11 reusable workflows) +* **Multiple workflow files**, including both entry-point and reusable workflows * **Reusable workflow architecture** for modularity and maintainability * **Environment-based testing** (staging/production with scheduled validation) * **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 ```text ┌─────────────────────────────────────────────────────────────────────┐ │ ci-cd.yml (Main Orchestrator) │ -│ Triggered on: push to main, PR, release, tag v*.*.* │ +│ Triggered on: push to main/release/v*, PR, release, tag v*.*.* │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ┌────────┐ ┌───────┐ ┌────────────────┐ ┌────────┐ │ @@ -56,6 +56,9 @@ The Aignostics Python SDK uses a **sophisticated multi-stage CI/CD pipeline** bu ┌───────────────────────────────────────────────────────────────┐ │ Parallel Entry Points │ ├───────────────────────────────────────────────────────────────┤ +│ prepare-release.yml → Create release branch │ +│ publish-release.yml → Tag + changelog → CI/CD publish │ +│ merge-release.yml → Merge branch into main │ │ build-native-only.yml → Native executables (6 platforms) │ │ claude-code-*.yml → PR reviews + interactive sessions │ │ test-scheduled-*.yml → Staging (6h) + Production (24h) │ @@ -70,7 +73,10 @@ The Aignostics Python SDK uses a **sophisticated multi-stage CI/CD pipeline** bu | Workflow | Triggers | Purpose | Calls | |----------|----------|---------|-------| -| **ci-cd.yml** | push(main), PR, release, tag | Main CI/CD pipeline | _lint,_audit, _test,_codeql, _ketryx,_package-publish, _docker-publish | +| **ci-cd.yml** | push(main, release/v*), PR, release, tag | Main CI/CD pipeline | _lint,_audit, _test,_codeql, _ketryx,_package-publish, _docker-publish | +| **prepare-release.yml** | workflow_dispatch | Create release branch + bump version | — | +| **publish-release.yml** | workflow_dispatch | Generate changelog, tag, push → CI/CD | — | +| **merge-release.yml** | workflow_dispatch | Merge release branch into main | — | | **build-native-only.yml** | push, PR, release (if msg contains `build:native:only`) | Native executable builds | _build-native-only | | **claude-code-interactive.yml** | workflow_dispatch (manual) | Manual Claude sessions | _claude-code (interactive) | | **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 * `build:native:only` - Only build native executables * `skip:test:long_running` - Skip long-running tests * `enable:test:very_long_running` - Enable very long running tests -* `Bump version:` - Skip CI (version bump commits) **Usage**: @@ -398,6 +403,7 @@ git commit -m "fix: issue skip:test:long_running" **Triggers**: * `push` to `main` branch +* `push` to `release/v*` branches (release branch CI) * `pull_request` to `main` (opened, synchronize, reopened) * `release` created * `tags` matching `v*.*.*` @@ -415,7 +421,6 @@ Cancels in-progress runs when new commits are pushed to same PR/branch. * Commit message contains `skip:ci` * Commit message contains `build:native:only` -* Commit starts with `Bump version:` * PR has label `skip:ci` or `build:native:only` **Job Dependencies**: @@ -1006,26 +1011,39 @@ make dist_native ### Releasing a Version -1. Ensure `main` branch is clean and all tests pass -2. Run version bump: +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). - ```bash - make bump patch # or minor, major - ``` +**Phase 1 — Prepare the release branch** (triggers `prepare-release.yml`): -3. This creates a commit and git tag -4. Push with tags: +```bash +make prepare-release 1.2.3 # explicit version +``` + +Creates `release/vX.Y.Z` from `main`, commits version bump + `uv.lock`, pushes. CI runs on the branch automatically. + +**Phase 2 — Collect Ketryx approvals:** + +Point the Ketryx release to `release/vX.Y.Z` and collect approvals. Ensure CI is green. + +**Phase 3 — Publish** (triggers `publish-release.yml`): + +```bash +make publish-release # auto-detects release/v* branch +make publish-release release/v1.2.3 # explicit branch +``` + +Generates `CHANGELOG.md`, creates annotated `vX.Y.Z` tag, pushes → CI/CD fires on tag → Ketryx check must pass before PyPI publish. + +**Phase 4 — Merge back to main** (triggers `merge-release.yml`): + +```bash +make merge-release # auto-detects release/v* branch +make merge-release release/v1.2.3 # explicit branch +``` - ```bash - git push --follow-tags - ``` +Merges `release/vX.Y.Z` into `main` with `--no-ff`, pushes `main`, deletes the release branch. -5. CI detects tag and triggers: - * Full CI pipeline (lint, audit, test, CodeQL) - * Package build and publish to PyPI - * Docker image build and publish - * GitHub release creation - * Slack notification to team +**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. ### Manual Testing with Claude @@ -1070,6 +1088,9 @@ make dist_native | File | Type | Purpose | Duration | |------|------|---------|----------| | `ci-cd.yml` | Entry | Main pipeline orchestration | ~20 min | +| `prepare-release.yml` | Entry | Create release branch + bump version | ~2 min | +| `publish-release.yml` | Entry | Generate changelog, create tag, push | ~2 min | +| `merge-release.yml` | Entry | Merge release branch into main | ~1 min | | `build-native-only.yml` | Entry | Native build trigger | ~60 min (6 platforms) | | `claude-code-interactive.yml` | Entry | Manual Claude sessions | varies | | `claude-code-automation-pr-review.yml` | Entry | Automated PR reviews | ~10 min | diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 259094bdf..aed9abe24 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -4,6 +4,7 @@ on: push: branches: - "main" + - "release/v*" tags: - "v*.*.*" pull_request: @@ -68,7 +69,6 @@ jobs: if: | (!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) && (!contains(needs.get-commit-message.outputs.commit_message, 'build:native:only')) && - !(github.ref_type == 'branch' && startsWith(needs.get-commit-message.outputs.commit_message, 'Bump version')) && (!contains(github.event.pull_request.labels.*.name, 'skip:ci')) && (!contains(github.event.pull_request.labels.*.name, 'build:native:only')) uses: ./.github/workflows/_lint.yml @@ -82,7 +82,6 @@ jobs: if: | (!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) && (!contains(needs.get-commit-message.outputs.commit_message, 'build:native:only')) && - !(github.ref_type == 'branch' && startsWith(needs.get-commit-message.outputs.commit_message, 'Bump version')) && (!contains(github.event.pull_request.labels.*.name, 'skip:ci')) && (!contains(github.event.pull_request.labels.*.name, 'build:native:only')) uses: ./.github/workflows/_audit.yml @@ -96,7 +95,6 @@ jobs: if: | (!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) && (!contains(needs.get-commit-message.outputs.commit_message, 'build:native:only')) && - !(github.ref_type == 'branch' && startsWith(needs.get-commit-message.outputs.commit_message, 'Bump version:')) && (!contains(github.event.pull_request.labels.*.name, 'skip:ci')) && (!contains(github.event.pull_request.labels.*.name, 'build:native:only')) uses: ./.github/workflows/_test.yml @@ -123,7 +121,6 @@ jobs: if: | (!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) && (!contains(needs.get-commit-message.outputs.commit_message, 'build:native:only')) && - !(github.ref_type == 'branch' && startsWith(needs.get-commit-message.outputs.commit_message, 'Bump version:')) && (!contains(github.event.pull_request.labels.*.name, 'skip:ci')) && (!contains(github.event.pull_request.labels.*.name, 'build:native:only')) uses: ./.github/workflows/_codeql.yml @@ -138,7 +135,6 @@ jobs: if: | (!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) && (!contains(needs.get-commit-message.outputs.commit_message, 'build:native:only')) && - !(github.ref_type == 'branch' && startsWith(needs.get-commit-message.outputs.commit_message, 'Bump version:')) && (!contains(github.event.pull_request.labels.*.name, 'skip:ci')) && (!contains(github.event.pull_request.labels.*.name, 'build:native:only')) runs-on: ubuntu-latest @@ -165,7 +161,6 @@ jobs: github.actor != 'dependabot[bot]' && (!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) && (!contains(needs.get-commit-message.outputs.commit_message, 'build:native:only')) && - !(github.ref_type == 'branch' && startsWith(needs.get-commit-message.outputs.commit_message, 'Bump version:')) && (!contains(github.event.pull_request.labels.*.name, 'skip:ci')) && (!contains(github.event.pull_request.labels.*.name, 'build:native:only')) uses: ./.github/workflows/_ketryx_report_and_check.yml diff --git a/.github/workflows/merge-release.yml b/.github/workflows/merge-release.yml new file mode 100644 index 000000000..8a9c0d081 --- /dev/null +++ b/.github/workflows/merge-release.yml @@ -0,0 +1,97 @@ +name: "Merge Release" + +on: + workflow_dispatch: + inputs: + branch: + description: 'Release branch to merge (e.g. "release/v1.1.0"). Auto-detected if omitted.' + required: false + type: string + +permissions: + contents: read + +jobs: + merge-release: + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + + - name: Configure git identity + run: | + git config --global user.name "aignostics-release-bot[bot]" + git config --global user.email "aignostics-release-bot[bot]@users.noreply.github.com" + + - name: Checkout main + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.app-token.outputs.token }} + ref: main + fetch-depth: 0 + + - name: Resolve release branch + id: branch + env: + INPUT: ${{ inputs.branch }} + run: | + if [ -n "$INPUT" ]; then + BRANCH="$INPUT" + else + MATCHES=$(git ls-remote --heads origin 'release/v*' | awk '{print $2}' | sed 's|refs/heads/||') + if [ -z "$MATCHES" ]; then + echo "❌ No release/v* branches found." + exit 1 + fi + COUNT=$(echo "$MATCHES" | wc -l | tr -d ' ') + if [ "$COUNT" -gt 1 ]; then + echo "❌ Multiple release/v* branches found. Specify one explicitly:" + echo "$MATCHES" + exit 1 + fi + BRANCH="$MATCHES" + fi + # Validate branch name pattern + if ! echo "$BRANCH" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "❌ Branch '${BRANCH}' does not match 'release/vX.Y.Z'. Aborting." + exit 1 + fi + # Validate branch exists on origin + if ! git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then + echo "❌ Branch '${BRANCH}' does not exist on origin. Aborting." + exit 1 + fi + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" + echo "✅ Using release branch: ${BRANCH}" + + - name: Fetch remote + run: git fetch origin + + - name: Merge release branch into main + run: | + BRANCH="${{ steps.branch.outputs.branch }}" + git merge --no-ff "origin/${BRANCH}" -m "chore: merge ${BRANCH} into main" + + - name: Push main + run: git push origin main + + - name: Delete remote release branch + run: git push origin --delete "${{ steps.branch.outputs.branch }}" + + - name: Print job summary + run: | + BRANCH="${{ steps.branch.outputs.branch }}" + cat >> "$GITHUB_STEP_SUMMARY" << EOF + ## ✅ Release merged + + | | | + |---|---| + | **Branch** | \`${BRANCH}\` | + | **Merged into** | \`main\` | + + The release branch has been merged into \`main\` and deleted. + EOF diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 000000000..ef356aa79 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,106 @@ +name: "Prepare Release" + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g. "1.3.0")' + required: true + type: string + +permissions: + contents: read + +jobs: + prepare-release: + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + + - name: Configure git identity + run: | + git config --global user.name "aignostics-release-bot[bot]" + git config --global user.email "aignostics-release-bot[bot]@users.noreply.github.com" + + - name: Checkout main + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.app-token.outputs.token }} + ref: main + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + version-file: "pyproject.toml" + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Validate version + id: version + env: + INPUT: ${{ inputs.version }} + run: | + # Must be an explicit x.y.z semver + if ! echo "$INPUT" | grep -qE '^\d+\.\d+\.\d+$'; then + echo "❌ Invalid version: '$INPUT'. Must be an explicit semver like '1.3.0'." + exit 1 + fi + + CURRENT=$(cat VERSION) + BRANCH_NAME="release/v${INPUT}" + echo "new_version=${INPUT}" >> "$GITHUB_OUTPUT" + echo "branch_name=${BRANCH_NAME}" >> "$GITHUB_OUTPUT" + echo "current_version=${CURRENT}" >> "$GITHUB_OUTPUT" + + - name: Validate release refs do not already exist + run: | + BRANCH_NAME="${{ steps.version.outputs.branch_name }}" + TAG_NAME="v${{ steps.version.outputs.new_version }}" + + if git ls-remote --exit-code --heads origin "${BRANCH_NAME}" > /dev/null 2>&1; then + echo "::error::Release branch '${BRANCH_NAME}' already exists on origin." + exit 1 + fi + + if git ls-remote --exit-code --tags origin "${TAG_NAME}" > /dev/null 2>&1; then + echo "::error::Release tag '${TAG_NAME}' already exists on origin." + exit 1 + fi + + - name: Create release branch + run: git checkout -b "${{ steps.version.outputs.branch_name }}" + + - name: Bump version + run: uv run --frozen bump-my-version bump --new-version "${{ steps.version.outputs.new_version }}" + + - name: Push release branch + run: git push -u origin "${{ steps.version.outputs.branch_name }}" + + - name: Print next-steps summary + run: | + cat >> "$GITHUB_STEP_SUMMARY" << EOF + ## ✅ Release branch created + + | | | + |---|---| + | **Branch** | \`${{ steps.version.outputs.branch_name }}\` | + | **Version** | ${{ steps.version.outputs.current_version }} → ${{ steps.version.outputs.new_version }} | + + ### Next steps + + 1. Point your Ketryx release to branch \`${{ steps.version.outputs.branch_name }}\` and collect approvals. + 2. Once approvals are in place, run: + \`\`\`bash + make publish-release + \`\`\` + 3. After the tag CI pipeline completes successfully and the package is published, merge back: + \`\`\`bash + make merge-release + \`\`\` + EOF diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 000000000..377921cda --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,148 @@ +name: "Publish Release" + +on: + workflow_dispatch: + inputs: + branch: + description: 'Release branch name (e.g. "release/v1.1.0"). Auto-detected if omitted.' + required: false + type: string + +permissions: + contents: read + +jobs: + publish-release: + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + + - name: Configure git identity + run: | + git config --global user.name "aignostics-release-bot[bot]" + git config --global user.email "aignostics-release-bot[bot]@users.noreply.github.com" + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.app-token.outputs.token }} + fetch-depth: 0 + + - name: Resolve release branch + id: branch + env: + INPUT: ${{ inputs.branch }} + run: | + if [ -n "$INPUT" ]; then + BRANCH="$INPUT" + else + MATCHES=$(git ls-remote --heads origin 'release/v*' | awk '{print $2}' | sed 's|refs/heads/||') + if [ -z "$MATCHES" ]; then + echo "❌ No release/v* branches found. Run 'make prepare-release' first." + exit 1 + fi + COUNT=$(echo "$MATCHES" | wc -l | tr -d ' ') + if [ "$COUNT" -gt 1 ]; then + echo "❌ Multiple release/v* branches found. Specify one explicitly:" + echo "$MATCHES" + exit 1 + fi + BRANCH="$MATCHES" + fi + # Validate branch name pattern + if ! echo "$BRANCH" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "❌ Branch '${BRANCH}' does not match 'release/vX.Y.Z'. Aborting." + exit 1 + fi + # Validate branch exists on origin + if ! git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then + echo "❌ Branch '${BRANCH}' does not exist on origin. Aborting." + exit 1 + fi + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" + echo "✅ Using release branch: ${BRANCH}" + + - name: Checkout release branch + run: git checkout "${{ steps.branch.outputs.branch }}" + + - name: Install uv + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + version-file: "pyproject.toml" + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Install dependencies + run: uv sync --frozen + + - name: Read version info + id: version + run: | + NEW_VERSION=$(cat VERSION) + TAG_NAME="v${NEW_VERSION}" + PREV_TAG=$(git describe --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$PREV_TAG" ]; then + PREV_VERSION="${PREV_TAG#v}" + else + PREV_VERSION="(initial)" + fi + echo "new_version=${NEW_VERSION}" >> "$GITHUB_OUTPUT" + echo "tag_name=${TAG_NAME}" >> "$GITHUB_OUTPUT" + echo "prev_tag=${PREV_TAG}" >> "$GITHUB_OUTPUT" + echo "prev_version=${PREV_VERSION}" >> "$GITHUB_OUTPUT" + + - name: Generate changelog + run: | + PREV_TAG="${{ steps.version.outputs.prev_tag }}" + TAG_NAME="${{ steps.version.outputs.tag_name }}" + if [ -n "$PREV_TAG" ]; then + uv run git-cliff --tag "$TAG_NAME" --prepend CHANGELOG.md "${PREV_TAG}..HEAD" + else + uv run git-cliff --tag "$TAG_NAME" --output CHANGELOG.md + fi + + - name: Commit changelog + run: | + TAG_NAME="${{ steps.version.outputs.tag_name }}" + git add CHANGELOG.md + if git diff --staged --quiet; then + echo "No changelog changes to commit." + else + git commit --no-verify -m "chore: update changelog for ${TAG_NAME}" + fi + + - name: Create annotated tag + run: | + git tag -a "${{ steps.version.outputs.tag_name }}" \ + -m "Bump version: ${{ steps.version.outputs.prev_version }} → ${{ steps.version.outputs.new_version }}" + + - name: Push branch and tag + run: git push origin "${{ steps.branch.outputs.branch }}" --follow-tags + + - name: Print job summary + run: | + BRANCH="${{ steps.branch.outputs.branch }}" + TAG_NAME="${{ steps.version.outputs.tag_name }}" + cat >> "$GITHUB_STEP_SUMMARY" << EOF + ## ✅ Release published + + | | | + |---|---| + | **Branch** | \`${BRANCH}\` | + | **Tag** | \`${TAG_NAME}\` | + | **Version** | ${{ steps.version.outputs.prev_version }} → ${{ steps.version.outputs.new_version }} | + + ### Next steps + + 1. Monitor the CI/CD pipeline triggered by the tag: + [View CI runs](https://github.com/aignostics/python-sdk/actions) + 2. Once CI passes and the package is published, merge the release branch: + \`\`\`bash + make merge-release + \`\`\` + EOF diff --git a/CLAUDE.md b/CLAUDE.md index a4fade9ab..34c3f8017 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1277,48 +1277,73 @@ uv sync --all-extras # Install all optional groups ### Version Bumping and Releases -**Bump version (via Nox):** +Releases follow a **four-phase GitHub workflow–based strategy** that allows Ketryx compliance approvals to be collected *before* publishing: -```bash -# Patch version (1.0.0 -> 1.0.1) -make bump patch +``` +Prerequisite (anytime): Create the Ketryx release in the Ketryx portal +Phase 1: make prepare-release x.y.z +Phase 2: Point Ketryx release to release/vX.Y.Z; collect approvals +Phase 3: make publish-release +Phase 4: make merge-release +``` -# Minor version (1.0.0 -> 1.1.0) -make bump minor +**Phase 1 — Prepare the release branch:** -# Major version (1.0.0 -> 2.0.0) -make bump major +```bash +# Creates release/vX.Y.Z branch from main, bumps version files, and pushes. +# No tag is created yet. +make prepare-release 1.2.3 # explicit version ``` -**This process:** +This triggers `prepare-release.yml` on GitHub Actions, which: -1. Updates version in `pyproject.toml` -2. Creates git commit: "Bump version: 1.0.0 → 1.0.1" -3. Creates git tag: `v1.0.1` -4. Generates changelog from conventional commits +1. Creates `release/vX.Y.Z` branch from `main` +2. Runs `bump-my-version` (commits version files + `uv.lock`) +3. Pushes the branch — CI runs lint/test/audit on it -**Push with tags:** +**Phase 2 — Collect Ketryx approvals:** + +Point the Ketryx release to the `release/vX.Y.Z` branch and collect required approvals. CI must be green on the branch before proceeding. + +**Phase 3 — Publish (tag + PyPI):** ```bash -# Push commits and tags -git push --follow-tags +# Generates CHANGELOG.md, creates vX.Y.Z tag, pushes → triggers CI/CD publish. +make publish-release -# CI detects tag and triggers: -# 1. Full CI pipeline (lint + test + audit) -# 2. Package build and publish to PyPI -# 3. Docker image build and publish -# 4. GitHub release creation -# 5. Slack notification +# Optionally specify a branch explicitly: +make publish-release release/v1.2.3 ``` -**Manual release (if needed):** +This triggers `publish-release.yml`, which: + +1. Generates `CHANGELOG.md` for the release range +2. Commits the changelog +3. Creates and pushes the annotated `vX.Y.Z` tag +4. CI/CD fires on the tag; Ketryx check must pass before PyPI publish + +**Phase 4 — Merge back to main:** ```bash -# Build package -uv build +# Merges the release branch into main (--no-ff) and deletes the branch. +make merge-release + +# Optionally specify a branch explicitly: +make merge-release release/v1.2.3 +``` + +This triggers `merge-release.yml`, which: -# Publish to PyPI (via UV_PUBLISH_TOKEN secret) -uv publish +1. Merges `release/vX.Y.Z` into `main` with `--no-ff` +2. Pushes `main` +3. Deletes the remote release branch + +**What triggers CI/CD:** + +``` +make prepare-release → push to release/vX.Y.Z → lint + test + audit + Ketryx +make publish-release → push vX.Y.Z tag → full CI + PyPI + Docker + GitHub release +make merge-release → push to main → full CI pipeline ``` ### CI/CD Integration diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6196aa92e..ddb434ded 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,17 +124,35 @@ Notes: ### Publish Release +Releases follow a four-phase workflow that allows Ketryx compliance approvals to be collected before publishing: + +**Phase 1 — Create the release branch:** + ```shell -make bump # Patch release -make minor # Patch release -make major # Patch release -make x.y.z # Targeted release +make prepare-release 1.2.3 # explicit version ``` -Notes: +This triggers a GitHub Actions workflow that creates `release/vX.Y.Z` from `main`, bumps version files, and pushes the branch. CI runs automatically on the branch. + +**Phase 2 — Collect Ketryx approvals:** + +Point the Ketryx release to the `release/vX.Y.Z` branch and collect required approvals. + +**Phase 3 — Publish (tag → PyPI):** + +```shell +make publish-release +``` + +Generates `CHANGELOG.md`, creates the `vX.Y.Z` tag, and pushes — triggering CI/CD which publishes to PyPI, Docker registries, and creates a GitHub release (Ketryx check must pass first). + +**Phase 4 — Merge back to main:** + +```shell +make merge-release +``` -1. Changelog generated automatically -2. Publishes to PyPi, Docker Registries, Read The Docs, Streamlit and Auditing services +Merges the release branch into `main` with `--no-ff` and deletes the branch. ## Advanced usage diff --git a/Makefile b/Makefile index 7b3256c5d..ad36c36ff 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ $(error Python version validation failed. See error message above.) endif # Define all PHONY targets -.PHONY: act all audit bump clean codegen dist dist_native docs docker_build gui_watch install lint lint_fix pre_commit_run_all profile setup test test_coverage_reset test_default test_e2e test_e2e_matrix test_integration test_integration_matrix test_long_running test_scheduled test_stress test_sequential test_unit test_unit_matrix test_very_long_running update_from_template +.PHONY: act all audit clean codegen dist dist_native docs docker_build gui_watch install lint lint_fix merge-release pre_commit_run_all prepare-release profile publish-release setup test test_coverage_reset test_default test_e2e test_e2e_matrix test_integration test_integration_matrix test_long_running test_scheduled test_stress test_sequential test_unit test_unit_matrix test_very_long_running update_from_template # Main target i.e. default sessions defined in noxfile.py @@ -47,7 +47,7 @@ else \ fi ## Individual Nox sessions -act audit bump dist docs lint lint_fix setup test update_from_template: +act audit dist docs lint lint_fix setup test update_from_template: $(nox-cmd) # Standalone targets @@ -103,6 +103,39 @@ test_coverage_reset: rm -rf .coverage rm -rf reports/coverage* +## Trigger the prepare-release GitHub workflow to create a release/vX.Y.Z branch +## Usage: make prepare-release x.y.z +prepare-release: + $(eval VERSION := $(filter-out $@,$(MAKECMDGOALS))) + @if [ -z "$(VERSION)" ]; then \ + echo "❌ Usage: make prepare-release x.y.z"; \ + exit 1; \ + fi + gh workflow run prepare-release.yml --field version=$(VERSION) + @echo "Workflow triggered. Monitor at: https://github.com/aignostics/python-sdk/actions" + +## Trigger the publish-release GitHub workflow to generate changelog, tag, and push +## Usage: make publish-release [release/vX.Y.Z] +publish-release: + $(eval BRANCH := $(filter-out $@,$(MAKECMDGOALS))) + @if [ -n "$(BRANCH)" ]; then \ + gh workflow run publish-release.yml --field branch=$(BRANCH); \ + else \ + gh workflow run publish-release.yml; \ + fi + @echo "Workflow triggered. Monitor at: https://github.com/aignostics/python-sdk/actions" + +## Trigger the merge-release GitHub workflow to merge release/vX.Y.Z into main and delete the branch +## Usage: make merge-release [release/vX.Y.Z] +merge-release: + $(eval BRANCH := $(filter-out $@,$(MAKECMDGOALS))) + @if [ -n "$(BRANCH)" ]; then \ + gh workflow run merge-release.yml --field branch=$(BRANCH); \ + else \ + gh workflow run merge-release.yml; \ + fi + @echo "Workflow triggered. Monitor at: https://github.com/aignostics/python-sdk/actions" + ## Clean build artifacts and caches clean: rm -rf .mypy_cache @@ -192,7 +225,9 @@ help: @echo " act - Run GitHub actions locally via act" @echo " all - Run all default nox sessions, i.e. lint, test, docs, audit" @echo " audit - Run security and license compliance audit" - @echo " bump patch|minor|major|x.y.z - Bump version" + @echo " prepare-release x.y.z - Create release/vX.Y.Z branch via GitHub workflow" + @echo " publish-release [release/vX.Y.Z] - Generate changelog, tag, and push via GitHub workflow" + @echo " merge-release [release/vX.Y.Z] - Merge release branch into main and delete it via GitHub workflow" @echo " clean - Clean build artifacts and caches" @echo " codegen - Download openapi.json from Aignostics platform, generate API code" @echo " dist - Build wheel and sdist into dist/" diff --git a/noxfile.py b/noxfile.py index 79b853e48..0d72f6aec 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1023,21 +1023,6 @@ def act(session: nox.Session) -> None: ) -@nox.session(default=False) -def bump(session: nox.Session) -> None: - """Bump version and push changes to git.""" - version_part = session.posargs[0] if session.posargs else "patch" - - # Check if the version_part is a specific version (e.g., 1.2.3) - if re.match(r"^\d+\.\d+\.\d+$", version_part): - session.run("bump-my-version", "bump", "--new-version", version_part, external=True) - else: - session.run("bump-my-version", "bump", version_part, external=True) - - # Push changes to git including tag created - session.run("git", "push", "--follow-tags", "--no-verify", external=True) - - @nox.session() def dist(session: nox.Session) -> None: """Build wheel and put in dist/.""" diff --git a/pyproject.toml b/pyproject.toml index b6a1f0b8f..04196e035 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -405,7 +405,7 @@ replace = "{new_version}" regex = false ignore_missing_version = false ignore_missing_files = false -tag = true +tag = false sign_tags = false tag_name = "v{new_version}" allow_dirty = false @@ -413,16 +413,10 @@ commit = true commit_args = "--no-verify" tag_message = "Bump version: {current_version} → {new_version}" message = "Bump version: {current_version} → {new_version}" -# Note: Uncomment the following line to avoid running tests and ketryx during version bumps -#message = "Bump version: {current_version} → {new_version} [skip:test:all,skip:ketryx]" -#tag_message = "Bump version: {current_version} → {new_version} [skip:test:all,skip:ketryx]" setup_hooks = [] pre_commit_hooks = [ "uv sync", "git add uv.lock", - "uv run git-cliff --tag $BVHOOK_NEW_VERSION", - "rm -f CHANGELOG.md.tmp", - "git add CHANGELOG.md", ] post_commit_hooks = [] From 901a5c227aabc91b9101d5d0d5a9743306699bcc Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Tue, 21 Apr 2026 16:09:54 +0200 Subject: [PATCH 2/2] chore: add concurrency to release workflows --- .github/workflows/merge-release.yml | 4 ++++ .github/workflows/prepare-release.yml | 4 ++++ .github/workflows/publish-release.yml | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/.github/workflows/merge-release.yml b/.github/workflows/merge-release.yml index 8a9c0d081..b6443909e 100644 --- a/.github/workflows/merge-release.yml +++ b/.github/workflows/merge-release.yml @@ -11,6 +11,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: merge-release: runs-on: ubuntu-latest diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index ef356aa79..cfdfc7f6d 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -11,6 +11,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: prepare-release: runs-on: ubuntu-latest diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 377921cda..8d79ef71a 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -11,6 +11,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: publish-release: runs-on: ubuntu-latest