prepare-release-prs #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: prepare-release-prs | |
| # Triggered manually after generate-all and validate-all have passed on develop. | |
| # | |
| # Expected pipeline: | |
| # generate-all → validate-all → (validate passes) → prepare-release-prs | |
| # | |
| # validate-all passing is the signal that no force_major is needed: if the | |
| # generated code compiled and all tests passed, no method signatures or model | |
| # shapes changed in a way that breaks consumers. In that case, run this workflow | |
| # with no inputs. | |
| # | |
| # If validate-all failed due to a genuine generator-level SDK break (not just a | |
| # test that needs updating for a new required field), pass the affected modules | |
| # via force_major_modules before triggering this workflow. | |
| # | |
| # This workflow: | |
| # - Diffs each module's OpenAPI spec (develop vs master) to classify changes | |
| # - Computes the next semver version per module and for the repo tag | |
| # - Bumps VERSION for every module | |
| # - Opens two draft PRs: | |
| # PR 1 (release -> master): clean release versions | |
| # PR 2 (release -> develop): release versions + patch+1 .dev | |
| # | |
| # After this workflow completes, a developer must: | |
| # 1. Update hand-written tests on the release branch to reflect API changes | |
| # 2. Mark both draft PRs as ready for review | |
| # 3. Merge in order: master first, then develop — the snapshot commit on the | |
| # develop PR must never land on master | |
| # | |
| # If the PR changelog looks wrong, close/delete the release branch and retrigger. | |
| # | |
| # The workflow file must live on master to appear in the GitHub Actions UI, | |
| # but all checkout and diff operations target develop and master explicitly | |
| # via git, so the file location has no effect on the logic. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| force_major_modules: | |
| description: 'Comma-separated modules to force a major bump (e.g. "payments,invoicing"). Only needed after a validate-all failure caused by a genuine SDK break. Leave blank in normal operation.' | |
| type: string | |
| required: false | |
| default: '' | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| jobs: | |
| # JOB 1: discover-modules | |
| # | |
| # Reads the specs/ directory to produce the list of modules dynamically. | |
| # Adding a new module requires no changes to this workflow — it is picked | |
| # up automatically as long as a spec file exists under specs/. | |
| discover-modules: | |
| name: Discover Modules | |
| runs-on: ubuntu-latest | |
| outputs: | |
| modules: ${{ steps.list.outputs.modules }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| ref: develop | |
| - name: List modules from specs/ | |
| id: list | |
| run: | | |
| modules=$(ls specs/*.spec.yaml \ | |
| | sed 's|specs/||;s|\.spec\.yaml||' \ | |
| | jq -R . | jq -sc .) | |
| echo "modules=$modules" >> $GITHUB_OUTPUT | |
| # JOB 2: analyze-module | |
| # | |
| # Runs once per module in parallel. For each module it: | |
| # - Diffs the OpenAPI spec on develop vs master via oasdiff | |
| # - Applies force_major_modules override if present | |
| # - Computes the next version from the current VERSION | |
| # - Renders a human-readable markdown changelog | |
| # - Uploads results as an artifact for the prepare job to consume | |
| # | |
| # Results go via artifacts rather than job outputs because matrix outputs | |
| # are unreliable when multiple parallel jobs write to the same key — | |
| # the last writer wins and all others are lost. | |
| analyze-module: | |
| name: Analyze ${{ matrix.module }} | |
| needs: discover-modules | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| module: ${{ fromJson(needs.discover-modules.outputs.modules) }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| ref: develop | |
| # oasdiff classifies OpenAPI changes: | |
| # level 3 = spec-breaking (removed endpoint, changed type, etc.) | |
| # level 1 = additive (new endpoint, new optional field, etc.) | |
| # level 2 = non-breaking (description changes, etc.) | |
| # | |
| # Both level 3 and level 1 default to a minor bump. Level 3 changes are | |
| # labelled [SPEC-BREAKING] in the changelog — they are breaking per the | |
| # spec definition but in practice typically reflect corrections for fields | |
| # that were already logically required in backend services, not true | |
| # consumer-visible SDK breaks. force_major_modules is the explicit escape | |
| # hatch when a genuine major bump is warranted. | |
| - name: Install oasdiff | |
| run: | | |
| OASDIFF_VERSION="1.10.14" | |
| curl -sSL "https://github.com/tufin/oasdiff/releases/download/v${OASDIFF_VERSION}/oasdiff_${OASDIFF_VERSION}_linux_amd64.tar.gz" \ | |
| | tar -xz -C /usr/local/bin oasdiff | |
| # Extract both spec versions from git's object store (no file checkout needed). | |
| # If the spec doesn't exist on master this is a new module — skip the diff, | |
| # write an empty changelog, and flag it so the next step handles it separately. | |
| - name: Diff spec vs master | |
| run: | | |
| MODULE="${{ matrix.module }}" | |
| SPEC_PATH="specs/${MODULE}.spec.yaml" | |
| if git cat-file -e origin/master:${SPEC_PATH} 2>/dev/null; then | |
| git show origin/master:${SPEC_PATH} > /tmp/master-spec.yaml | |
| git show origin/develop:${SPEC_PATH} > /tmp/develop-spec.yaml | |
| oasdiff changelog --format json /tmp/master-spec.yaml /tmp/develop-spec.yaml > /tmp/${MODULE}-changelog.json | |
| echo "false" > /tmp/${MODULE}-new-module.txt | |
| else | |
| echo "[]" > /tmp/${MODULE}-changelog.json | |
| echo "true" > /tmp/${MODULE}-new-module.txt | |
| fi | |
| # Semver bump rules: | |
| # force_major_modules flag -> major (explicit override; use after validate-all failure) | |
| # any level 3 or level 1 -> minor | |
| # only level 2 -> patch | |
| # new module -> 1.0.0 | |
| # | |
| # Patch bump: .dev is stripped with no increment — the SNAPSHOT version | |
| # already represents the intended next patch number. | |
| - name: Compute bump type and next version | |
| id: compute | |
| run: | | |
| MODULE="${{ matrix.module }}" | |
| FORCE_MAJOR="false" | |
| IFS=',' read -ra FLAGGED <<< "${{ inputs.force_major_modules }}" | |
| for flagged in "${FLAGGED[@]}"; do | |
| TRIMMED=$(echo "$flagged" | xargs) | |
| if [ "$TRIMMED" = "$MODULE" ]; then | |
| FORCE_MAJOR="true" | |
| break | |
| fi | |
| done | |
| NEW_MODULE=$(cat /tmp/${MODULE}-new-module.txt) | |
| if [ "$NEW_MODULE" = "true" ]; then | |
| CURRENT="none" | |
| BUMP="new" | |
| NEXT="1.0.0" | |
| else | |
| BREAKING=$(jq '[.[] | select(.level == 3)] | length' /tmp/${MODULE}-changelog.json) | |
| ADDITIONS=$(jq '[.[] | select(.level == 1)] | length' /tmp/${MODULE}-changelog.json) | |
| if [ "$FORCE_MAJOR" = "true" ]; then | |
| BUMP="major" | |
| elif [ "$BREAKING" -gt 0 ] || [ "$ADDITIONS" -gt 0 ]; then | |
| BUMP="minor" | |
| else | |
| BUMP="patch" | |
| fi | |
| CURRENT=$(grep '^VERSION' ${MODULE}/${MODULE}/version.py | sed 's/VERSION = "\(.*\)"/\1/') | |
| BASE=$(echo "$CURRENT" | sed 's/\.dev$//') | |
| MAJOR=$(echo "$BASE" | cut -d. -f1) | |
| MINOR=$(echo "$BASE" | cut -d. -f2) | |
| PATCH=$(echo "$BASE" | cut -d. -f3) | |
| case "$BUMP" in | |
| major) NEXT="$((MAJOR + 1)).0.0" ;; | |
| minor) NEXT="${MAJOR}.$((MINOR + 1)).0" ;; | |
| patch) NEXT="${MAJOR}.${MINOR}.${PATCH}" ;; | |
| esac | |
| fi | |
| echo "bump=$BUMP" >> $GITHUB_OUTPUT | |
| echo "version=$NEXT" >> $GITHUB_OUTPUT | |
| echo "current=$CURRENT" >> $GITHUB_OUTPUT | |
| echo "force_major=$FORCE_MAJOR" >> $GITHUB_OUTPUT | |
| - name: Render module changelog markdown | |
| run: | | |
| MODULE="${{ matrix.module }}" | |
| BUMP="${{ steps.compute.outputs.bump }}" | |
| CURRENT="${{ steps.compute.outputs.current }}" | |
| NEXT="${{ steps.compute.outputs.version }}" | |
| FORCE_MAJOR="${{ steps.compute.outputs.force_major }}" | |
| { | |
| echo "## \`${MODULE}\` ${CURRENT} -> ${NEXT} (\`${BUMP}\`)" | |
| echo "" | |
| if [ "$FORCE_MAJOR" = "true" ]; then | |
| echo "> [!WARNING]" | |
| echo "> **Force-major:** this module was explicitly flagged by the operator." | |
| echo "> Carefully diff all generated code before approving —" | |
| echo "> method signatures, model shapes, or serialization behaviour may have changed." | |
| echo "" | |
| fi | |
| TOTAL=$(jq length /tmp/${MODULE}-changelog.json) | |
| if [ "$TOTAL" -eq 0 ]; then | |
| echo "_No spec changes detected. Stripping .dev as part of standard release._" | |
| echo "" | |
| else | |
| jq -r 'group_by(.path + .operation) | .[] | "#### " + (.[0].operation | ascii_upcase) + " " + .[0].path + "\n" + (map(if .level == 3 then "- **[SPEC-BREAKING]** " + .text elif .level == 1 then "- **[ADDITION]** " + .text else "- **[CHANGE]** " + .text end) | unique | join("\n")) + "\n"' /tmp/${MODULE}-changelog.json | |
| fi | |
| } > /tmp/${MODULE}-changelog.md | |
| - name: Save results as artifact | |
| run: | | |
| MODULE="${{ matrix.module }}" | |
| mkdir -p /tmp/release-artifacts/${MODULE} | |
| cp /tmp/${MODULE}-changelog.md /tmp/release-artifacts/${MODULE}/changelog.md | |
| echo "${{ steps.compute.outputs.bump }}" > /tmp/release-artifacts/${MODULE}/bump.txt | |
| echo "${{ steps.compute.outputs.version }}" > /tmp/release-artifacts/${MODULE}/next-version.txt | |
| echo "${{ steps.compute.outputs.current }}" > /tmp/release-artifacts/${MODULE}/current-version.txt | |
| echo "${{ steps.compute.outputs.force_major }}" > /tmp/release-artifacts/${MODULE}/force-major.txt | |
| - uses: actions/upload-artifact@v6 | |
| with: | |
| name: release-data-${{ matrix.module }} | |
| path: /tmp/release-artifacts/${{ matrix.module }} | |
| # JOB 3: prepare-release | |
| # | |
| # Runs after all matrix jobs complete. It: | |
| # - Aggregates per-module artifacts | |
| # - Determines the repo-level semver bump (highest module bump wins) | |
| # - Computes the next repo tag from the latest semver git tag | |
| # - Bumps VERSION for every module | |
| # - Writes the job summary to the Actions UI | |
| # - Commits, pushes the release branch, and opens two draft PRs: | |
| # PR 1 (release -> master): clean release versions | |
| # PR 2 (release -> develop): release versions + patch+1 .dev | |
| prepare-release: | |
| name: Prepare Release | |
| needs: | |
| - discover-modules | |
| - analyze-module | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| ref: develop | |
| - name: Download all release artifacts | |
| uses: actions/download-artifact@v7 | |
| with: | |
| pattern: release-data-* | |
| path: /tmp/release-artifacts | |
| merge-multiple: false | |
| # Repo-level tag bump follows highest-wins: if any module is major, tag is major. | |
| # "new" module type is treated as minor for the repo tag calculation. | |
| - name: Compute repo-level bump and next tag | |
| id: repo-version | |
| run: | | |
| git fetch --tags | |
| CURRENT_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) | |
| CURRENT_TAG="${CURRENT_TAG:-v0.0.0}" | |
| BASE="${CURRENT_TAG#v}" | |
| MAJOR=$(echo "$BASE" | cut -d. -f1) | |
| MINOR=$(echo "$BASE" | cut -d. -f2) | |
| PATCH=$(echo "$BASE" | cut -d. -f3) | |
| HIGHEST="patch" | |
| for bump_file in /tmp/release-artifacts/release-data-*/bump.txt; do | |
| BUMP=$(cat "$bump_file") | |
| if [ "$BUMP" = "major" ]; then | |
| HIGHEST="major" | |
| elif { [ "$BUMP" = "minor" ] || [ "$BUMP" = "new" ]; } && [ "$HIGHEST" != "major" ]; then | |
| HIGHEST="minor" | |
| fi | |
| done | |
| case "$HIGHEST" in | |
| major) NEXT_TAG="v$((MAJOR + 1)).0.0" ;; | |
| minor) NEXT_TAG="v${MAJOR}.$((MINOR + 1)).0" ;; | |
| patch) NEXT_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;; | |
| esac | |
| echo "current_tag=$CURRENT_TAG" >> $GITHUB_OUTPUT | |
| echo "next_tag=$NEXT_TAG" >> $GITHUB_OUTPUT | |
| echo "bump=$HIGHEST" >> $GITHUB_OUTPUT | |
| echo "branch=release/${NEXT_TAG}" >> $GITHUB_OUTPUT | |
| - name: Bump VERSION for each module | |
| run: | | |
| for module_dir in /tmp/release-artifacts/release-data-*/; do | |
| MODULE=$(basename "$module_dir" | sed 's/release-data-//') | |
| NEXT=$(cat "${module_dir}/next-version.txt") | |
| sed -i "s/VERSION = \".*\"/VERSION = \"${NEXT}\"/" ${MODULE}/${MODULE}/version.py | |
| done | |
| - name: Build aggregate changelog | |
| id: changelog | |
| run: | | |
| NEXT_TAG="${{ steps.repo-version.outputs.next_tag }}" | |
| CURRENT_TAG="${{ steps.repo-version.outputs.current_tag }}" | |
| BUMP="${{ steps.repo-version.outputs.bump }}" | |
| FORCE_MAJOR_INPUT="${{ inputs.force_major_modules }}" | |
| { | |
| echo "# Release ${NEXT_TAG}" | |
| echo "" | |
| echo "**Repo bump:** \`${BUMP}\` (${CURRENT_TAG} -> ${NEXT_TAG})" | |
| echo "" | |
| if [ -n "$FORCE_MAJOR_INPUT" ]; then | |
| echo "> [!WARNING]" | |
| echo "> **Force-major modules:** \`${FORCE_MAJOR_INPUT}\`" | |
| echo "> These modules had their bump forced to \`major\`. Review their generated" | |
| echo "> code diffs carefully before approving this PR." | |
| echo "" | |
| fi | |
| echo "> **Next step:** Update tests on this branch to reflect the changes below, then mark both PRs ready for review." | |
| echo "" | |
| echo "---" | |
| echo "" | |
| for module_dir in /tmp/release-artifacts/release-data-*/; do | |
| cat "${module_dir}/changelog.md" | |
| done | |
| } > /tmp/full-changelog.md | |
| { | |
| echo 'body<<EOF' | |
| cat /tmp/full-changelog.md | |
| echo EOF | |
| } >> $GITHUB_OUTPUT | |
| cp /tmp/full-changelog.md /tmp/pr-body.md | |
| - name: Write job summary | |
| run: | | |
| NEXT_TAG="${{ steps.repo-version.outputs.next_tag }}" | |
| CURRENT_TAG="${{ steps.repo-version.outputs.current_tag }}" | |
| BUMP="${{ steps.repo-version.outputs.bump }}" | |
| { | |
| echo "# Release: ${NEXT_TAG}" | |
| echo "" | |
| echo "| Module | Current | Bump | Next | Force Major |" | |
| echo "|--------|---------|------|------|-------------|" | |
| for module_dir in /tmp/release-artifacts/release-data-*/; do | |
| MODULE=$(basename "$module_dir" | sed 's/release-data-//') | |
| CURRENT=$(cat "${module_dir}/current-version.txt") | |
| MODULE_BUMP=$(cat "${module_dir}/bump.txt") | |
| NEXT=$(cat "${module_dir}/next-version.txt") | |
| FORCE_MAJOR=$(cat "${module_dir}/force-major.txt") | |
| [ "$FORCE_MAJOR" = "true" ] && FORCE_LABEL="YES" || FORCE_LABEL="" | |
| echo "| \`${MODULE}\` | ${CURRENT} | ${MODULE_BUMP} | ${NEXT} | ${FORCE_LABEL} |" | |
| done | |
| echo "" | |
| echo "**Repo tag:** ${CURRENT_TAG} -> **${NEXT_TAG}** (\`${BUMP}\`)" | |
| echo "" | |
| echo "---" | |
| echo "" | |
| cat /tmp/full-changelog.md | |
| } >> $GITHUB_STEP_SUMMARY | |
| # Commit clean release versions (no .dev) and push the release branch. | |
| # This is the commit the master PR targets. | |
| - name: Create release branch and commit VERSION bumps | |
| run: | | |
| BRANCH="${{ steps.repo-version.outputs.branch }}" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git config user.name "github-actions[bot]" | |
| git checkout -b "$BRANCH" | |
| git add -A | |
| if test "$(git status --porcelain=v1 2>/dev/null | wc -l)" -gt "0"; then | |
| git commit -m "chore: bump versions for ${{ steps.repo-version.outputs.next_tag }}" | |
| else | |
| echo "No VERSION changes to commit (all modules already at release version)." | |
| fi | |
| git push origin "$BRANCH" --force | |
| - name: Open draft PR to master | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh pr create \ | |
| --base master \ | |
| --head "${{ steps.repo-version.outputs.branch }}" \ | |
| --title "Release ${{ steps.repo-version.outputs.next_tag }}" \ | |
| --draft \ | |
| --body-file /tmp/pr-body.md \ | |
| || echo "PR to master already exists, skipping." | |
| # Add a second commit on the release branch bumping all modules to | |
| # patch+1 .dev. When the develop PR merges, develop is immediately | |
| # ready for the next development cycle. Must merge master PR first so | |
| # this snapshot commit never lands on master. | |
| - name: Prepare snapshot versions for back-merge | |
| run: | | |
| for module_dir in /tmp/release-artifacts/release-data-*/; do | |
| MODULE=$(basename "$module_dir" | sed 's/release-data-//') | |
| NEXT=$(cat "${module_dir}/next-version.txt") | |
| MAJOR=$(echo "$NEXT" | cut -d. -f1) | |
| MINOR=$(echo "$NEXT" | cut -d. -f2) | |
| PATCH=$(echo "$NEXT" | cut -d. -f3) | |
| SNAPSHOT_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1)).dev" | |
| sed -i "s/VERSION = \".*\"/VERSION = \"${SNAPSHOT_VERSION}\"/" ${MODULE}/${MODULE}/version.py | |
| done | |
| git add -A | |
| if test "$(git status --porcelain=v1 2>/dev/null | wc -l)" -gt "0"; then | |
| git commit -m "chore: prepare next snapshot versions after ${{ steps.repo-version.outputs.next_tag }}" | |
| else | |
| echo "No snapshot version changes to commit." | |
| fi | |
| git push origin "${{ steps.repo-version.outputs.branch }}" | |
| - name: Open draft PR to develop | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh pr create \ | |
| --base develop \ | |
| --head "${{ steps.repo-version.outputs.branch }}" \ | |
| --title "Release ${{ steps.repo-version.outputs.next_tag }} back-merge to develop" \ | |
| --draft \ | |
| --body "Back-merge of ${{ steps.repo-version.outputs.next_tag }} into develop. Bumps all modules to next patch .dev for continued development." \ | |
| || echo "PR to develop already exists, skipping." |