From 695f6e87554563e9a66caeeb38d38cd131ad75b8 Mon Sep 17 00:00:00 2001 From: Herbert Damker <52109189+hdamker@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:38:14 +0200 Subject: [PATCH] refactor(ra): fold update-issue, post-result and create-sync-pr into one finalize job --- .../workflows/release-automation-reusable.yml | 1029 +---------------- .../docs/technical-architecture.md | 11 +- release_automation/scripts/bot_responder.py | 44 + release_automation/scripts/command_policy.py | 6 + release_automation/scripts/finalize.py | 451 ++++++++ release_automation/scripts/github_client.py | 32 + release_automation/scripts/issue_manager.py | 45 +- release_automation/scripts/issue_sync.py | 267 ++++- .../scripts/regression_runner.py | 12 +- .../scripts/workflow_context.py | 3 +- .../tests/test_bot_responder.py | 31 + .../tests/test_command_policy.py | 36 + release_automation/tests/test_finalize.py | 607 ++++++++++ .../tests/test_github_client.py | 15 + .../tests/test_issue_manager.py | 93 +- release_automation/tests/test_issue_sync.py | 296 +++-- .../tests/test_regression_runner.py | 6 +- shared-actions/post-bot-comment/action.yml | 41 +- shared-actions/sync-release-issue/action.yml | 297 ----- .../tests/test_release_automation_workflow.py | 29 +- 20 files changed, 1783 insertions(+), 1568 deletions(-) create mode 100644 release_automation/scripts/finalize.py create mode 100644 release_automation/tests/test_finalize.py delete mode 100644 shared-actions/sync-release-issue/action.yml diff --git a/.github/workflows/release-automation-reusable.yml b/.github/workflows/release-automation-reusable.yml index bc335c19..caeefaaf 100644 --- a/.github/workflows/release-automation-reusable.yml +++ b/.github/workflows/release-automation-reusable.yml @@ -46,6 +46,7 @@ jobs: runs-on: ubuntu-latest outputs: plan: ${{ steps.policy.outputs.plan }} + finalize: ${{ steps.policy.outputs.finalize }} # Compatibility outputs under the names of the former check-trigger, # derive-state and post-interim jobs, so the jobs after plan read them # unchanged; removed when those jobs move into act/finalize. @@ -255,7 +256,7 @@ jobs: # interim_processing and command_rejected update the ack with GITHUB_TOKEN # (no extra issue_comment runs); config_error and publish_confirmation use # the App token, as their former jobs did. A failed interim post does not - # stop the command (post-result then skips, as before). + # stop the command (finalize then posts no result comment, as before). - name: Generate App Token id: app-token if: | @@ -862,8 +863,7 @@ jobs: gh = GitHubClient(f'{owner}/{repo}', token) publisher = ReleasePublisher(gh) - # Note: issue close moved to post-result job (after success message) - # Note: sync PR creation moved to create-sync-pr job + # Note: the sync PR and the issue close are done in the finalize job warnings = [] release_url = '' @@ -880,7 +880,7 @@ jobs: set_output('reference_tag', '') set_output('pointer_branch', '') print(f'::error::Publication failed: {publish_result.error_message}') - sys.exit(0) # Exit cleanly to allow post-result to run + sys.exit(0) # Exit cleanly; finalize posts publish_failed release_url = publish_result.release_url or '' is_prerelease = publish_result.is_prerelease @@ -914,8 +914,6 @@ jobs: warnings.append(f'Branch cleanup issue: {op}') print(f' {op}: {status}') - # Note: Issue close is done in post-result job AFTER success message - # Set outputs set_output('success', 'true') set_output('release_url', release_url) @@ -929,245 +927,6 @@ jobs: print('Publish flow completed successfully') PYTHON_SCRIPT - # ───────────────────────────────────────────────────────────────────────────── - # Phase 5f.2: Create post-release sync PR - # ───────────────────────────────────────────────────────────────────────────── - create-sync-pr: - name: "Create Post-Release Sync PR" - needs: [plan, publish-release] - if: | - needs.publish-release.outputs.success == 'true' - runs-on: ubuntu-latest - outputs: - sync_pr_url: ${{ steps.sync.outputs.sync_pr_url }} - sync_pr_number: ${{ steps.sync.outputs.sync_pr_number }} - sync_status: ${{ steps.sync.outputs.sync_status }} - steps: - - name: Checkout API repo (main branch) - uses: actions/checkout@v7 - with: - path: api-repo - fetch-depth: 1 - - - name: Generate App Token - id: app-token - if: vars.RELEASE_APP_CLIENT_ID != '' - uses: actions/create-github-app-token@v3 - with: - client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - - - name: Copy README release info from tag - id: readme - env: - RELEASE_TAG: ${{ needs.plan.outputs.release_tag }} - uses: actions/github-script@v9 - with: - github-token: ${{ steps.app-token.outputs.token || github.token }} - script: | - const fs = require('fs'); - const tag = process.env.RELEASE_TAG; - - // Read README from release tag - let tagReadme; - try { - const { data } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: 'README.md', - ref: tag - }); - tagReadme = Buffer.from(data.content, 'base64').toString('utf8'); - } catch (e) { - core.warning(`Could not read README.md from tag ${tag}: ${e.message}`); - core.setOutput('changed', 'false'); - return; - } - - const START = ''; - const END = ''; - - // Extract release info block from tag README - const tagStart = tagReadme.indexOf(START); - const tagEnd = tagReadme.indexOf(END); - if (tagStart === -1 || tagEnd === -1) { - core.warning('Tag README missing release info delimiters'); - core.setOutput('changed', 'false'); - return; - } - const tagBlock = tagReadme.substring(tagStart, tagEnd + END.length); - - // Read local README (main branch checkout) - const localPath = 'api-repo/README.md'; - if (!fs.existsSync(localPath)) { - core.warning('README.md not found in API repo checkout'); - core.setOutput('changed', 'false'); - return; - } - const localReadme = fs.readFileSync(localPath, 'utf8'); - const localStart = localReadme.indexOf(START); - const localEnd = localReadme.indexOf(END); - if (localStart === -1 || localEnd === -1) { - core.warning('Main README missing release info delimiters'); - core.setOutput('changed', 'false'); - return; - } - - // Replace local block with tag block - const newReadme = localReadme.substring(0, localStart) + - tagBlock + - localReadme.substring(localEnd + END.length); - - if (newReadme === localReadme) { - console.log('README release info already up to date'); - core.setOutput('changed', 'false'); - return; - } - - fs.writeFileSync(localPath, newReadme); - core.setOutput('changed', 'true'); - console.log('README release info synced from tag'); - - - name: Copy CHANGELOG from release tag - id: changelog - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token || github.token }} - RELEASE_TAG: ${{ needs.plan.outputs.release_tag }} - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - const path = require('path'); - const tag = process.env.RELEASE_TAG; - - // Build candidate paths: per-cycle first, then flat CHANGELOG.md - // (flat covers legacy maintenance-release repos that have not yet - // migrated to per-cycle layout; mirrors the publish-side fallback). - const match = tag.match(/^r(\d+)\.\d+/); - const candidates = []; - if (match) candidates.push(`CHANGELOG/CHANGELOG-r${match[1]}.md`); - candidates.push('CHANGELOG.md'); - - let syncedPath = ''; - let content = ''; - for (const p of candidates) { - try { - const { data } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: p, - ref: tag - }); - content = Buffer.from(data.content, 'base64').toString('utf8'); - syncedPath = p; - break; - } catch (e) { - // Try next candidate - } - } - - if (!syncedPath) { - core.warning(`No CHANGELOG found at ${candidates.join(' or ')} on tag ${tag}`); - core.setOutput('synced', 'false'); - return; - } - - // Write to local api-repo checkout (mkdir parent for per-cycle case) - const localPath = path.join('api-repo', syncedPath); - fs.mkdirSync(path.dirname(localPath), { recursive: true }); - fs.writeFileSync(localPath, content); - core.setOutput('synced', 'true'); - core.setOutput('changelog_path', syncedPath); - console.log(`Synced ${syncedPath} from tag ${tag}`); - - - name: Resolve Bot Identity - id: bot-identity - env: - GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} - run: | - if [ -n "${{ vars.RELEASE_APP_SLUG }}" ] && [ -n "${{ steps.app-token.outputs.token }}" ]; then - BOT_LOGIN="${{ vars.RELEASE_APP_SLUG }}[bot]" - else - BOT_LOGIN="github-actions[bot]" - fi - BOT_INFO=$(gh api "/users/$BOT_LOGIN" --jq '{id: .id, login: .login}') - BOT_NAME=$(echo "$BOT_INFO" | jq -r '.login') - BOT_ID=$(echo "$BOT_INFO" | jq -r '.id') - echo "bot_name=$BOT_NAME" >> $GITHUB_OUTPUT - echo "bot_email=${BOT_ID}+${BOT_NAME}@users.noreply.github.com" >> $GITHUB_OUTPUT - - - name: Create sync PR - id: sync - env: - GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} - RELEASE_TAG: ${{ needs.plan.outputs.release_tag }} - README_CHANGED: ${{ steps.readme.outputs.changed }} - CHANGELOG_SYNCED: ${{ steps.changelog.outputs.synced }} - run: | - cd api-repo - - # Check if there are any changes to commit - if [ "$README_CHANGED" != "true" ] && [ "$CHANGELOG_SYNCED" != "true" ]; then - echo "::warning::No content changes to sync" - echo "sync_status=no_changes" >> $GITHUB_OUTPUT - echo "sync_pr_url=" >> $GITHUB_OUTPUT - echo "sync_pr_number=" >> $GITHUB_OUTPUT - exit 0 - fi - - SYNC_BRANCH="pr-to-main/${RELEASE_TAG}" - - # Create branch, stage, commit, push - git checkout -b "$SYNC_BRANCH" - git add -A - git diff --cached --quiet && { - echo "::warning::No actual file changes detected" - echo "sync_status=no_changes" >> $GITHUB_OUTPUT - echo "sync_pr_url=" >> $GITHUB_OUTPUT - echo "sync_pr_number=" >> $GITHUB_OUTPUT - exit 0 - } - - git config user.name "${{ steps.bot-identity.outputs.bot_name }}" - git config user.email "${{ steps.bot-identity.outputs.bot_email }}" - git commit -m "chore: post-release sync for ${RELEASE_TAG}" - git push origin "$SYNC_BRANCH" - - # Create PR — capture failure as sync_status=failed. - # Body assembled via heredoc so the markdown has no - # YAML-run-block indent leaking into the rendered PR description. - PR_BODY=$(cat </dev/null || true - - echo "sync_pr_url=$PR_URL" >> $GITHUB_OUTPUT - echo "sync_pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT - echo "sync_status=created" >> $GITHUB_OUTPUT - echo "Sync PR created: $PR_URL" - else - echo "::error::Failed to create post-release sync PR" - echo "sync_status=failed" >> $GITHUB_OUTPUT - echo "sync_pr_url=" >> $GITHUB_OUTPUT - echo "sync_pr_number=" >> $GITHUB_OUTPUT - fi - # ───────────────────────────────────────────────────────────────────────────── # Phase 5g: Handle PR merge (draft release creation) # ───────────────────────────────────────────────────────────────────────────── @@ -1507,36 +1266,24 @@ jobs: } # ───────────────────────────────────────────────────────────────────────────── - # Phase 6: Update Release Issue state (after command execution) + # Finalize — post-release sync PR, common sync, Release Issue sync + # and notifications, result comment, published Release Issue. Runs after the + # command jobs even when one failed; release_automation/scripts/finalize.py + # decides which parts apply from the needs context. # ───────────────────────────────────────────────────────────────────────────── - update-issue: - name: "sync-issue" + finalize: needs: [ plan, create-snapshot, discard-snapshot, delete-draft, + publish-release, handle-pr-merge, handle-issue-event, ] - if: | - always() && - needs.plan.outputs.config_error == '' && - needs.plan.outputs.state != '' && - ( - (needs.create-snapshot.result == 'success' && needs.create-snapshot.outputs.success == 'true') || - (needs.discard-snapshot.result == 'success' && needs.discard-snapshot.outputs.success == 'true') || - (needs.delete-draft.result == 'success' && needs.delete-draft.outputs.success == 'true') || - (needs.handle-pr-merge.result == 'success' && needs.handle-pr-merge.outputs.success == 'true') || - (needs.handle-issue-event.result == 'success') || - (needs.plan.outputs.command == 'sync-issue') - ) + if: always() && needs.plan.outputs.finalize == 'true' runs-on: ubuntu-latest - outputs: - issue_number: ${{ steps.sync.outputs.issue_number }} - issue_action: ${{ steps.sync.outputs.issue_action }} - issue_url: ${{ steps.sync.outputs.issue_url }} steps: - name: Generate App Token id: app-token @@ -1559,749 +1306,19 @@ jobs: validation linting/config - # ── Common file cache sync ───────────────────────────────────────── - # When cache is stale, fetch Commonalities files and create/update a - # sync PR before the Release Issue update. Uses continue-on-error so - # sync failures don't prevent the issue update. - - - name: Checkout API repo (for common sync) - id: sync-checkout-api - if: needs.plan.outputs.common_cache_status == 'stale' - continue-on-error: true - uses: actions/checkout@v7 - with: - path: _api_repo - fetch-depth: 1 - token: ${{ steps.app-token.outputs.token || github.token }} - - - name: Checkout Commonalities at declared tag (sparse) - id: sync-checkout-commonalities - if: steps.sync-checkout-api.outcome == 'success' - continue-on-error: true - uses: actions/checkout@v7 - with: - repository: camaraproject/Commonalities - ref: ${{ needs.plan.outputs.commonalities_release }} - path: _commonalities - sparse-checkout: artifacts/common - sparse-checkout-cone-mode: true - - - name: Copy Commonalities files and write manifest - id: sync-common - if: steps.sync-checkout-commonalities.outcome == 'success' - continue-on-error: true - env: - COMMONALITIES_RELEASE: ${{ needs.plan.outputs.commonalities_release }} - TOOLING_PATH: ${{ github.workspace }}/_tooling - shell: python - run: | - import os, shutil, sys - from pathlib import Path - - sys.path.insert(0, os.environ['TOOLING_PATH']) - - comm_release = os.environ['COMMONALITIES_RELEASE'] - source_dir = Path('_commonalities/artifacts/common') - api_repo = Path('_api_repo') - common_dir = api_repo / 'code' / 'common' - common_dir.mkdir(parents=True, exist_ok=True) - output_file = os.environ['GITHUB_OUTPUT'] - - if not source_dir.is_dir(): - print(f"::error::Commonalities artifacts/common not found at tag {comm_release}") - with open(output_file, 'a') as f: - f.write("synced=false\n") - sys.exit(0) - - # Copy YAML files from Commonalities checkout to code/common/ - from tooling_lib.cache_sync import git_blob_sha - - source_files = sorted(p for p in source_dir.iterdir() if p.suffix == '.yaml') - if not source_files: - print(f"::error::No YAML files in Commonalities artifacts/common at {comm_release}") - with open(output_file, 'a') as f: - f.write("synced=false\n") - sys.exit(0) - - manifest_files = {} - for src_path in source_files: - dest = common_dir / src_path.name - shutil.copy2(src_path, dest) - raw_bytes = dest.read_bytes() - sha = git_blob_sha(raw_bytes) - manifest_files[src_path.name] = sha - print(f"Synced: {src_path.name} ({sha[:12]})") - - # Write .sync-manifest.yaml per sync-manifest-schema.yaml - import yaml - - manifest = { - 'sources': [{ - 'repository': 'Commonalities', - 'release': comm_release, - 'files': manifest_files, - }] - } - manifest_path = common_dir / '.sync-manifest.yaml' - manifest_path.write_text( - yaml.dump(manifest, default_flow_style=False, sort_keys=False), - encoding='utf-8' - ) - print(f"Wrote manifest: {manifest_path} ({len(manifest_files)} files)") - - with open(output_file, 'a') as f: - f.write("synced=true\n") - f.write(f"files_count={len(manifest_files)}\n") - - - name: Resolve Bot Identity (for common sync) - id: sync-bot-identity - if: needs.plan.outputs.common_cache_status == 'stale' && steps.sync-common.outputs.synced == 'true' - env: - GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} - run: | - if [ -n "${{ vars.RELEASE_APP_SLUG }}" ] && [ -n "${{ steps.app-token.outputs.token }}" ]; then - BOT_LOGIN="${{ vars.RELEASE_APP_SLUG }}[bot]" - else - BOT_LOGIN="github-actions[bot]" - fi - BOT_INFO=$(gh api "/users/$BOT_LOGIN" --jq '{id: .id, login: .login}') - BOT_NAME=$(echo "$BOT_INFO" | jq -r '.login') - BOT_ID=$(echo "$BOT_INFO" | jq -r '.id') - echo "bot_name=$BOT_NAME" >> $GITHUB_OUTPUT - echo "bot_email=${BOT_ID}+${BOT_NAME}@users.noreply.github.com" >> $GITHUB_OUTPUT - - - name: Create or update common sync PR - id: sync-pr - if: needs.plan.outputs.common_cache_status == 'stale' && steps.sync-common.outputs.synced == 'true' - continue-on-error: true - env: - GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} - COMMONALITIES_RELEASE: ${{ needs.plan.outputs.commonalities_release }} - run: | - cd _api_repo - - SYNC_BRANCH="sync-common/${COMMONALITIES_RELEASE}" - - # Close stale sync PRs (different commonalities release) - STALE_PRS=$(gh pr list --state open --json number,headRefName \ - --jq '.[] | select(.headRefName | startswith("sync-common/")) | select(.headRefName != "'"$SYNC_BRANCH"'") | .number' 2>/dev/null || true) - for pr_num in $STALE_PRS; do - echo "Closing stale sync PR #$pr_num" - gh pr close "$pr_num" --comment "Superseded by sync to Commonalities ${COMMONALITIES_RELEASE}" || true - done - - git config user.name "${{ steps.sync-bot-identity.outputs.bot_name }}" - git config user.email "${{ steps.sync-bot-identity.outputs.bot_email }}" - - # Release Automation owns the sync-common branch: rebuild it from the - # current main HEAD on every run (dependabot model), carrying the - # freshly-synced code/common content already staged in the working tree. - # -B resets the branch ref at HEAD and keeps those working-tree changes, - # avoiding the overwrite abort that a plain checkout of an existing - # branch hits against the already-modified files. - git checkout -B "$SYNC_BRANCH" - - git add code/common/ - if git diff --cached --quiet; then - echo "code/common already matches Commonalities ${COMMONALITIES_RELEASE}; nothing to sync" - echo "sync_pr_url=" >> $GITHUB_OUTPUT - echo "sync_pr_number=" >> $GITHUB_OUTPUT - echo "sync_status=no_changes" >> $GITHUB_OUTPUT - exit 0 - fi - - git commit -m "chore: sync common files from Commonalities ${COMMONALITIES_RELEASE}" - - # Force-push: the branch is reset to main HEAD each run, so it is not a - # fast-forward of any pre-existing remote branch. RA is the sole owner. - git push --force origin "$SYNC_BRANCH" - - # Ensure exactly one open sync PR. The branch may have pre-existed with - # its PR already closed (e.g. a codeowner closed it but left the branch), - # so reuse an open PR if present, otherwise (re)create one. - EXISTING_PR=$(gh pr list --head "$SYNC_BRANCH" --state open --json url --jq '.[0].url' 2>/dev/null || true) - if [ -n "$EXISTING_PR" ]; then - echo "Reusing open sync PR: $EXISTING_PR" - echo "sync_pr_url=$EXISTING_PR" >> $GITHUB_OUTPUT - echo "sync_pr_number=$(echo "$EXISTING_PR" | grep -oE '[0-9]+$')" >> $GITHUB_OUTPUT - echo "sync_status=updated" >> $GITHUB_OUTPUT - exit 0 - fi - - # Create PR. Body assembled via heredoc so markdown has no - # YAML-run-block indent leaking into the rendered PR description. - PR_BODY=$(cat < [!WARNING] - > This branch is owned by Release Automation and is rebuilt from \`main\` on every run. - > **Do not commit fixes to this branch** — any commits here are overwritten the next time the sync runs. - - Automated sync of \`code/common/\` files from \`camaraproject/Commonalities\` at release tag \`${COMMONALITIES_RELEASE}\`. - - This PR was created by release automation because the cached Commonalities files were out of sync with the declared dependency in \`release-plan.yaml\`. - - **What changed:** - - Updated files in \`code/common/\` to match Commonalities ${COMMONALITIES_RELEASE} - - Added/updated \`.sync-manifest.yaml\` with file integrity hashes - - **How to handle this PR:** - - **Validation green** — review and merge. - - **Validation red** — choose one: - - *(recommended)* fix the errors on \`main\`, then re-trigger the sync; this PR is rebuilt from the new \`main\` HEAD with the corrected content. - - merge this PR with the errors and fix them on \`main\` afterwards. - - To re-trigger the sync, run the **CAMARA Release Automation** workflow from the repository's **Actions** tab. - - \`/create-snapshot\` is blocked until common files are in sync. - EOF - ) - PR_URL=$(gh pr create \ - --title "Sync common files from Commonalities ${COMMONALITIES_RELEASE}" \ - --body "$PR_BODY" \ - --head "$SYNC_BRANCH" \ - --base main) || { - echo "::error::Failed to create sync PR" - echo "sync_status=failed" >> $GITHUB_OUTPUT - exit 0 - } - - PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') - gh pr edit "$PR_NUMBER" --add-label "common-sync" --add-label "automated" 2>/dev/null || true - - echo "sync_pr_url=$PR_URL" >> $GITHUB_OUTPUT - echo "sync_pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT - echo "sync_status=created" >> $GITHUB_OUTPUT - echo "Sync PR created: $PR_URL" - - # ── End common file cache sync ──────────────────────────────────────── - - - name: Resolve Sync Context - id: sync-context - shell: bash - run: | - state_override="" - snapshot_branch_override="" - release_pr_number_override="" - draft_release_url_override="" - force_update="false" - - if [ "${{ needs.create-snapshot.result }}" = "success" ] && [ "${{ needs.create-snapshot.outputs.success }}" = "true" ]; then - state_override="snapshot-active" - snapshot_branch_override="${{ needs.create-snapshot.outputs.snapshot_branch }}" - release_pr_number_override="${{ needs.create-snapshot.outputs.release_pr_number }}" - force_update="true" - elif [ "${{ needs.handle-pr-merge.result }}" = "success" ] && [ "${{ needs.handle-pr-merge.outputs.success }}" = "true" ]; then - state_override="draft-ready" - snapshot_branch_override="${{ needs.plan.outputs.snapshot_branch }}" - release_pr_number_override="${{ github.event.pull_request.number }}" - draft_release_url_override="${{ needs.handle-pr-merge.outputs.draft_release_url }}" - force_update="true" - elif [ "${{ needs.discard-snapshot.result }}" = "success" ] && [ "${{ needs.discard-snapshot.outputs.success }}" = "true" ]; then - state_override="planned" - force_update="true" - elif [ "${{ needs.delete-draft.result }}" = "success" ] && [ "${{ needs.delete-draft.outputs.success }}" = "true" ]; then - state_override="planned" - force_update="true" - elif [ "${{ needs.plan.outputs.command }}" = "sync-issue" ]; then - force_update="true" - fi - - { - echo "state_override=$state_override" - echo "snapshot_branch_override=$snapshot_branch_override" - echo "release_pr_number_override=$release_pr_number_override" - echo "draft_release_url_override=$draft_release_url_override" - echo "force_update=$force_update" - } >> "$GITHUB_OUTPUT" - - - name: Sync Release Issue - id: sync - uses: ./_tooling/shared-actions/sync-release-issue - with: - release_tag: ${{ needs.plan.outputs.release_tag }} - state: ${{ needs.plan.outputs.state }} - snapshot_branch: ${{ needs.plan.outputs.snapshot_branch }} - release_pr_number: ${{ needs.plan.outputs.release_pr_number }} - state_override: ${{ steps.sync-context.outputs.state_override }} - snapshot_branch_override: ${{ steps.sync-context.outputs.snapshot_branch_override }} - release_pr_number_override: ${{ steps.sync-context.outputs.release_pr_number_override }} - draft_release_url_override: ${{ steps.sync-context.outputs.draft_release_url_override }} - force_update: ${{ steps.sync-context.outputs.force_update }} - trigger_type: ${{ needs.plan.outputs.trigger_type }} - github_token: ${{ steps.app-token.outputs.token || github.token }} - common_cache_status: ${{ needs.plan.outputs.common_cache_status }} - common_cache_details: ${{ needs.plan.outputs.common_cache_details }} - common_sync_pr_url: ${{ steps.sync-pr.outputs.sync_pr_url || needs.plan.outputs.common_sync_pr_url }} - - - name: Log Result - run: | - echo "Issue sync result:" - echo " Action: ${{ steps.sync.outputs.issue_action }}" - echo " Issue: #${{ steps.sync.outputs.issue_number }}" - echo " URL: ${{ steps.sync.outputs.issue_url }}" - - - name: Post Not-Planned Notification - if: | - steps.sync.outputs.issue_action == 'updated' && - needs.plan.outputs.state == 'not_planned' && - steps.sync.outputs.issue_number != '' - uses: ./_tooling/shared-actions/post-bot-comment - with: - issue_number: ${{ steps.sync.outputs.issue_number }} - release_tag: ${{ needs.plan.outputs.release_tag }} - run_id: ${{ github.run_id }} - template: state_not_planned - base_context: ${{ needs.plan.outputs.base_context }} - github_token: ${{ steps.app-token.outputs.token || github.token }} - context: | - { - "state": "not-planned" - } - - - name: Post Issue Created Notification - if: | - steps.sync.outputs.issue_action == 'created' && - steps.sync.outputs.issue_number != '' - uses: ./_tooling/shared-actions/post-bot-comment - with: - issue_number: ${{ steps.sync.outputs.issue_number }} - release_tag: ${{ needs.plan.outputs.release_tag }} - run_id: ${{ github.run_id }} - template: issue_created - base_context: ${{ needs.plan.outputs.base_context }} - github_token: ${{ steps.app-token.outputs.token || github.token }} - context: | - { - "state": "planned", - "trigger_type": "${{ needs.plan.outputs.trigger_type }}", - "release_plan_url": "${{ github.server_url }}/${{ github.repository }}/blob/main/release-plan.yaml", - "closed_issue_number": "${{ github.event.issue.number }}", - "closed_issue_url": "${{ github.event.issue.html_url }}" - } - - - name: Post Common Sync PR Created - if: | - steps.sync-pr.outputs.sync_status == 'created' && - (needs.plan.outputs.release_issue_number != '' || steps.sync.outputs.issue_number != '') - uses: ./_tooling/shared-actions/post-bot-comment - with: - issue_number: ${{ needs.plan.outputs.release_issue_number || steps.sync.outputs.issue_number }} - release_tag: ${{ needs.plan.outputs.release_tag }} - run_id: ${{ github.run_id }} - template: common_sync_pr_created - base_context: ${{ needs.plan.outputs.base_context }} - github_token: ${{ steps.app-token.outputs.token || github.token }} - context: | - { - "common_cache_details": "${{ needs.plan.outputs.common_cache_details }}", - "common_sync_pr_url": "${{ steps.sync-pr.outputs.sync_pr_url }}" - } - - - name: Post Common Sync Failed - if: | - steps.sync-checkout-commonalities.outcome == 'failure' && - (needs.plan.outputs.release_issue_number != '' || steps.sync.outputs.issue_number != '') - uses: ./_tooling/shared-actions/post-bot-comment - with: - issue_number: ${{ needs.plan.outputs.release_issue_number || steps.sync.outputs.issue_number }} - release_tag: ${{ needs.plan.outputs.release_tag }} - run_id: ${{ github.run_id }} - template: common_sync_failed - base_context: ${{ needs.plan.outputs.base_context }} - github_token: ${{ steps.app-token.outputs.token || github.token }} - context: '{}' - - # ───────────────────────────────────────────────────────────────────────────── - # Phase 7: Post final result comment - # ───────────────────────────────────────────────────────────────────────────── - post-result: - needs: - [ - plan, - create-snapshot, - discard-snapshot, - delete-draft, - publish-release, - create-sync-pr, - ] - if: | - always() && - needs.plan.outputs.trigger_type == 'slash_command' && - needs.plan.outputs.allowed == 'true' && - needs.plan.outputs.interim_posted == 'true' - runs-on: ubuntu-latest - steps: - - name: Generate App Token - id: app-token - if: vars.RELEASE_APP_CLIENT_ID != '' - uses: actions/create-github-app-token@v3 - with: - client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - - - name: Checkout tooling - uses: actions/checkout@v7 + - name: Setup Python + uses: actions/setup-python@v7 with: - repository: ${{ job.workflow_repository }} - ref: ${{ job.workflow_sha }} - path: _tooling - sparse-checkout: | - release_automation - shared-actions - tooling_lib - validation - linting/config - - - name: Determine Result Template - id: template - run: | - COMMAND="${{ needs.plan.outputs.command }}" - - # Map command to success/failure templates - case "$COMMAND" in - create-snapshot) - if [ "${{ needs.create-snapshot.outputs.success }}" == "true" ]; then - echo "template=snapshot_created" >> $GITHUB_OUTPUT - else - echo "template=snapshot_failed" >> $GITHUB_OUTPUT - fi - ;; - discard-snapshot) - if [ "${{ needs.discard-snapshot.outputs.success }}" == "true" ]; then - echo "template=snapshot_discarded" >> $GITHUB_OUTPUT - else - echo "template=snapshot_failed" >> $GITHUB_OUTPUT - fi - ;; - delete-draft) - if [ "${{ needs.delete-draft.outputs.success }}" == "true" ]; then - echo "template=draft_revoked" >> $GITHUB_OUTPUT - else - echo "template=snapshot_failed" >> $GITHUB_OUTPUT - fi - ;; - sync-issue) - echo "template=interim_processing" >> $GITHUB_OUTPUT - ;; - publish-release) - if [ "${{ needs.publish-release.outputs.success }}" == "true" ]; then - echo "template=release_published" >> $GITHUB_OUTPUT - else - echo "template=publish_failed" >> $GITHUB_OUTPUT - fi - ;; - *) - # No handler matched - this is a workflow bug - echo "template=internal_error" >> $GITHUB_OUTPUT - ;; - esac - - - name: Resolve Reason - id: reason - env: - COMMAND: ${{ needs.plan.outputs.command }} - COMMAND_ARGS: ${{ needs.plan.outputs.command_args }} - run: | - # Pass user-supplied reason text via env (not via GitHub Actions - # expression interpolation) so backticks, $(), and other shell - # metacharacters in slash-command arguments are treated as opaque - # text, not expanded by bash. - ARGS="$COMMAND_ARGS" - - # Default reason for discard/delete commands when none provided - if [ -z "$ARGS" ] && { [ "$COMMAND" = "discard-snapshot" ] || [ "$COMMAND" = "delete-draft" ]; }; then - ARGS="No reason provided" - fi - - echo "reason=$ARGS" >> $GITHUB_OUTPUT - - - name: Build Result Context - id: context - env: - REASON: ${{ steps.reason.outputs.reason }} - run: | - # Build delta context — pass REASON via env to avoid shell-interpolating - # user-supplied text (same rationale as Resolve Reason above). - CONTEXT=$(jq -n \ - --arg reason "$REASON" \ - '{ - reason: $reason - }') - - # Override release_review_branch with renamed name for discard/delete - if [ "${{ needs.discard-snapshot.outputs.success }}" == "true" ]; then - RRB="${{ needs.discard-snapshot.outputs.renamed_review_branch }}" - if [ -n "$RRB" ]; then - RRB_URL="${{ github.server_url }}/${{ github.repository }}/tree/${RRB}" - CONTEXT=$(echo "$CONTEXT" | jq \ - --arg rrb "$RRB" \ - --arg rrb_url "$RRB_URL" \ - '. + {release_review_branch: $rrb, release_review_branch_url: $rrb_url}') - fi - fi - - if [ "${{ needs.delete-draft.outputs.success }}" == "true" ]; then - RRB="${{ needs.delete-draft.outputs.renamed_review_branch }}" - if [ -n "$RRB" ]; then - RRB_URL="${{ github.server_url }}/${{ github.repository }}/tree/${RRB}" - CONTEXT=$(echo "$CONTEXT" | jq \ - --arg rrb "$RRB" \ - --arg rrb_url "$RRB_URL" \ - '. + {release_review_branch: $rrb, release_review_branch_url: $rrb_url}') - fi - fi - - # Add create-snapshot outputs if that job ran successfully - if [ "${{ needs.create-snapshot.outputs.success }}" == "true" ]; then - # Use env var for apis_json to avoid shell quote issues with embedded JSON - CREATE_APIS_JSON='${{ needs.create-snapshot.outputs.apis_json }}' - # Construct branch URLs - SB="${{ needs.create-snapshot.outputs.snapshot_branch }}" - RRB="${{ needs.create-snapshot.outputs.release_review_branch }}" - SB_URL="${{ github.server_url }}/${{ github.repository }}/tree/${SB}" - RRB_URL="${{ github.server_url }}/${{ github.repository }}/tree/${RRB}" - CONTEXT=$(echo "$CONTEXT" | jq \ - --arg sid "${{ needs.create-snapshot.outputs.snapshot_id }}" \ - --arg sb "$SB" \ - --arg sb_url "$SB_URL" \ - --arg rrb "$RRB" \ - --arg rrb_url "$RRB_URL" \ - --arg prn "${{ needs.create-snapshot.outputs.release_pr_number }}" \ - --arg pru "${{ needs.create-snapshot.outputs.release_pr_url }}" \ - --arg apis "$CREATE_APIS_JSON" \ - '. + {snapshot_id: $sid, snapshot_branch: $sb, snapshot_branch_url: $sb_url, release_review_branch: $rrb, release_review_branch_url: $rrb_url, release_pr_number: $prn, release_pr_url: $pru, apis_json: $apis}') - fi - - # Add publish-release outputs if that job ran - if [ "${{ needs.publish-release.outputs.success }}" == "true" ]; then - # Construct reference tag URL - REF_TAG="${{ needs.publish-release.outputs.reference_tag }}" - REF_TAG_URL="" - if [ -n "$REF_TAG" ]; then - REF_TAG_URL="${{ github.server_url }}/${{ github.repository }}/tree/${REF_TAG}" - fi - # Get short SHA for display - SRC_SHA="${{ needs.plan.outputs.src_commit_sha }}" - SRC_SHA_SHORT="${SRC_SHA:0:7}" - CONTEXT=$(echo "$CONTEXT" | jq \ - --arg release_url "${{ needs.publish-release.outputs.release_url }}" \ - --arg reference_tag "$REF_TAG" \ - --arg reference_tag_url "$REF_TAG_URL" \ - --arg sync_pr_url "${{ needs.create-sync-pr.outputs.sync_pr_url }}" \ - --arg sync_pr_number "${{ needs.create-sync-pr.outputs.sync_pr_number }}" \ - --arg src_commit_sha_short "$SRC_SHA_SHORT" \ - '. + {release_url: $release_url, reference_tag: $reference_tag, reference_tag_url: $reference_tag_url, sync_pr_url: $sync_pr_url, sync_pr_number: $sync_pr_number, src_commit_sha_short: $src_commit_sha_short}') - - # Collect post-publish warnings (ref tag, pointer branch, cleanup, sync PR) - PUBLISH_WARNINGS="" - - # Warnings from publish job (non-critical steps 2-4) - PUBLISH_ERR="${{ needs.publish-release.outputs.error_message }}" - if [ -n "$PUBLISH_ERR" ]; then - PUBLISH_WARNINGS="$PUBLISH_ERR" - fi - - # Warning for sync PR anomaly (differentiated by status) - SYNC_STATUS="${{ needs.create-sync-pr.outputs.sync_status }}" - SYNC_WARNING="" - if [ "$SYNC_STATUS" = "failed" ]; then - SYNC_WARNING="Post-release sync PR creation failed — create manually" - elif [ "$SYNC_STATUS" = "no_changes" ]; then - SYNC_WARNING="Post-release sync skipped (no changes detected) — verify README/CHANGELOG" - elif [ "$SYNC_STATUS" != "created" ]; then - SYNC_WARNING="Post-release sync status unknown — verify README/CHANGELOG" - fi - if [ -n "$SYNC_WARNING" ]; then - if [ -n "$PUBLISH_WARNINGS" ]; then - PUBLISH_WARNINGS="$PUBLISH_WARNINGS; $SYNC_WARNING" - else - PUBLISH_WARNINGS="$SYNC_WARNING" - fi - fi - - if [ -n "$PUBLISH_WARNINGS" ]; then - CONTEXT=$(echo "$CONTEXT" | jq \ - --arg pw "$PUBLISH_WARNINGS" \ - '. + {publish_warnings: $pw}') - fi - fi - - # Add error message for command failure cases - if [ "${{ needs.create-snapshot.outputs.success }}" == "false" ]; then - CONTEXT=$(echo "$CONTEXT" | jq \ - --arg err "${{ needs.create-snapshot.outputs.error_message }}" \ - '. + {error_message: $err}') - elif [ "${{ needs.discard-snapshot.outputs.success }}" == "false" ]; then - CONTEXT=$(echo "$CONTEXT" | jq \ - --arg err "${{ needs.discard-snapshot.outputs.error_message }}" \ - '. + {error_message: $err}') - elif [ "${{ needs.delete-draft.outputs.success }}" == "false" ]; then - CONTEXT=$(echo "$CONTEXT" | jq \ - --arg err "${{ needs.delete-draft.outputs.error_message }}" \ - '. + {error_message: $err}') - elif [ "${{ needs.publish-release.outputs.success }}" == "false" ]; then - CONTEXT=$(echo "$CONTEXT" | jq \ - --arg err "${{ needs.publish-release.outputs.error_message }}" \ - '. + {error_message: $err}') - fi - - - - echo "context<> $GITHUB_OUTPUT - echo "$CONTEXT" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + python-version: ${{ env.PYTHON_VERSION }} - - name: Post Result Comment - uses: ./_tooling/shared-actions/post-bot-comment - with: - issue_number: ${{ needs.plan.outputs.issue_number }} - release_tag: ${{ needs.plan.outputs.release_tag }} - run_id: ${{ github.run_id }} - template: ${{ steps.template.outputs.template }} - # Use base_context + delta - base_context: ${{ needs.plan.outputs.base_context }} - github_token: ${{ steps.app-token.outputs.token || github.token }} - context: ${{ steps.context.outputs.context }} - comment_id: ${{ needs.plan.outputs.comment_id }} + - name: Install dependencies + run: pip install --quiet -r _tooling/requirements.txt - # Update issue state and conditionally close (not in publish-release job) - - name: Update and Close Release Issue - if: | - needs.publish-release.outputs.success == 'true' && - needs.plan.outputs.release_issue_number != '' + - name: Finalize + working-directory: _tooling env: - ISSUE_NUMBER: ${{ needs.plan.outputs.release_issue_number }} - RELEASE_TAG: ${{ needs.plan.outputs.release_tag }} - RELEASE_URL: ${{ needs.publish-release.outputs.release_url }} - REFERENCE_TAG: ${{ needs.publish-release.outputs.reference_tag }} - SYNC_PR_URL: ${{ needs.create-sync-pr.outputs.sync_pr_url }} - PUBLISH_WARNINGS: ${{ needs.publish-release.outputs.error_message }} - SYNC_STATUS: ${{ needs.create-sync-pr.outputs.sync_status }} - uses: actions/github-script@v9 - with: - github-token: ${{ steps.app-token.outputs.token || github.token }} - script: | - const issueNumber = parseInt(process.env.ISSUE_NUMBER); - const releaseTag = process.env.RELEASE_TAG; - const releaseUrl = process.env.RELEASE_URL; - const referenceTag = process.env.REFERENCE_TAG; - const syncPrUrl = process.env.SYNC_PR_URL; - const publishWarnings = process.env.PUBLISH_WARNINGS; - const syncStatus = process.env.SYNC_STATUS; - const repoUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}`; - - console.log(`Updating release issue #${issueNumber} after publish (sync status: ${syncStatus})`); - - try { - // Get current issue - const { data: issue } = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber - }); - - let body = issue.body || ''; - - // Build STATE section content - let stateLines = []; - stateLines.push('**State:** `published`'); - stateLines.push(''); - stateLines.push(`✅ **Release published:** [${releaseTag}](${releaseUrl})`); - stateLines.push(`📌 **Reference tag:** [\`${referenceTag}\`](${repoUrl}/tree/${referenceTag})`); - if (syncPrUrl) { - stateLines.push(`🔄 **Sync PR:** [View PR](${syncPrUrl})`); - } - - // Collect warnings from non-critical post-publish steps - let warnings = []; - if (publishWarnings) { - warnings.push(publishWarnings); - } - if (syncStatus === 'failed') { - warnings.push('Post-release sync PR creation failed — create manually'); - } else if (syncStatus === 'no_changes') { - warnings.push('Post-release sync skipped (no changes detected) — verify README/CHANGELOG'); - } else if (syncStatus !== 'created') { - warnings.push('Post-release sync status unknown — verify README/CHANGELOG'); - } - if (warnings.length > 0) { - stateLines.push(`⚠️ **Warnings:** ${warnings.join('; ')}`); - } - const stateContent = stateLines.join('\n'); - - // Replace STATE section - const stateStartMarker = ''; - const stateEndMarker = ''; - const stateStartIdx = body.indexOf(stateStartMarker); - const stateEndIdx = body.indexOf(stateEndMarker); - if (stateStartIdx !== -1 && stateEndIdx !== -1) { - body = body.substring(0, stateStartIdx + stateStartMarker.length) + - '\n' + stateContent + '\n' + - body.substring(stateEndIdx); - } - - // Update ACTIONS section — conditional on sync status - let actionsContent; - if (syncStatus === 'created') { - actionsContent = '_No actions available - release is published._'; - } else if (syncStatus === 'failed') { - actionsContent = '**Manual action required:** Create post-release sync PR manually (update README and CHANGELOG on main), then close this issue.'; - } else if (syncStatus === 'no_changes') { - actionsContent = '**Manual action required:** Verify README and CHANGELOG on main are correct, then close this issue.'; - } else { - actionsContent = '**Manual action required:** Verify post-release sync status, then close this issue.'; - } - const actionsStartMarker = ''; - const actionsEndMarker = ''; - const actionsStartIdx = body.indexOf(actionsStartMarker); - const actionsEndIdx = body.indexOf(actionsEndMarker); - if (actionsStartIdx !== -1 && actionsEndIdx !== -1) { - body = body.substring(0, actionsStartIdx + actionsStartMarker.length) + - '\n' + actionsContent + '\n' + - body.substring(actionsEndIdx); - } - - // Update issue body - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: body - }); - - // Update labels - remove old state labels, add published - const currentLabels = issue.labels.map(l => l.name); - const oldStateLabels = currentLabels.filter(l => l.startsWith('release-state:')); - for (const label of oldStateLabels) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: label - }); - } catch (e) { /* ignore */ } - } - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: ['release-state:published'] - }); - - // Close the issue only if sync PR was created successfully - if (syncStatus === 'created') { - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - state: 'closed', - state_reason: 'completed' - }); - console.log(`Issue #${issueNumber} closed successfully`); - } else { - console.log(`Issue #${issueNumber} left open — sync status: ${syncStatus}`); - } - } catch (error) { - console.log(`::warning::Failed to close issue: ${error.message}`); - // Non-fatal - the release was already published - } + NEEDS_JSON: ${{ toJSON(needs) }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + APP_TOKEN_USED: ${{ steps.app-token.outputs.token != '' }} + RELEASE_APP_SLUG: ${{ vars.RELEASE_APP_SLUG }} + run: python -m release_automation.scripts.finalize diff --git a/release_automation/docs/technical-architecture.md b/release_automation/docs/technical-architecture.md index 3ed6bb65..e5805bb5 100644 --- a/release_automation/docs/technical-architecture.md +++ b/release_automation/docs/technical-architecture.md @@ -103,9 +103,6 @@ tooling/ │ ├── post-bot-comment/ # Create/update bot comments with templates │ │ └── action.yml │ │ -│ ├── sync-release-issue/ # Issue lifecycle sync -│ │ └── action.yml -│ │ │ ├── update-issue-section/ # Update reserved sections in issue body │ │ └── action.yml │ │ @@ -689,13 +686,11 @@ Updates a reserved section in the issue description between `" match = re.search(pattern, content) return match.group(1) if match else None + + +def merge_comment_context(base_context: Any, delta: Dict[str, Any]) -> Dict[str, Any]: + """ + Base context updated by delta, with apis_json (JSON string or list) turned + into apis, completed to the full schema by build_context(). + """ + raw = dict(base_context) if isinstance(base_context, dict) else {} + raw.update(delta) + if "apis_json" in raw: + apis = raw.pop("apis_json") + if isinstance(apis, str): + try: + apis = json.loads(apis) + except (json.JSONDecodeError, TypeError): + apis = [] + raw["apis"] = apis if isinstance(apis, list) else [] + return build_context(**raw) diff --git a/release_automation/scripts/command_policy.py b/release_automation/scripts/command_policy.py index 7e024b3d..39b0b376 100644 --- a/release_automation/scripts/command_policy.py +++ b/release_automation/scripts/command_policy.py @@ -275,6 +275,11 @@ def reject(message: str) -> Rejection: return replace(plan, response=RESPONSE_INTERIM) +def finalize_due(result: Union[Plan, Rejection]) -> bool: + """Whether the finalize job runs: a Plan whose command runs, not a rejection or confirmation request.""" + return result.allowed and result.response != RESPONSE_PUBLISH_CONFIRMATION + + def release_info_from_env(env) -> ReleaseInfoResult: """Rebuild a ReleaseInfoResult from the derive-release-state outputs.""" if env.get("CONFIG_ERROR"): @@ -353,6 +358,7 @@ def main(client=None) -> int: "allowed": ("true" if result.allowed else "false") if validated else "", "error_message": result.error_message if validated and not result.allowed else "", "plan": result.to_json(), + "finalize": "true" if finalize_due(result) else "false", "response": result.response, "response_release_tag": ( "unknown" if result.response == RESPONSE_CONFIG_ERROR else (release_info.release_tag or "") diff --git a/release_automation/scripts/finalize.py b/release_automation/scripts/finalize.py new file mode 100644 index 00000000..a0272f0e --- /dev/null +++ b/release_automation/scripts/finalize.py @@ -0,0 +1,451 @@ +""" +Finalize phase of CAMARA release automation. + +Runs after the action jobs, also when one of them failed, and is the only job +after plan for push, workflow_dispatch and /sync-issue. In order: + +1. post-release sync PR, after a publication (sync_pr) +2. common sync when the cache is stale, then the Release Issue sync and its + notifications (common_sync, issue_sync) +3. the result comment on a slash command, updating its acknowledgment +4. after a publication: the published Release Issue — STATE/ACTIONS, label, + closed only when the sync PR was created (issue_sync) + +What ran before comes from toJSON(needs): the plan job's outputs and each +action job's result and outputs. + +Usage (workflow step): + python3 -m release_automation.scripts.finalize +""" + +import json +import os +import sys +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from .common_sync import CommonSyncOutcome +from .issue_sync import WorkflowSync, post_publish_warnings +from .sync_pr import SyncPrOutcome + +COMMONALITIES_REPO = "camaraproject/Commonalities" + +# A job whose success counts only together with its "success" output +COMMAND_JOBS = ("create-snapshot", "discard-snapshot", "delete-draft", "handle-pr-merge") + +# Template of the result comment per command; the second is the failure template +RESULT_TEMPLATES = { + "create-snapshot": ("create-snapshot", "snapshot_created", "snapshot_failed"), + "discard-snapshot": ("discard-snapshot", "snapshot_discarded", "snapshot_failed"), + "delete-draft": ("delete-draft", "draft_revoked", "snapshot_failed"), + "publish-release": ("publish-release", "release_published", "publish_failed"), +} + + +@dataclass +class Run: + """What finalize knows about the run: needs, the event payload, repository.""" + needs: Dict[str, Any] + event: Dict[str, Any] = field(default_factory=dict) + server_url: str = "https://github.com" + repository: str = "" + run_id: str = "" + + def out(self, job: str, name: str) -> str: + return str((self.needs.get(job) or {}).get("outputs", {}).get(name, "") or "") + + def result(self, job: str) -> str: + return (self.needs.get(job) or {}).get("result", "") + + def plan(self, name: str) -> str: + return self.out("plan", name) + + def succeeded(self, job: str) -> bool: + """Job concluded success and reported success (a command job).""" + return self.result(job) == "success" and self.out(job, "success") == "true" + + def tree_url(self, ref: str) -> str: + return f"{self.server_url}/{self.repository}/tree/{ref}" + + @property + def published(self) -> bool: + return self.out("publish-release", "success") == "true" + + +# ── when each part runs ────────────────────────────────────────────────────── + + +def issue_sync_due(run: Run) -> bool: + """Release Issue sync (and common sync): a successful command, an issue event, or a sync.""" + if run.plan("config_error") or not run.plan("state"): + return False + return ( + any(run.succeeded(job) for job in COMMAND_JOBS) + or run.result("handle-issue-event") == "success" + or run.plan("command") == "sync-issue" + ) + + +def result_due(run: Run) -> bool: + """Result comment: an allowed slash command whose acknowledgment was updated by plan.""" + return ( + run.plan("trigger_type") == "slash_command" + and run.plan("allowed") == "true" + and run.plan("interim_posted") == "true" + ) + + +# ── Release Issue sync ─────────────────────────────────────────────────────── + + +def sync_overrides(run: Run) -> Dict[str, Any]: + """State and artifacts the run's command produced, which the issue sync must not re-derive.""" + overrides = { + "state_override": "", + "snapshot_branch_override": "", + "release_pr_number_override": "", + "draft_release_url_override": "", + "force_update": False, + } + if run.succeeded("create-snapshot"): + overrides.update( + state_override="snapshot-active", + snapshot_branch_override=run.out("create-snapshot", "snapshot_branch"), + release_pr_number_override=run.out("create-snapshot", "release_pr_number"), + force_update=True, + ) + elif run.succeeded("handle-pr-merge"): + pr_number = (run.event.get("pull_request") or {}).get("number", "") + overrides.update( + state_override="draft-ready", + snapshot_branch_override=run.plan("snapshot_branch"), + release_pr_number_override=str(pr_number), + draft_release_url_override=run.out("handle-pr-merge", "draft_release_url"), + force_update=True, + ) + elif run.succeeded("discard-snapshot") or run.succeeded("delete-draft"): + overrides.update(state_override="planned", force_update=True) + elif run.plan("command") == "sync-issue": + overrides.update(force_update=True) + return overrides + + +Notification = Tuple[str, str, Dict[str, str]] + + +def notifications(run: Run, sync: WorkflowSync, common: Optional[CommonSyncOutcome]) -> List[Notification]: + """Comments after the issue sync, as (template, issue number, context delta).""" + posts = [] + if sync.issue_action == "updated" and run.plan("state") == "not-planned" and sync.issue_number: + posts.append(("state_not_planned", sync.issue_number, {"state": "not-planned"})) + if sync.issue_action == "created" and sync.issue_number: + closed = run.event.get("issue") or {} + posts.append(("issue_created", sync.issue_number, { + "state": "planned", + "trigger_type": run.plan("trigger_type"), + "release_plan_url": f"{run.server_url}/{run.repository}/blob/main/release-plan.yaml", + "closed_issue_number": str(closed.get("number", "")), + "closed_issue_url": closed.get("html_url", ""), + })) + issue = run.plan("release_issue_number") or sync.issue_number + if common and issue: + if common.status == "created": + posts.append(("common_sync_pr_created", issue, { + "common_cache_details": run.plan("common_cache_details"), + "common_sync_pr_url": common.pr_url, + })) + elif common.status == "failed": + posts.append(("common_sync_failed", issue, {})) + return posts + + +# ── result comment ─────────────────────────────────────────────────────────── + + +def result_template(run: Run) -> str: + command = run.plan("command") + if command == "sync-issue": + return "interim_processing" + if command not in RESULT_TEMPLATES: + # No handler matched: a workflow bug + return "internal_error" + job, success, failure = RESULT_TEMPLATES[command] + return success if run.out(job, "success") == "true" else failure + + +def result_context(run: Run, sync: Optional[SyncPrOutcome]) -> Dict[str, str]: + """Context delta of the result comment over the plan's base context.""" + command = run.plan("command") + reason = run.plan("command_args") + if not reason and command in ("discard-snapshot", "delete-draft"): + reason = "No reason provided" + context = {"reason": reason} + + for job in ("discard-snapshot", "delete-draft"): + renamed = run.out(job, "renamed_review_branch") + if run.out(job, "success") == "true" and renamed: + context.update(release_review_branch=renamed, release_review_branch_url=run.tree_url(renamed)) + + if run.out("create-snapshot", "success") == "true": + snapshot_branch = run.out("create-snapshot", "snapshot_branch") + review_branch = run.out("create-snapshot", "release_review_branch") + context.update( + snapshot_id=run.out("create-snapshot", "snapshot_id"), + snapshot_branch=snapshot_branch, + snapshot_branch_url=run.tree_url(snapshot_branch), + release_review_branch=review_branch, + release_review_branch_url=run.tree_url(review_branch), + release_pr_number=run.out("create-snapshot", "release_pr_number"), + release_pr_url=run.out("create-snapshot", "release_pr_url"), + apis_json=run.out("create-snapshot", "apis_json"), + ) + + if run.published: + reference_tag = run.out("publish-release", "reference_tag") + context.update( + release_url=run.out("publish-release", "release_url"), + reference_tag=reference_tag, + reference_tag_url=run.tree_url(reference_tag) if reference_tag else "", + sync_pr_url=sync.pr_url if sync else "", + sync_pr_number=sync.pr_number if sync else "", + src_commit_sha_short=run.plan("src_commit_sha")[:7], + ) + warnings = post_publish_warnings( + run.out("publish-release", "error_message"), sync.status if sync else "" + ) + if warnings: + context["publish_warnings"] = warnings + + for job in ("create-snapshot", "discard-snapshot", "delete-draft", "publish-release"): + if run.out(job, "success") == "false": + context["error_message"] = run.out(job, "error_message") + break + return context + + +# ── orchestration ──────────────────────────────────────────────────────────── + + +@dataclass +class Clients: + """Network-facing collaborators; tests replace them.""" + gh: Any + git_factory: Any # (repo, work_dir, token) -> git client + token: str = "" + app_slug: str = "" + app_token_used: bool = False + work_root: Path = field(default_factory=lambda: Path(tempfile.mkdtemp())) + _bot: Optional[Tuple[str, str]] = None + + def git(self, repo: str, name: str, token: Optional[str] = None): + return self.git_factory(repo, str(self.work_root / name), token) + + def bot_identity(self) -> Tuple[str, str]: + """Commit identity: the App bot when an App token is in use, else github-actions[bot].""" + if self._bot is None: + login = f"{self.app_slug}[bot]" if self.app_slug and self.app_token_used else "github-actions[bot]" + user = self.gh.get_user(login) + name = user["login"] + self._bot = (name, f"{user['id']}+{name}@users.noreply.github.com") + return self._bot + + +@dataclass +class Report: + """What finalize did, for the log, the step summary and the exit code.""" + sync_pr: Optional[SyncPrOutcome] = None + common_sync: Optional[CommonSyncOutcome] = None + issue_sync: Optional[WorkflowSync] = None + result_template: str = "" + issue_finalized: Optional[bool] = None + errors: List[str] = field(default_factory=list) + + +def post_comment(clients: Clients, run: Run, template: str, issue: str, delta: Dict[str, Any], comment_id: str = "") -> None: + from .bot_responder import BotResponder + + try: + base_context = json.loads(run.plan("base_context") or "{}") + except json.JSONDecodeError: + print("::warning::Invalid base_context JSON, ignoring") + base_context = {} + body = BotResponder().render_comment(template, base_context, delta, run.plan("release_tag")) + if comment_id: + clients.gh.update_comment(int(comment_id), body) + print(f"Updated comment {comment_id} ({template})") + else: + new_id = clients.gh.create_comment(int(issue), body) + print(f"Created comment {new_id} on #{issue} ({template})") + + +def run_sync_pr(run: Run, clients: Clients) -> SyncPrOutcome: + from .sync_pr import create_sync_pr + + try: + bot_name, bot_email = clients.bot_identity() + except Exception as e: + print(f"::error::Could not resolve the bot identity: {e}") + return SyncPrOutcome(status="failed") + git = clients.git(run.repository, "api-repo", clients.token) + return create_sync_pr(clients.gh, git, run.plan("release_tag"), bot_name, bot_email) + + +def run_common_sync(run: Run, clients: Clients) -> CommonSyncOutcome: + from .common_sync import sync_common + + try: + bot_name, bot_email = clients.bot_identity() + except Exception as e: + print(f"::error::Could not resolve the bot identity: {e}") + return CommonSyncOutcome(status="failed") + return sync_common( + clients.git(run.repository, "common-sync-api", clients.token), + # Public repository; the App token is scoped to the API repository + clients.git(COMMONALITIES_REPO, "commonalities"), + clients.gh, + run.plan("commonalities_release"), + bot_name, + bot_email, + ) + + +def run_issue_sync(run: Run, clients: Clients, report: Report, summary_path: str) -> None: + from .issue_sync import sync_for_workflow + + if run.plan("common_cache_status") == "stale": + report.common_sync = run_common_sync(run, clients) + print(f"Common sync: {report.common_sync.status}") + + sync = sync_for_workflow( + clients.gh, + release_tag=run.plan("release_tag"), + state=run.plan("state"), + common_cache_status=run.plan("common_cache_status"), + common_cache_details=run.plan("common_cache_details"), + common_sync_pr_url=(report.common_sync.pr_url if report.common_sync else "") or run.plan("common_sync_pr_url"), + summary_path=summary_path, + **sync_overrides(run), + ) + report.issue_sync = sync + print("Issue sync result:") + print(f" Action: {sync.issue_action}") + print(f" Issue: #{sync.issue_number}") + print(f" URL: {sync.issue_url}") + if sync.issue_action == "error": + # As before: a failed issue sync posts no notifications and fails the job. + report.errors.append("Release Issue sync failed") + return + + for template, issue, delta in notifications(run, sync, report.common_sync): + try: + post_comment(clients, run, template, issue, delta) + except Exception as e: + print(f"::error::Could not post {template}: {e}") + report.errors.append(f"{template} comment failed") + return + + +def run_result(run: Run, clients: Clients, report: Report) -> None: + from .issue_manager import IssueManager + from .issue_sync import IssueSyncManager + + report.result_template = result_template(run) + try: + post_comment( + clients, run, report.result_template, run.plan("issue_number"), + result_context(run, report.sync_pr), comment_id=run.plan("comment_id"), + ) + except Exception as e: + # As before: without the result comment the issue is not finalised. + print(f"::error::Could not post the result comment: {e}") + report.errors.append("result comment failed") + return + + if run.published and run.plan("release_issue_number"): + sync = report.sync_pr or SyncPrOutcome(status="") + print(f"Updating release issue #{run.plan('release_issue_number')} after publish (sync status: {sync.status})") + report.issue_finalized = IssueSyncManager(clients.gh, issue_manager=IssueManager()).finalize_published_issue( + issue_number=int(run.plan("release_issue_number")), + release_tag=run.plan("release_tag"), + release_url=run.out("publish-release", "release_url"), + reference_tag=run.out("publish-release", "reference_tag"), + sync_pr_url=sync.pr_url, + sync_status=sync.status, + publish_warnings=run.out("publish-release", "error_message"), + ) + + +def finalize(run: Run, clients: Clients, summary_path: str = "") -> Report: + report = Report() + if run.published: + report.sync_pr = run_sync_pr(run, clients) + print(f"Post-release sync PR: {report.sync_pr.status} {report.sync_pr.pr_url}") + if issue_sync_due(run): + run_issue_sync(run, clients, report, summary_path) + if result_due(run): + run_result(run, clients, report) + write_summary(run, report, summary_path) + return report + + +def write_summary(run: Run, report: Report, summary_path: str) -> None: + """Step summary of the finalize job: what it changed and where.""" + if not summary_path: + return + rows = [ + ("Trigger", f"{run.plan('trigger_type')} {run.plan('command')}".strip()), + ("Release", f"{run.plan('release_tag')} ({run.plan('state') or 'no state'})"), + ] + if report.sync_pr: + rows.append(("Post-release sync PR", f"{report.sync_pr.status} {report.sync_pr.pr_url}".strip())) + if report.common_sync: + rows.append(("Common sync", f"{report.common_sync.status} {report.common_sync.pr_url}".strip())) + if report.issue_sync: + rows.append(("Release Issue", f"{report.issue_sync.issue_action} {report.issue_sync.issue_url}".strip())) + if report.result_template: + rows.append(("Result comment", report.result_template)) + if report.issue_finalized is not None: + rows.append(("Published issue", "updated" if report.issue_finalized else "update failed (see log)")) + with open(summary_path, "a", encoding="utf-8") as f: + f.write("## Release Automation: finalize\n\n| | |\n|---|---|\n") + for name, value in rows: + f.write(f"| {name} | {value} |\n") + if report.errors: + f.write(f"\nErrors: {'; '.join(report.errors)}\n") + + +def main(clients: Optional[Clients] = None) -> int: + env = os.environ + with open(env["GITHUB_EVENT_PATH"], encoding="utf-8") as f: + event = json.load(f) + run = Run( + needs=json.loads(env["NEEDS_JSON"]), + event=event, + server_url=env.get("GITHUB_SERVER_URL", "https://github.com"), + repository=env["GITHUB_REPOSITORY"], + run_id=env.get("GITHUB_RUN_ID", ""), + ) + if clients is None: + from .git_operations import GitOperations + from .github_client import GitHubClient + + token = env.get("GITHUB_TOKEN", "") + clients = Clients( + gh=GitHubClient(repo=run.repository, token=token or None), + git_factory=lambda repo, work_dir, tok: GitOperations(repo=repo, work_dir=work_dir, token=tok), + token=token, + app_slug=env.get("RELEASE_APP_SLUG", ""), + app_token_used=env.get("APP_TOKEN_USED") == "true", + work_root=Path(env.get("RUNNER_TEMP") or tempfile.mkdtemp()), + ) + + report = finalize(run, clients, env.get("GITHUB_STEP_SUMMARY", "")) + for error in report.errors: + print(f"::error::{error}") + return 1 if report.errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/release_automation/scripts/github_client.py b/release_automation/scripts/github_client.py index d4f5816d..6003f117 100644 --- a/release_automation/scripts/github_client.py +++ b/release_automation/scripts/github_client.py @@ -930,6 +930,38 @@ def add_issue_comment(self, issue_number: int, body: str) -> None: "-f", f"body={body}", ]) + def create_comment(self, issue_number: int, body: str) -> int: + """ + Add a comment to an issue or PR. + + Returns: + ID of the new comment + + Raises: + GitHubClientError: If the operation fails + """ + output = self._run_gh([ + "api", + f"repos/{self.repo}/issues/{issue_number}/comments", + "-X", "POST", + "-f", f"body={body}", + ]) + return json.loads(output)["id"] + + def update_comment(self, comment_id: int, body: str) -> None: + """ + Replace the body of an issue comment. + + Raises: + GitHubClientError: If the operation fails + """ + self._run_gh([ + "api", + f"repos/{self.repo}/issues/comments/{comment_id}", + "-X", "PATCH", + "-f", f"body={body}", + ]) + def set_labels(self, issue_number: int, labels: List[str]) -> None: """ Replace all labels on an issue with the specified labels. diff --git a/release_automation/scripts/issue_manager.py b/release_automation/scripts/issue_manager.py index ee4e9c76..6e5f8392 100644 --- a/release_automation/scripts/issue_manager.py +++ b/release_automation/scripts/issue_manager.py @@ -346,7 +346,9 @@ def generate_published_state_section( release_tag: str, release_url: str, reference_tag: str, - sync_pr_url: Optional[str] = None + repo: str, + sync_pr_url: str = "", + warnings: str = "", ) -> str: """ Generate content for the STATE section in published state. @@ -354,31 +356,46 @@ def generate_published_state_section( Args: release_tag: Release tag (e.g., "r4.1") release_url: URL to the published release - reference_tag: Reference tag (e.g., "source/r4.1") - sync_pr_url: Optional URL to the post-release sync PR + reference_tag: Reference tag (e.g., "src/r4.1"); the line stays when empty + repo: Repository "owner/name" for the reference tag link + sync_pr_url: URL of the post-release sync PR, if one was created + warnings: "; "-joined post-publish warnings (post_publish_warnings()) Returns: Formatted state section content """ - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - lines = [ - f"**State:** `published` | **Last Updated:** {timestamp}", + "**State:** `published`", "", - f"**Release:** [{release_tag}]({release_url})", - f"**Reference tag:** `{reference_tag}`", + f"✅ **Release published:** [{release_tag}]({release_url})", + f"📌 **Reference tag:** [`{reference_tag}`](https://github.com/{repo}/tree/{reference_tag})", ] - if sync_pr_url: - lines.append(f"**Sync PR:** {sync_pr_url}") - + lines.append(f"🔄 **Sync PR:** [View PR]({sync_pr_url})") + if warnings: + lines.append(f"⚠️ **Warnings:** {warnings}") return "\n".join(lines) - def generate_published_actions_section(self) -> str: + def generate_published_actions_section(self, sync_status: str) -> str: """ Generate content for the ACTIONS section in published state. + Args: + sync_status: Post-release sync status (created | failed | no_changes | other) + Returns: - Formatted actions section content (no actions available) + No-actions text when the sync PR was created, manual follow-up otherwise """ - return "**Valid actions:** No further actions available — release is published" + if sync_status == "created": + return "_No actions available - release is published._" + if sync_status == "failed": + return ( + "**Manual action required:** Create post-release sync PR manually " + "(update README and CHANGELOG on main), then close this issue." + ) + if sync_status == "no_changes": + return ( + "**Manual action required:** Verify README and CHANGELOG on main are correct, " + "then close this issue." + ) + return "**Manual action required:** Verify post-release sync status, then close this issue." diff --git a/release_automation/scripts/issue_sync.py b/release_automation/scripts/issue_sync.py index 0898f106..1914b2c7 100644 --- a/release_automation/scripts/issue_sync.py +++ b/release_automation/scripts/issue_sync.py @@ -8,8 +8,11 @@ from dataclasses import dataclass import re +import traceback from typing import Any, Dict, List, Optional +import yaml + from .github_client import GitHubClient from .state_manager import ReleaseState, ReleaseStateManager from .issue_manager import IssueManager @@ -79,9 +82,9 @@ def __init__( Args: github_client: GitHubClient for repository operations - state_manager: ReleaseStateManager for state derivation (optional for close_release_issue) - issue_manager: IssueManager for issue content management (optional for close_release_issue) - bot_responder: BotResponder for message templates (optional for close_release_issue) + state_manager: ReleaseStateManager for state derivation (optional for finalize_published_issue) + issue_manager: IssueManager for issue content management + bot_responder: BotResponder for message templates (optional for finalize_published_issue) """ self.gh = github_client self.state_manager = state_manager @@ -624,83 +627,243 @@ def get_state_label(self, state: ReleaseState) -> str: """ return f"{self.STATE_LABEL_PREFIX}{state.value.replace('_', '-')}" - def close_release_issue( + def finalize_published_issue( self, issue_number: int, release_tag: str, release_url: str, reference_tag: str, - sync_pr_url: str = "" + sync_pr_url: str, + sync_status: str, + publish_warnings: str = "", ) -> bool: """ - Update and close Release Issue after publication. + Mark the Release Issue published; close it only if the sync PR was created. Steps: - 1. Update STATE section with publication info - 2. Update ACTIONS section (no actions available) - 3. Change label to release-state:published - 4. Close issue with reason "completed" + 1. STATE section: release, reference tag, sync PR, post-publish warnings + 2. ACTIONS section: no actions when sync_status is "created", the + manual follow-up for that status otherwise + 3. State label to release-state:published + 4. Close with reason "completed" only when sync_status is "created" Args: - issue_number: Issue number to close + issue_number: Release Issue number release_tag: Release tag (e.g., "r4.1") release_url: URL to the published release - reference_tag: Reference tag (e.g., "source/r4.1") - sync_pr_url: Optional URL to the post-release sync PR + reference_tag: Reference tag (e.g., "src/r4.1"), "" if its creation failed + sync_pr_url: URL of the post-release sync PR, "" if none + sync_status: Post-release sync status (created | failed | no_changes | other) + publish_warnings: Warnings of the non-critical publish steps Returns: - True if successful, False on error + True if every step succeeded, False on an API error (logged, never + raised: the release is already published) """ try: - # Get current issue issue = self.gh.get_issue(issue_number) - current_body = issue.get("body", "") - - # Update STATE section - new_state_content = self.issue_manager.generate_published_state_section( - release_tag=release_tag, - release_url=release_url, - reference_tag=reference_tag, - sync_pr_url=sync_pr_url or None - ) - updated_body = self.issue_manager.update_section( - current_body, "STATE", new_state_content + body = issue.get("body", "") or "" + body = self.issue_manager.update_section( + body, + "STATE", + self.issue_manager.generate_published_state_section( + release_tag=release_tag, + release_url=release_url, + reference_tag=reference_tag, + repo=self.gh.repo, + sync_pr_url=sync_pr_url, + warnings=post_publish_warnings(publish_warnings, sync_status), + ), ) - - # Update ACTIONS section - new_actions_content = self.issue_manager.generate_published_actions_section() - updated_body = self.issue_manager.update_section( - updated_body, "ACTIONS", new_actions_content + body = self.issue_manager.update_section( + body, "ACTIONS", self.issue_manager.generate_published_actions_section(sync_status) ) + self.gh.update_issue(issue_number, body=body) - # Update issue body if changed - if updated_body != current_body: - self.gh.update_issue(issue_number, body=updated_body) - - # Update labels: remove old state labels, add published current_labels = [ label.get("name", "") if isinstance(label, dict) else label for label in issue.get("labels", []) ] - old_state_labels = [ - l for l in current_labels - if l.startswith(self.STATE_LABEL_PREFIX) - ] + old_state_labels = [l for l in current_labels if l.startswith(self.STATE_LABEL_PREFIX)] if old_state_labels: self.gh.remove_labels(issue_number, old_state_labels) + self.gh.add_labels(issue_number, [f"{self.STATE_LABEL_PREFIX}published"]) - new_state_label = f"{self.STATE_LABEL_PREFIX}published" - self.gh.add_labels(issue_number, [new_state_label]) - - # Close the issue - self.gh.close_issue(issue_number, state_reason="completed") - + if sync_status == "created": + self.gh.close_issue(issue_number, state_reason="completed") + print(f"Issue #{issue_number} closed successfully") + else: + print(f"Issue #{issue_number} left open — sync status: {sync_status}") return True - except Exception as e: - # Log error but don't raise - issue closure is non-critical - import logging - logging.getLogger(__name__).error( - f"Failed to close release issue #{issue_number}: {e}" - ) + print(f"::warning::Failed to close issue: {e}") return False + + +def post_publish_warnings(publish_warnings: str, sync_status: str) -> str: + """ + Warnings of a publication: the non-critical publish steps, then the sync PR status. + + Returns: + "; "-joined text, "" when there is nothing to warn about + """ + warnings = [publish_warnings] if publish_warnings else [] + if sync_status == "failed": + warnings.append("Post-release sync PR creation failed — create manually") + elif sync_status == "no_changes": + warnings.append("Post-release sync skipped (no changes detected) — verify README/CHANGELOG") + elif sync_status != "created": + warnings.append("Post-release sync status unknown — verify README/CHANGELOG") + return "; ".join(warnings) + + +@dataclass(frozen=True) +class WorkflowSync: + """Release Issue sync as the workflow sees it; issue_action is + created | updated | unchanged | skipped | error.""" + issue_number: str = "" + issue_action: str = "skipped" + issue_url: str = "" + + +def parse_state(value: str) -> Optional[ReleaseState]: + """ReleaseState for a value or name in any case, - or _; None for "".""" + normalized = value.strip().lower().replace("-", "_") + if not normalized: + return None + for state in ReleaseState: + if normalized in {state.value.lower().replace("-", "_"), state.name.lower()}: + return state + raise ValueError(f"Unknown release state override: {value}") + + +def _summary(path: str, text: str) -> None: + if path: + with open(path, "a", encoding="utf-8") as f: + f.write(text) + + +def sync_for_workflow( + gh: GitHubClient, + release_tag: str, + state: str, + state_override: str = "", + snapshot_branch_override: str = "", + release_pr_number_override: str = "", + draft_release_url_override: str = "", + force_update: bool = False, + common_cache_status: str = "", + common_cache_details: str = "", + common_sync_pr_url: str = "", + summary_path: str = "", +) -> WorkflowSync: + """ + Sync the Release Issue for a workflow run (create, update, or leave it). + + Args: + gh: GitHub client of the API repository + release_tag: Release tag from the plan + state: State derived by the plan job + state_override: State the run's command moved the release to ("" for none) + snapshot_branch_override, release_pr_number_override, + draft_release_url_override: Artifacts the run's command created + force_update: Refresh the issue even if its state label matches + common_cache_status, common_cache_details, common_sync_pr_url: Cache + detection and common sync PR, shown in the CONFIG section + summary_path: GITHUB_STEP_SUMMARY ("" to skip) + + Returns: + WorkflowSync; issue_action "error" when the sync failed (logged) + """ + try: + override = parse_state(state_override) + except ValueError as e: + print(f"::error::{e}") + return WorkflowSync(issue_action="error") + + requested_state = state_override or state + print("Effective sync context:") + print(f" Source: {'override' if override else 're-derived'}") + print(f" Requested state: {requested_state or '(none)'}") + print(f" Snapshot branch override: {snapshot_branch_override or '(none)'}") + print(f" Release PR override: {release_pr_number_override or '(none)'}") + print(f" Draft release URL override present: {'yes' if draft_release_url_override else 'no'}") + print(f" Force update: {force_update}") + + if not release_tag: + print("No release tag provided - skipping issue sync") + return WorkflowSync() + + if requested_state.lower() in ("published", ""): + print(f"State '{requested_state}' does not require issue sync - skipping") + if requested_state.lower() == "published": + _summary(summary_path, ( + "## Release Status: Published\n\n" + f"Release `{release_tag}` has already been published. No further actions needed.\n" + )) + else: + _summary(summary_path, ( + "## Release Status\n\n" + f"No release state detected for `{release_tag}`. Check release-plan.yaml configuration.\n" + )) + return WorkflowSync() + + print(f"Reading release-plan.yaml for {release_tag}") + release_plan = gh.get_file_content("release-plan.yaml", ref="main") + if release_plan is None: + print("::warning::Could not read release-plan.yaml - skipping issue sync") + return WorkflowSync() + try: + release_plan_data = yaml.safe_load(release_plan) + except yaml.YAMLError as e: + print(f"::warning::Could not parse release-plan.yaml: {e}") + return WorkflowSync() + if not isinstance(release_plan_data, dict): + print("::warning::release-plan.yaml is not a valid YAML mapping") + return WorkflowSync() + + plan_tag = (release_plan_data.get("repository") or {}).get("target_release_tag", "") + if plan_tag != release_tag: + print(f"::warning::release-plan.yaml tag '{plan_tag}' doesn't match input '{release_tag}'") + + sync_manager = IssueSyncManager( + github_client=gh, + state_manager=ReleaseStateManager(github_client=gh), + issue_manager=IssueManager(), + bot_responder=BotResponder(), + ) + print(f"Syncing Release Issue for {release_tag} (state: {requested_state})") + try: + result = sync_manager.sync_release_issue( + release_plan_data, + state_override=override, + snapshot_branch_override=snapshot_branch_override or None, + release_pr_number_override=release_pr_number_override or None, + draft_release_url_override=draft_release_url_override or None, + force_update=force_update, + common_cache_status=common_cache_status, + common_cache_details=common_cache_details, + common_sync_pr_url=common_sync_pr_url, + ) + except Exception as e: + print(f"::error::Issue sync failed: {e}") + traceback.print_exc() + return WorkflowSync(issue_action="error") + + if result.action == "none": + print(f"No action taken: {result.reason}") + if result.reason == "no_planned_release": + _summary(summary_path, ( + "## Release Status: Not Planned\n\n" + f"No release is currently planned for `{release_tag}` (`target_release_type: none`).\n" + "No open Release Issue exists.\n" + )) + return WorkflowSync(issue_action="unchanged") + if result.issue: + number = result.issue.get("number", "") + url = result.issue.get("html_url", "") + print(f"Issue {result.action}: #{number}") + print(f" URL: {url}") + return WorkflowSync(issue_number=str(number), issue_action=result.action, issue_url=url) + print(f"Action {result.action} but no issue returned") + return WorkflowSync(issue_action=result.action) diff --git a/release_automation/scripts/regression_runner.py b/release_automation/scripts/regression_runner.py index 936f4ad5..1a8d089a 100644 --- a/release_automation/scripts/regression_runner.py +++ b/release_automation/scripts/regression_runner.py @@ -795,7 +795,7 @@ def _fire_command_phase( 2. The caller run reached `completed` with `conclusion == 'success'`; 3. A bot comment appeared on the Release Issue after we posted, and its first non-blank line starts with `expected_title`; - 4. With *require_sync_job*, the run's sync-issue job concluded success. + 4. With *require_sync_job*, the run's finalize job concluded success. Rejection (e.g. cache-stale, permission-denied) surfaces as the caller bot posting `command_rejected` as its final reply — case (3) fails with @@ -885,13 +885,13 @@ def _fire_command_phase( return report, run_id -# Job name of the reusable workflow's update-issue job, which runs the +# Job name of the reusable workflow's finalize job, which runs the # Release Issue sync. Source of truth: .github/workflows/release-automation-reusable.yml -_SYNC_ISSUE_JOB = "sync-issue" +_SYNC_ISSUE_JOB = "finalize" def _check_sync_job(report: PhaseReport, repo: str, run_id: str) -> bool: - """True iff the run's sync-issue job concluded success; detail set otherwise. + """True iff the run's finalize job concluded success; detail set otherwise. A run can conclude success with the sync job skipped (e.g. a condition regression), so the run conclusion alone does not prove the sync ran. @@ -918,7 +918,7 @@ def phase_dispatch_sync_issue( """Dispatch the caller workflow (sync-issue) and check its sync job. workflow_dispatch posts no bot comment, so the assertions are the run - conclusion and the sync-issue job conclusion. + conclusion and the finalize job conclusion. """ report = PhaseReport(name="dispatch sync-issue") if dry_run: @@ -979,7 +979,7 @@ def phase_fire_sync_issue( dry_run: bool, expected_titles: dict[str, str] | None = None, ) -> tuple[PhaseReport, str | None]: - """Post /sync-issue; check the final bot reply and the sync-issue job.""" + """Post /sync-issue; check the final bot reply and the finalize job.""" titles = expected_titles or load_expected_comment_titles() return _fire_command_phase( repo, issue_number, diff --git a/release_automation/scripts/workflow_context.py b/release_automation/scripts/workflow_context.py index aad43b57..d39abfb2 100644 --- a/release_automation/scripts/workflow_context.py +++ b/release_automation/scripts/workflow_context.py @@ -60,8 +60,7 @@ def parse_json_list(json_str): "release_pr_number": os.environ.get("CTX_RELEASE_PR_NUMBER", ""), "release_pr_url": f"{os.environ.get('GITHUB_SERVER_URL', '')}/{os.environ.get('GITHUB_REPOSITORY', '')}/pull/{os.environ.get('CTX_RELEASE_PR_NUMBER', '')}" if os.environ.get("CTX_RELEASE_PR_NUMBER") else "", "src_commit_sha": os.environ.get("CTX_SRC_COMMIT_SHA", ""), # Not in BotContext directly? Check. - - # Trigger PR (for push events — PR that triggered the release-plan.yaml change) + # Dependencies "commonalities_release": os.environ.get("CTX_COMMONALITIES_RELEASE", ""), diff --git a/release_automation/tests/test_bot_responder.py b/release_automation/tests/test_bot_responder.py index 530c1825..be9e8ba0 100644 --- a/release_automation/tests/test_bot_responder.py +++ b/release_automation/tests/test_bot_responder.py @@ -12,6 +12,7 @@ BotResponder, BotResponderError, TemplateNotFoundError, + merge_comment_context, ) @@ -643,3 +644,33 @@ def test_internal_error_template(self, bot_responder): assert "View logs" not in result # New template uses "View workflow logs" assert "workflow logs" in result assert "actions/runs/12345" in result + + +class TestRenderComment: + """render_comment(): base context + delta, as the post-bot-comment action merges them.""" + + BASE = {"release_tag": "r4.1", "state": "planned", "user": "base-user", "apis": []} + + def test_delta_overrides_base_and_marker_is_prepended(self, bot_responder): + result = bot_responder.render_comment( + "interim_processing", self.BASE, {"command": "sync-issue", "user": "alice"}, "r4.1" + ) + + assert result.startswith("\n") + assert "@alice" in result + + @pytest.mark.parametrize( + "apis_json,expected", + [ + ('[{"api_name": "QoD", "api_version": "1.0.0"}]', [{"api_name": "QoD", "api_version": "1.0.0"}]), + ([{"api_name": "QoD", "api_version": "1.0.0"}], [{"api_name": "QoD", "api_version": "1.0.0"}]), + ("not json", []), + (42, []), + ], + ) + def test_apis_json_becomes_apis(self, bot_responder, apis_json, expected): + assert merge_comment_context(self.BASE, {"apis_json": apis_json})["apis"] == expected + + @pytest.mark.parametrize("base", [None, [], "x"]) + def test_non_dict_base_is_ignored(self, base): + assert merge_comment_context(base, {"release_tag": "r4.2"})["release_tag"] == "r4.2" diff --git a/release_automation/tests/test_command_policy.py b/release_automation/tests/test_command_policy.py index 311a4c55..cb4f4745 100644 --- a/release_automation/tests/test_command_policy.py +++ b/release_automation/tests/test_command_policy.py @@ -626,3 +626,39 @@ def test_main_non_slash_config_error(tmp_path, monkeypatch): assert (out["allowed"], out["response"]) == ("", "") assert json.loads(out["plan"])["error_type"] == "malformed_yaml" + + +# ── finalize gate ──────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "trigger,state,due", + [ + (slash("create-snapshot"), ReleaseState.PLANNED, True), + (slash("sync-issue"), ReleaseState.PUBLISHED, True), + (publish(confirm_tag="r1.2"), ReleaseState.DRAFT_READY, True), + # The confirmation request is the whole response; nothing ran to finalize. + (publish(confirm_tag=""), ReleaseState.DRAFT_READY, False), + (slash("delete-draft"), ReleaseState.PLANNED, False), + (Trigger(trigger_type="push", command="sync-issue", should_continue=True), ReleaseState.PLANNED, True), + (Trigger(trigger_type="pr_merge", should_continue=True), ReleaseState.SNAPSHOT_ACTIVE, True), + (Trigger(trigger_type="issue_close", issue_number="4", should_continue=True), ReleaseState.DRAFT_READY, True), + ], +) +def test_finalize_due(trigger, state, due): + assert policy.finalize_due(evaluate(trigger, info(state), client=WRITE)) is due + + +@pytest.mark.parametrize("trigger", [slash("create-snapshot"), Trigger(trigger_type="pr_merge", should_continue=True)]) +def test_no_finalize_on_config_error(trigger): + assert policy.finalize_due(evaluate(trigger, CONFIG_ERROR, client=FakeClient())) is False + + +def test_main_writes_finalize(tmp_path, monkeypatch): + out = run_main( + tmp_path, monkeypatch, slash("create-snapshot"), + {"STATE": "planned", "RELEASE_TAG": "r1.2", "RELEASE_TYPE": "pre-release-alpha"}, + FakeClient(permissions={"alice": "write"}), + ) + + assert out["finalize"] == "true" diff --git a/release_automation/tests/test_finalize.py b/release_automation/tests/test_finalize.py new file mode 100644 index 00000000..c1abc9cd --- /dev/null +++ b/release_automation/tests/test_finalize.py @@ -0,0 +1,607 @@ +"""Tests for finalize.py (the finalize job: sync PRs, Release Issue, result comment). + +Expected values reproduce the former update-issue, post-result and +create-sync-pr jobs of release-automation-reusable.yml: their job and step +conditions, "Resolve Sync Context", "Determine Result Template", "Resolve +Reason" and "Build Result Context". +""" + +import json + +import pytest + +from release_automation.scripts import finalize +from release_automation.scripts.common_sync import CommonSyncOutcome +from release_automation.scripts.finalize import ( + Run, + issue_sync_due, + notifications, + result_context, + result_due, + result_template, + sync_overrides, +) +from release_automation.scripts.issue_sync import WorkflowSync +from release_automation.scripts.sync_pr import SyncPrOutcome + +ACTION_JOBS = ("create-snapshot", "discard-snapshot", "delete-draft", "publish-release", "handle-pr-merge", "handle-issue-event") + +PLAN = { + "trigger_type": "slash_command", + "command": "create-snapshot", + "command_args": "", + "issue_number": "42", + "user": "alice", + "allowed": "true", + "interim_posted": "true", + "config_error": "", + "state": "planned", + "release_tag": "r4.1", + "release_issue_number": "42", + "snapshot_branch": "release-snapshot/r4.1-abc1234", + "src_commit_sha": "abc1234def5678", + "common_cache_status": "", + "common_cache_details": "", + "common_sync_pr_url": "", + "commonalities_release": "r4.3", + "base_context": json.dumps({"release_tag": "r4.1", "state": "planned"}), + "comment_id": "555", +} + + +def make_run(plan=None, jobs=None, event=None): + needs = {"plan": {"result": "success", "outputs": {**PLAN, **(plan or {})}}} + for name in ACTION_JOBS: + needs[name] = {"result": "skipped", "outputs": {}} + for name, (result, outputs) in (jobs or {}).items(): + needs[name] = {"result": result, "outputs": outputs} + return Run( + needs=needs, + event=event or {}, + server_url="https://github.com", + repository="camaraproject/Example", + run_id="999", + ) + + +def ok(**outputs): + return ("success", {"success": "true", **outputs}) + + +def failed(**outputs): + return ("success", {"success": "false", **outputs}) + + +# ── when each part runs ────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "plan,jobs,due", + [ + ({}, {"create-snapshot": ok()}, True), + ({}, {"discard-snapshot": ok()}, True), + ({}, {"delete-draft": ok()}, True), + ({"trigger_type": "pr_merge"}, {"handle-pr-merge": ok()}, True), + ({"trigger_type": "issue_close"}, {"handle-issue-event": ("success", {"action_taken": "reopened"})}, True), + ({"command": "sync-issue"}, {}, True), + ({"trigger_type": "push", "command": "sync-issue", "allowed": ""}, {}, True), + ({}, {"create-snapshot": failed()}, False), + ({}, {"create-snapshot": ("failure", {"success": "true"})}, False), + ({"trigger_type": "pr_merge"}, {"handle-pr-merge": ("failure", {})}, False), + # A publication never syncs the issue here; the published finalisation does. + ({"command": "publish-release"}, {"publish-release": ok()}, False), + ({"command": "sync-issue", "state": ""}, {}, False), + ({"command": "sync-issue", "config_error": "missing"}, {}, False), + ], +) +def test_issue_sync_due(plan, jobs, due): + assert issue_sync_due(make_run(plan, jobs)) is due + + +@pytest.mark.parametrize( + "plan,due", + [ + ({}, True), + ({"trigger_type": "push"}, False), + ({"allowed": "false"}, False), + ({"interim_posted": "false"}, False), + ], +) +def test_result_due(plan, due): + assert result_due(make_run(plan)) is due + + +# ── Resolve Sync Context ───────────────────────────────────────────────────── + + +def test_overrides_after_create_snapshot(): + run = make_run(jobs={"create-snapshot": ok(snapshot_branch="release-snapshot/r4.1-new", release_pr_number="12")}) + + assert sync_overrides(run) == { + "state_override": "snapshot-active", + "snapshot_branch_override": "release-snapshot/r4.1-new", + "release_pr_number_override": "12", + "draft_release_url_override": "", + "force_update": True, + } + + +def test_overrides_after_draft_creation(): + run = make_run( + {"trigger_type": "pr_merge"}, + {"handle-pr-merge": ok(draft_release_url="https://github.com/camaraproject/Example/releases/tag/untagged-1")}, + event={"pull_request": {"number": 14}}, + ) + + assert sync_overrides(run) == { + "state_override": "draft-ready", + "snapshot_branch_override": "release-snapshot/r4.1-abc1234", + "release_pr_number_override": "14", + "draft_release_url_override": "https://github.com/camaraproject/Example/releases/tag/untagged-1", + "force_update": True, + } + + +@pytest.mark.parametrize("job", ["discard-snapshot", "delete-draft"]) +def test_overrides_after_discard_or_delete(job): + assert sync_overrides(make_run(jobs={job: ok()})) == { + "state_override": "planned", + "snapshot_branch_override": "", + "release_pr_number_override": "", + "draft_release_url_override": "", + "force_update": True, + } + + +@pytest.mark.parametrize("plan,force", [({"command": "sync-issue"}, True), ({"trigger_type": "issue_close", "command": ""}, False)]) +def test_overrides_without_a_state_change(plan, force): + assert sync_overrides(make_run(plan)) == { + "state_override": "", + "snapshot_branch_override": "", + "release_pr_number_override": "", + "draft_release_url_override": "", + "force_update": force, + } + + +# ── notifications after the issue sync ─────────────────────────────────────── + + +def test_issue_created_notification(): + run = make_run( + {"trigger_type": "issue_close", "command": ""}, + event={"issue": {"number": 40, "html_url": "https://github.com/camaraproject/Example/issues/40"}}, + ) + sync = WorkflowSync(issue_number="43", issue_action="created", issue_url="u") + + assert notifications(run, sync, None) == [("issue_created", "43", { + "state": "planned", + "trigger_type": "issue_close", + "release_plan_url": "https://github.com/camaraproject/Example/blob/main/release-plan.yaml", + "closed_issue_number": "40", + "closed_issue_url": "https://github.com/camaraproject/Example/issues/40", + })] + + +def test_issue_created_without_issue_event(): + sync = WorkflowSync(issue_number="43", issue_action="created") + + (_, _, delta), = notifications(make_run({"trigger_type": "push"}), sync, None) + + assert delta["closed_issue_number"] == "" and delta["closed_issue_url"] == "" + + +def test_not_planned_notification(): + # The former workflow compared state == 'not_planned'; the state value is + # 'not-planned', so this comment was never posted. + run = make_run({"state": "not-planned"}) + + assert notifications(run, WorkflowSync(issue_number="42", issue_action="updated"), None) == [ + ("state_not_planned", "42", {"state": "not-planned"}), + ] + + +@pytest.mark.parametrize("state", ["planned", "not_planned"]) +def test_no_not_planned_notification_in_other_states(state): + assert notifications(make_run({"state": state}), WorkflowSync(issue_number="42", issue_action="updated"), None) == [] + + +def test_common_sync_pr_created_notification(): + run = make_run({"common_cache_details": 'manifest has "r4.2"', "release_issue_number": ""}) + common = CommonSyncOutcome(status="created", pr_url="https://github.com/camaraproject/Example/pull/40", pr_number="40") + + assert notifications(run, WorkflowSync(issue_number="43", issue_action="unchanged"), common) == [ + ("common_sync_pr_created", "43", { + "common_cache_details": 'manifest has "r4.2"', + "common_sync_pr_url": "https://github.com/camaraproject/Example/pull/40", + }), + ] + + +@pytest.mark.parametrize("status", ["updated", "no_changes"]) +def test_no_common_sync_notification_without_new_pr(status): + assert notifications(make_run(), WorkflowSync(issue_action="unchanged"), CommonSyncOutcome(status=status)) == [] + + +def test_common_sync_failed_notification_prefers_the_plan_issue(): + run = make_run({"release_issue_number": "42"}) + + assert notifications(run, WorkflowSync(issue_number="43", issue_action="updated"), CommonSyncOutcome(status="failed")) == [ + ("common_sync_failed", "42", {}), + ] + + +def test_no_common_sync_notification_without_an_issue(): + run = make_run({"release_issue_number": ""}) + + assert notifications(run, WorkflowSync(issue_action="skipped"), CommonSyncOutcome(status="failed")) == [] + + +# ── Determine Result Template ──────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "command,jobs,template", + [ + ("create-snapshot", {"create-snapshot": ok()}, "snapshot_created"), + ("create-snapshot", {"create-snapshot": failed()}, "snapshot_failed"), + ("create-snapshot", {"create-snapshot": ("failure", {})}, "snapshot_failed"), + ("discard-snapshot", {"discard-snapshot": ok()}, "snapshot_discarded"), + ("discard-snapshot", {"discard-snapshot": failed()}, "snapshot_failed"), + ("delete-draft", {"delete-draft": ok()}, "draft_revoked"), + ("delete-draft", {"delete-draft": failed()}, "snapshot_failed"), + ("sync-issue", {}, "interim_processing"), + ("publish-release", {"publish-release": ok()}, "release_published"), + ("publish-release", {"publish-release": failed()}, "publish_failed"), + ("unknown-cmd", {}, "internal_error"), + ], +) +def test_result_template(command, jobs, template): + assert result_template(make_run({"command": command}, jobs)) == template + + +# ── Resolve Reason + Build Result Context ──────────────────────────────────── + + +@pytest.mark.parametrize( + "command,args,reason", + [ + ("discard-snapshot", "", "No reason provided"), + ("delete-draft", "", "No reason provided"), + ("discard-snapshot", "wrong `api` $(x)", "wrong `api` $(x)"), + ("create-snapshot", "", ""), + ("publish-release", "--confirm r4.1", "--confirm r4.1"), + ], +) +def test_reason(command, args, reason): + assert result_context(make_run({"command": command, "command_args": args}), None) == {"reason": reason} + + +def test_context_after_create_snapshot(): + apis = json.dumps([{"api_name": "qod", "api_version": "1.0.0-rc.1"}]) + run = make_run(jobs={"create-snapshot": ok( + snapshot_id="r4.1-abc1234", + snapshot_branch="release-snapshot/r4.1-abc1234", + release_review_branch="release-review/r4.1-abc1234", + release_pr_number="12", + release_pr_url="https://github.com/camaraproject/Example/pull/12", + apis_json=apis, + )}) + + assert result_context(run, None) == { + "reason": "", + "snapshot_id": "r4.1-abc1234", + "snapshot_branch": "release-snapshot/r4.1-abc1234", + "snapshot_branch_url": "https://github.com/camaraproject/Example/tree/release-snapshot/r4.1-abc1234", + "release_review_branch": "release-review/r4.1-abc1234", + "release_review_branch_url": "https://github.com/camaraproject/Example/tree/release-review/r4.1-abc1234", + "release_pr_number": "12", + "release_pr_url": "https://github.com/camaraproject/Example/pull/12", + "apis_json": apis, + } + + +@pytest.mark.parametrize("job", ["discard-snapshot", "delete-draft"]) +def test_context_after_discard_names_the_preserved_branch(job): + run = make_run({"command": job, "command_args": "redo"}, {job: ok(renamed_review_branch="release-review/r4.1-abc-preserved")}) + + assert result_context(run, None) == { + "reason": "redo", + "release_review_branch": "release-review/r4.1-abc-preserved", + "release_review_branch_url": "https://github.com/camaraproject/Example/tree/release-review/r4.1-abc-preserved", + } + + +def test_context_after_discard_without_rename(): + run = make_run({"command": "discard-snapshot"}, {"discard-snapshot": ok(renamed_review_branch="")}) + + assert result_context(run, None) == {"reason": "No reason provided"} + + +def publish(error_message=""): + return {"publish-release": ok( + release_url="https://github.com/camaraproject/Example/releases/tag/r4.1", + reference_tag="src/r4.1", + error_message=error_message, + )} + + +def test_context_after_publish(): + run = make_run({"command": "publish-release", "command_args": "--confirm r4.1"}, publish()) + sync = SyncPrOutcome(status="created", pr_url="https://github.com/camaraproject/Example/pull/20", pr_number="20") + + assert result_context(run, sync) == { + "reason": "--confirm r4.1", + "release_url": "https://github.com/camaraproject/Example/releases/tag/r4.1", + "reference_tag": "src/r4.1", + "reference_tag_url": "https://github.com/camaraproject/Example/tree/src/r4.1", + "sync_pr_url": "https://github.com/camaraproject/Example/pull/20", + "sync_pr_number": "20", + "src_commit_sha_short": "abc1234", + } + + +def test_context_after_publish_with_warnings(): + run = make_run({"command": "publish-release"}, publish("Failed to create pointer branch")) + jobs_ref = run.needs["publish-release"]["outputs"] + jobs_ref["reference_tag"] = "" + + context = result_context(run, SyncPrOutcome(status="no_changes")) + + assert context["reference_tag_url"] == "" + assert context["sync_pr_url"] == "" and context["sync_pr_number"] == "" + assert context["publish_warnings"] == ( + "Failed to create pointer branch; " + "Post-release sync skipped (no changes detected) — verify README/CHANGELOG" + ) + + +def test_context_after_publish_without_sync_outcome(): + context = result_context(make_run({"command": "publish-release"}, publish()), None) + + assert context["publish_warnings"] == "Post-release sync status unknown — verify README/CHANGELOG" + + +@pytest.mark.parametrize( + "job", + ["create-snapshot", "discard-snapshot", "delete-draft", "publish-release"], +) +def test_context_carries_the_failure(job): + run = make_run({"command": job}, {job: failed(error_message="boom")}) + + assert result_context(run, None)["error_message"] == "boom" + + +def test_skipped_job_carries_no_failure(): + assert "error_message" not in result_context(make_run(jobs={"create-snapshot": ("skipped", {})}), None) + + +# ── orchestration ──────────────────────────────────────────────────────────── + +from release_automation.scripts import common_sync as common_sync_mod # noqa: E402 +from release_automation.scripts import issue_sync as issue_sync_mod # noqa: E402 +from release_automation.scripts import sync_pr as sync_pr_mod # noqa: E402 +from release_automation.scripts.finalize import Clients, finalize as run_finalize # noqa: E402 +from release_automation.scripts.github_client import GitHubClientError # noqa: E402 + +ISSUE_BODY = ( + "\nx\n\n" + "\nx\n\n" +) + + +class FakeGitHub: + repo = "camaraproject/Example" + + def __init__(self, fail_comments=False): + self.events = [] + self.fail_comments = fail_comments + + def get_user(self, login): + return {"id": 7, "login": login} + + def create_comment(self, issue_number, body): + if self.fail_comments: + raise GitHubClientError("gh command failed: 502") + self.events.append(("comment", issue_number, body)) + return 1000 + len(self.events) + + def update_comment(self, comment_id, body): + if self.fail_comments: + raise GitHubClientError("gh command failed: 502") + self.events.append(("update", comment_id, body)) + + def get_issue(self, number): + return {"number": number, "body": ISSUE_BODY, "labels": [{"name": "release-state:draft-ready"}]} + + def update_issue(self, number, title=None, body=None): + self.events.append(("issue-body", number, body)) + + def remove_labels(self, number, labels): + self.events.append(("remove-labels", number, labels)) + + def add_labels(self, number, labels): + self.events.append(("add-labels", number, labels)) + + def close_issue(self, number, state_reason="completed"): + self.events.append(("close", number, state_reason)) + + +@pytest.fixture +def stubs(monkeypatch): + calls = {} + + def fake_sync_pr(gh, git, tag, bot_name, bot_email): + calls["sync_pr"] = dict(work_dir=git.work_dir, tag=tag, bot=(bot_name, bot_email)) + gh.events.append(("sync-pr", tag, None)) + return calls.get("sync_pr_outcome", SyncPrOutcome(status="created", pr_url="https://github.com/camaraproject/Example/pull/20", pr_number="20")) + + def fake_common(api_git, commonalities_git, gh, release, bot_name, bot_email): + calls["common"] = dict(api=api_git.work_dir, commonalities=(commonalities_git.repo, commonalities_git.token), release=release) + gh.events.append(("common-sync", release, None)) + return calls.get("common_outcome", CommonSyncOutcome(status="created", pr_url="https://github.com/camaraproject/Example/pull/40", pr_number="40")) + + def fake_issue_sync(gh, **kwargs): + calls["issue_sync"] = kwargs + gh.events.append(("issue-sync", kwargs["release_tag"], None)) + return calls.get("issue_sync_outcome", WorkflowSync(issue_number="42", issue_action="updated", issue_url="u")) + + monkeypatch.setattr(sync_pr_mod, "create_sync_pr", fake_sync_pr) + monkeypatch.setattr(common_sync_mod, "sync_common", fake_common) + monkeypatch.setattr(issue_sync_mod, "sync_for_workflow", fake_issue_sync) + return calls + + +class FakeGit: + def __init__(self, repo, work_dir, token): + self.repo, self.work_dir, self.token = repo, work_dir, token + + +def clients(gh, tmp_path, app=True): + return Clients(gh=gh, git_factory=FakeGit, token="app-token", app_slug="release-bot", app_token_used=app, work_root=tmp_path) + + +def kinds(gh): + return [e[0] for e in gh.events] + + +PUBLISH_PLAN = {"command": "publish-release", "command_args": "--confirm r4.1", "state": "draft-ready"} + + +def test_publish_runs_sync_pr_then_result_then_closes(stubs, tmp_path): + gh = FakeGitHub() + run = make_run(PUBLISH_PLAN, publish()) + + report = run_finalize(run, clients(gh, tmp_path)) + + assert kinds(gh) == ["sync-pr", "update", "issue-body", "remove-labels", "add-labels", "close"] + assert stubs["sync_pr"] == { + "work_dir": str(tmp_path / "api-repo"), + "tag": "r4.1", + "bot": ("release-bot[bot]", "7+release-bot[bot]@users.noreply.github.com"), + } + _, comment_id, body = gh.events[1] + assert comment_id == 555 + assert body.startswith("\n**🚀 Release published") + assert "[#20](https://github.com/camaraproject/Example/pull/20)" in body + assert "🔄 **Sync PR:** [View PR](https://github.com/camaraproject/Example/pull/20)" in gh.events[2][2] + assert gh.events[5] == ("close", 42, "completed") + assert report.errors == [] and report.issue_finalized is True + + +def test_publish_with_failed_sync_pr_leaves_the_issue_open(stubs, tmp_path): + stubs["sync_pr_outcome"] = SyncPrOutcome(status="failed") + gh = FakeGitHub() + + run_finalize(make_run(PUBLISH_PLAN, publish()), clients(gh, tmp_path)) + + assert "close" not in kinds(gh) + body = gh.events[1][2] + assert "This issue remains open — manual follow-up required." in body + assert "Post-release sync PR creation failed — create manually" in body + + +def test_publish_without_interim_still_opens_the_sync_pr_only(stubs, tmp_path): + gh = FakeGitHub() + + run_finalize(make_run({**PUBLISH_PLAN, "interim_posted": "false"}, publish()), clients(gh, tmp_path)) + + assert kinds(gh) == ["sync-pr"] + + +def test_result_comment_failure_skips_the_published_issue(stubs, tmp_path): + gh = FakeGitHub(fail_comments=True) + + report = run_finalize(make_run(PUBLISH_PLAN, publish()), clients(gh, tmp_path)) + + assert kinds(gh) == ["sync-pr"] + assert report.errors == ["result comment failed"] + assert report.issue_finalized is None + + +def test_sync_issue_command(stubs, tmp_path): + gh = FakeGitHub() + + base = json.dumps({"release_tag": "r4.1", "command": "sync-issue", "user": "alice"}) + report = run_finalize(make_run({"command": "sync-issue", "base_context": base}), clients(gh, tmp_path)) + + assert kinds(gh) == ["issue-sync", "update"] + assert stubs["issue_sync"]["force_update"] is True + assert "**⏳ Processing: `/sync-issue`**" in gh.events[1][2] + assert report.result_template == "interim_processing" + + +def test_stale_cache_runs_common_sync_before_the_issue_sync(stubs, tmp_path): + gh = FakeGitHub() + run = make_run({ + "trigger_type": "push", "command": "sync-issue", "allowed": "", "interim_posted": "false", + "common_cache_status": "stale", "common_cache_details": "code/common/.sync-manifest.yaml is missing", + "common_sync_pr_url": "", + }) + + run_finalize(run, clients(gh, tmp_path)) + + assert kinds(gh) == ["common-sync", "issue-sync", "comment"] + assert stubs["common"] == { + "api": str(tmp_path / "common-sync-api"), + "commonalities": ("camaraproject/Commonalities", None), + "release": "r4.3", + } + assert stubs["issue_sync"]["common_sync_pr_url"] == "https://github.com/camaraproject/Example/pull/40" + _, issue, body = gh.events[2] + assert issue == 42 + assert body.startswith("\n**Common file sync: [sync PR](https://github.com/camaraproject/Example/pull/40) created**") + assert "code/common/.sync-manifest.yaml is missing" in body + + +def test_issue_sync_falls_back_to_the_plans_sync_pr(stubs, tmp_path): + stubs["common_outcome"] = CommonSyncOutcome(status="no_changes") + run = make_run({"command": "sync-issue", "common_cache_status": "stale", "common_sync_pr_url": "https://x/pull/3"}) + + run_finalize(run, clients(FakeGitHub(), tmp_path)) + + assert stubs["issue_sync"]["common_sync_pr_url"] == "https://x/pull/3" + + +def test_issue_sync_error_posts_no_notifications_but_the_result(stubs, tmp_path): + stubs["issue_sync_outcome"] = WorkflowSync(issue_action="error") + stubs["common_outcome"] = CommonSyncOutcome(status="failed") + gh = FakeGitHub() + run = make_run({"command": "sync-issue", "common_cache_status": "stale"}) + + report = run_finalize(run, clients(gh, tmp_path)) + + assert kinds(gh) == ["common-sync", "issue-sync", "update"] + assert report.errors == ["Release Issue sync failed"] + + +def test_bot_identity_without_app_token(stubs, tmp_path): + gh = FakeGitHub() + + run_finalize(make_run(PUBLISH_PLAN, publish()), clients(gh, tmp_path, app=False)) + + assert stubs["sync_pr"]["bot"] == ("github-actions[bot]", "7+github-actions[bot]@users.noreply.github.com") + + +def test_summary(stubs, tmp_path): + summary = tmp_path / "summary" + + run_finalize(make_run(PUBLISH_PLAN, publish()), clients(FakeGitHub(), tmp_path), str(summary)) + + text = summary.read_text() + assert text.startswith("## Release Automation: finalize\n") + assert "| Post-release sync PR | created https://github.com/camaraproject/Example/pull/20 |" in text + assert "| Result comment | release_published |" in text + assert "| Published issue | updated |" in text + + +def test_main_exit_code(stubs, tmp_path, monkeypatch): + stubs["issue_sync_outcome"] = WorkflowSync(issue_action="error") + event = tmp_path / "event.json" + event.write_text("{}") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event)) + monkeypatch.setenv("GITHUB_REPOSITORY", "camaraproject/Example") + monkeypatch.setenv("NEEDS_JSON", json.dumps(make_run({"command": "sync-issue", "interim_posted": "false"}).needs)) + monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False) + + assert finalize.main(clients(FakeGitHub(), tmp_path)) == 1 diff --git a/release_automation/tests/test_github_client.py b/release_automation/tests/test_github_client.py index fe2ca4b4..2ad70305 100644 --- a/release_automation/tests/test_github_client.py +++ b/release_automation/tests/test_github_client.py @@ -280,6 +280,21 @@ def test_close_pr_with_comment(self, mock_run_gh): "pr", "close", "3", "--repo", "owner/repo", "--comment", "Superseded", ]) + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_create_comment_returns_id(self, mock_run_gh): + mock_run_gh.return_value = json.dumps({"id": 991, "body": "x"}) + self.assertEqual(self.client.create_comment(42, "hello"), 991) + self.assertEqual(mock_run_gh.call_args[0][0], [ + "api", "repos/owner/repo/issues/42/comments", "-X", "POST", "-f", "body=hello", + ]) + + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_update_comment(self, mock_run_gh): + self.client.update_comment(991, "@user ok") + self.assertEqual(mock_run_gh.call_args[0][0], [ + "api", "repos/owner/repo/issues/comments/991", "-X", "PATCH", "-f", "body=@user ok", + ]) + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") def test_get_collaborator_permission(self, mock_run_gh): mock_run_gh.return_value = "maintain\n" diff --git a/release_automation/tests/test_issue_manager.py b/release_automation/tests/test_issue_manager.py index 8da36c6f..92c469f5 100644 --- a/release_automation/tests/test_issue_manager.py +++ b/release_automation/tests/test_issue_manager.py @@ -472,56 +472,67 @@ def test_get_section_with_nested_comments(self): class TestIssueManagerPublishedSections: - """Tests for published state section generators.""" + """Published STATE/ACTIONS sections; text reproduces the former inline + "Update and Close Release Issue" step of the post-result job.""" - def test_generate_published_state_section_basic(self): - """Test generating published state section.""" - manager = IssueManager() - - content = manager.generate_published_state_section( + def test_published_state_section(self): + content = IssueManager().generate_published_state_section( release_tag="r4.1", - release_url="https://github.com/test/releases/tag/r4.1", - reference_tag="source/r4.1" + release_url="https://github.com/org/repo/releases/tag/r4.1", + reference_tag="src/r4.1", + repo="org/repo", + sync_pr_url="https://github.com/org/repo/pull/123", ) - assert "**State:** `published`" in content - assert "**Release:** [r4.1](https://github.com/test/releases/tag/r4.1)" in content - assert "**Reference tag:** `source/r4.1`" in content - # No sync PR line when not provided - assert "Sync PR" not in content - - def test_generate_published_state_section_with_sync_pr(self): - """Test published state with sync PR URL.""" - manager = IssueManager() - - content = manager.generate_published_state_section( - release_tag="r4.1", - release_url="https://github.com/test/releases/tag/r4.1", - reference_tag="source/r4.1", - sync_pr_url="https://github.com/test/pull/123" + assert content == ( + "**State:** `published`\n" + "\n" + "✅ **Release published:** [r4.1](https://github.com/org/repo/releases/tag/r4.1)\n" + "📌 **Reference tag:** [`src/r4.1`](https://github.com/org/repo/tree/src/r4.1)\n" + "🔄 **Sync PR:** [View PR](https://github.com/org/repo/pull/123)" ) - assert "**Sync PR:** https://github.com/test/pull/123" in content - - def test_generate_published_state_section_has_timestamp(self): - """Test that published state includes timestamp.""" - manager = IssueManager() - - content = manager.generate_published_state_section( + def test_published_state_section_without_sync_pr_with_warnings(self): + content = IssueManager().generate_published_state_section( release_tag="r4.1", - release_url="https://github.com/test/releases/tag/r4.1", - reference_tag="source/r4.1" + release_url="https://github.com/org/repo/releases/tag/r4.1", + reference_tag="src/r4.1", + repo="org/repo", + warnings="Failed to create pointer branch; Post-release sync PR creation failed — create manually", ) - # Should contain a timestamp in ISO format - assert "**Last Updated:**" in content - assert "202" in content # Year starts with 202x + assert "Sync PR" not in content + assert content.endswith( + "\n⚠️ **Warnings:** Failed to create pointer branch; " + "Post-release sync PR creation failed — create manually" + ) - def test_generate_published_actions_section(self): - """Test generating published actions section.""" - manager = IssueManager() + def test_published_state_section_keeps_empty_reference_tag_link(self): + # Reference tag creation is non-critical; the line stays (as before) when it failed. + content = IssueManager().generate_published_state_section( + release_tag="r4.1", release_url="u", reference_tag="", repo="org/repo", + ) - content = manager.generate_published_actions_section() + assert "📌 **Reference tag:** [``](https://github.com/org/repo/tree/)" in content + + @pytest.mark.parametrize( + "sync_status,text", + [ + ("created", "_No actions available - release is published._"), + ( + "failed", + "**Manual action required:** Create post-release sync PR manually " + "(update README and CHANGELOG on main), then close this issue.", + ), + ( + "no_changes", + "**Manual action required:** Verify README and CHANGELOG on main are correct, " + "then close this issue.", + ), + ("", "**Manual action required:** Verify post-release sync status, then close this issue."), + ("weird", "**Manual action required:** Verify post-release sync status, then close this issue."), + ], + ) + def test_published_actions_section(self, sync_status, text): + assert IssueManager().generate_published_actions_section(sync_status) == text - assert "No further actions available" in content - assert "release is published" in content diff --git a/release_automation/tests/test_issue_sync.py b/release_automation/tests/test_issue_sync.py index 5635e3b7..8e443878 100644 --- a/release_automation/tests/test_issue_sync.py +++ b/release_automation/tests/test_issue_sync.py @@ -7,11 +7,14 @@ import pytest from unittest.mock import MagicMock, patch +from release_automation.scripts.github_client import GitHubClientError +from release_automation.scripts.issue_manager import IssueManager from release_automation.scripts.issue_sync import ( IssueSyncManager, SyncResult, WORKFLOW_MARKER, REQUIRED_LABELS, + post_publish_warnings, ) from release_automation.scripts.state_manager import ReleaseInfoResult, ReleaseState @@ -784,140 +787,231 @@ def test_ensures_labels_before_operations(self): assert gh.create_label.call_count == 6 -class TestCloseReleaseIssue: - """Tests for close_release_issue method.""" +PUBLISHED_BODY = ( + "\n" + "\nold state\n\n" + "\nconfig\n\n" + "\nold actions\n\n" +) - def test_close_release_issue_success(self): - """Test successful issue closure flow.""" - gh = MagicMock() - state_manager = MagicMock() - issue_manager = MagicMock() - bot_responder = MagicMock() - # Setup issue +class TestFinalizePublishedIssue: + """Published Release Issue: STATE/ACTIONS, label swap, close only on a created sync PR. + + Reproduces the former inline "Update and Close Release Issue" step. + """ + + def finalize(self, gh, sync_status="created", publish_warnings="", sync_pr_url="https://github.com/org/repo/pull/9"): + gh.repo = "org/repo" gh.get_issue.return_value = { "number": 42, - "body": "\nold\n\n\nold\n", - "labels": [{"name": "release-state:draft-ready"}] + "body": PUBLISHED_BODY, + "labels": [{"name": "release-issue"}, {"name": "release-state:draft-ready"}], } - - # Setup issue_manager section generators - issue_manager.generate_published_state_section.return_value = "published state" - issue_manager.generate_published_actions_section.return_value = "no actions" - issue_manager.update_section.side_effect = lambda body, section, content: body.replace( - f"\nold\n", - f"\n{content}\n" - ) - - manager = IssueSyncManager(gh, state_manager, issue_manager, bot_responder) - - result = manager.close_release_issue( + manager = IssueSyncManager(gh, issue_manager=IssueManager()) + return manager.finalize_published_issue( issue_number=42, release_tag="r4.1", - release_url="https://github.com/test/releases/tag/r4.1", - reference_tag="source/r4.1", - sync_pr_url="https://github.com/test/pull/123" + release_url="https://github.com/org/repo/releases/tag/r4.1", + reference_tag="src/r4.1", + sync_pr_url=sync_pr_url, + sync_status=sync_status, + publish_warnings=publish_warnings, ) - assert result is True + def body(self, gh): + return gh.update_issue.call_args.kwargs["body"] - # Verify STATE section was updated - issue_manager.generate_published_state_section.assert_called_once_with( - release_tag="r4.1", - release_url="https://github.com/test/releases/tag/r4.1", - reference_tag="source/r4.1", - sync_pr_url="https://github.com/test/pull/123" - ) - - # Verify ACTIONS section was updated - issue_manager.generate_published_actions_section.assert_called_once() + def test_created_updates_body_swaps_labels_and_closes(self): + gh = MagicMock() - # Verify labels were updated + assert self.finalize(gh) is True + + assert self.body(gh) == ( + "\n" + "\n" + "**State:** `published`\n" + "\n" + "✅ **Release published:** [r4.1](https://github.com/org/repo/releases/tag/r4.1)\n" + "📌 **Reference tag:** [`src/r4.1`](https://github.com/org/repo/tree/src/r4.1)\n" + "🔄 **Sync PR:** [View PR](https://github.com/org/repo/pull/9)\n" + "\n" + "\nconfig\n\n" + "\n_No actions available - release is published._\n\n" + ) gh.remove_labels.assert_called_once_with(42, ["release-state:draft-ready"]) gh.add_labels.assert_called_once_with(42, ["release-state:published"]) - - # Verify issue was closed gh.close_issue.assert_called_once_with(42, state_reason="completed") - def test_close_release_issue_without_sync_pr(self): - """Test closure without sync PR URL.""" + @pytest.mark.parametrize( + "sync_status,warning,action", + [ + ("failed", "Post-release sync PR creation failed — create manually", "Create post-release sync PR manually"), + ("no_changes", "Post-release sync skipped (no changes detected) — verify README/CHANGELOG", "Verify README and CHANGELOG"), + ("", "Post-release sync status unknown — verify README/CHANGELOG", "Verify post-release sync status"), + ("unexpected", "Post-release sync status unknown — verify README/CHANGELOG", "Verify post-release sync status"), + ], + ) + def test_without_created_sync_pr_issue_stays_open(self, sync_status, warning, action): gh = MagicMock() - state_manager = MagicMock() - issue_manager = MagicMock() - bot_responder = MagicMock() - gh.get_issue.return_value = { - "number": 42, - "body": "body content", - "labels": [] - } - issue_manager.update_section.side_effect = lambda body, section, content: body + assert self.finalize(gh, sync_status=sync_status, sync_pr_url="") is True - manager = IssueSyncManager(gh, state_manager, issue_manager, bot_responder) + body = self.body(gh) + assert f"⚠️ **Warnings:** {warning}\n" in body + assert "Sync PR" not in body + assert f"\n**Manual action required:** {action}" in body + gh.add_labels.assert_called_once_with(42, ["release-state:published"]) + gh.close_issue.assert_not_called() - result = manager.close_release_issue( - issue_number=42, - release_tag="r4.1", - release_url="https://github.com/test/releases/tag/r4.1", - reference_tag="source/r4.1" - ) + def test_publish_warnings_appear_in_state(self): + gh = MagicMock() - assert result is True + self.finalize(gh, publish_warnings="Failed to create reference tag; Failed to create pointer branch") - # sync_pr_url should be None when empty string passed - issue_manager.generate_published_state_section.assert_called_once() - call_kwargs = issue_manager.generate_published_state_section.call_args - assert call_kwargs.kwargs.get("sync_pr_url") is None + assert ( + "⚠️ **Warnings:** Failed to create reference tag; Failed to create pointer branch\n" + ) in self.body(gh) + gh.close_issue.assert_called_once() - def test_close_release_issue_api_error(self): - """Test graceful handling of API errors.""" + def test_publish_and_sync_warnings_are_joined(self): gh = MagicMock() - state_manager = MagicMock() - issue_manager = MagicMock() - bot_responder = MagicMock() - # Simulate API error - gh.get_issue.side_effect = Exception("API error") + self.finalize(gh, sync_status="failed", publish_warnings="Failed to create pointer branch") - manager = IssueSyncManager(gh, state_manager, issue_manager, bot_responder) + assert ( + "⚠️ **Warnings:** Failed to create pointer branch; " + "Post-release sync PR creation failed — create manually" + ) in self.body(gh) - result = manager.close_release_issue( - issue_number=42, - release_tag="r4.1", - release_url="https://github.com/test/releases/tag/r4.1", - reference_tag="source/r4.1" - ) + @pytest.mark.parametrize("failing", ["get_issue", "update_issue", "add_labels", "close_issue"]) + def test_api_errors_are_logged_not_raised(self, failing, capsys): + gh = MagicMock() + getattr(gh, failing).side_effect = GitHubClientError("gh command failed: 502") + + assert self.finalize(gh) is False + assert "::warning::Failed to close issue: gh command failed: 502" in capsys.readouterr().out + + +@pytest.mark.parametrize( + "publish_warnings,sync_status,expected", + [ + ("", "created", ""), + ("a; b", "created", "a; b"), + ("", "failed", "Post-release sync PR creation failed — create manually"), + ("a", "no_changes", "a; Post-release sync skipped (no changes detected) — verify README/CHANGELOG"), + ("", "", "Post-release sync status unknown — verify README/CHANGELOG"), + ], +) +def test_post_publish_warnings(publish_warnings, sync_status, expected): + assert post_publish_warnings(publish_warnings, sync_status) == expected - # Should return False on error, not raise - assert result is False - def test_close_release_issue_no_existing_state_labels(self): - """Test closure when no state labels exist.""" + +# ── sync_for_workflow: the former sync-release-issue composite ─────────────── + +from release_automation.scripts import issue_sync as issue_sync_mod # noqa: E402 +from release_automation.scripts.issue_sync import WorkflowSync, sync_for_workflow # noqa: E402 + +PLAN_YAML = "repository:\n target_release_tag: r4.1\n target_release_type: pre-release-alpha\n" + + +class TestSyncForWorkflow: + def run(self, monkeypatch, tmp_path, result=None, plan=PLAN_YAML, raises=None, **kwargs): gh = MagicMock() - state_manager = MagicMock() - issue_manager = MagicMock() - bot_responder = MagicMock() + gh.get_file_content.return_value = plan + calls = {} + + def fake_sync(self, release_plan, **kw): + calls.update(release_plan=release_plan, **kw) + if raises: + raise raises + return result + + monkeypatch.setattr(IssueSyncManager, "sync_release_issue", fake_sync) + summary = tmp_path / "summary" + args = dict(release_tag="r4.1", state="planned") + args.update(kwargs) + outcome = sync_for_workflow(gh, summary_path=str(summary), **args) + return outcome, calls, summary.read_text() if summary.exists() else "" + + def test_created(self, monkeypatch, tmp_path): + issue = {"number": 7, "html_url": "https://github.com/o/r/issues/7"} + + outcome, calls, _ = self.run( + monkeypatch, tmp_path, SyncResult(action="created", issue=issue), + state_override="snapshot-active", snapshot_branch_override="release-snapshot/r4.1-abc", + release_pr_number_override="12", force_update=True, + common_cache_status="stale", common_cache_details="d", common_sync_pr_url="u", + ) - gh.get_issue.return_value = { - "number": 42, - "body": "body", - "labels": [{"name": "release-issue"}] # No state label - } - issue_manager.update_section.side_effect = lambda body, section, content: body + assert outcome == WorkflowSync(issue_number="7", issue_action="created", issue_url="https://github.com/o/r/issues/7") + assert calls["release_plan"]["repository"]["target_release_tag"] == "r4.1" + assert calls["state_override"] == ReleaseState.SNAPSHOT_ACTIVE + assert calls["snapshot_branch_override"] == "release-snapshot/r4.1-abc" + assert calls["release_pr_number_override"] == "12" + assert calls["draft_release_url_override"] is None + assert calls["force_update"] is True + assert (calls["common_cache_status"], calls["common_cache_details"], calls["common_sync_pr_url"]) == ("stale", "d", "u") + + def test_no_action_is_unchanged(self, monkeypatch, tmp_path): + outcome, _, summary = self.run(monkeypatch, tmp_path, SyncResult(action="none", reason="up_to_date")) + + assert outcome == WorkflowSync(issue_action="unchanged") + assert summary == "" + + def test_no_planned_release_writes_summary(self, monkeypatch, tmp_path): + outcome, _, summary = self.run(monkeypatch, tmp_path, SyncResult(action="none", reason="no_planned_release")) + + assert outcome == WorkflowSync(issue_action="unchanged") + assert summary == ( + "## Release Status: Not Planned\n\n" + "No release is currently planned for `r4.1` (`target_release_type: none`).\n" + "No open Release Issue exists.\n" + ) - manager = IssueSyncManager(gh, state_manager, issue_manager, bot_responder) + @pytest.mark.parametrize("state,override", [("published", ""), ("planned", "published")]) + def test_published_is_skipped_with_summary(self, monkeypatch, tmp_path, state, override): + outcome, calls, summary = self.run(monkeypatch, tmp_path, state=state, state_override=override) - result = manager.close_release_issue( - issue_number=42, - release_tag="r4.1", - release_url="https://github.com/test/releases/tag/r4.1", - reference_tag="source/r4.1" + assert outcome == WorkflowSync(issue_action="skipped") + assert calls == {} + assert summary == ( + "## Release Status: Published\n\n" + "Release `r4.1` has already been published. No further actions needed.\n" ) - assert result is True + def test_without_release_tag_is_skipped(self, monkeypatch, tmp_path): + outcome, calls, _ = self.run(monkeypatch, tmp_path, release_tag="") - # Should not call remove_labels if no state labels - gh.remove_labels.assert_not_called() + assert outcome == WorkflowSync(issue_action="skipped") + assert calls == {} - # Should still add published label - gh.add_labels.assert_called_once_with(42, ["release-state:published"]) + @pytest.mark.parametrize("plan", [None, "a: [", "- just a list\n"]) + def test_unreadable_release_plan_is_skipped(self, monkeypatch, tmp_path, plan): + outcome, calls, _ = self.run(monkeypatch, tmp_path, plan=plan) + + assert outcome == WorkflowSync(issue_action="skipped") + assert calls == {} + + def test_unknown_override_is_an_error(self, monkeypatch, tmp_path): + outcome, calls, _ = self.run(monkeypatch, tmp_path, state_override="bogus") + + assert outcome == WorkflowSync(issue_action="error") + assert calls == {} + + @pytest.mark.parametrize("value,state", [ + ("draft-ready", ReleaseState.DRAFT_READY), + ("draft_ready", ReleaseState.DRAFT_READY), + ("PLANNED", ReleaseState.PLANNED), + ("not-planned", ReleaseState.NOT_PLANNED), + ]) + def test_override_spellings(self, monkeypatch, tmp_path, value, state): + _, calls, _ = self.run(monkeypatch, tmp_path, SyncResult(action="none"), state_override=value) + + assert calls["state_override"] == state + + def test_sync_exception_is_an_error(self, monkeypatch, tmp_path): + outcome, _, _ = self.run(monkeypatch, tmp_path, raises=RuntimeError("boom")) + + assert outcome == WorkflowSync(issue_action="error") diff --git a/release_automation/tests/test_regression_runner.py b/release_automation/tests/test_regression_runner.py index 3106573a..0813d24a 100644 --- a/release_automation/tests/test_regression_runner.py +++ b/release_automation/tests/test_regression_runner.py @@ -1408,7 +1408,7 @@ def _jobs(sync_conclusion: str) -> dict: return { "jobs": [ {"name": "release-automation / check-trigger", "conclusion": "success"}, - {"name": "release-automation / sync-issue", "conclusion": sync_conclusion}, + {"name": "release-automation / finalize", "conclusion": sync_conclusion}, ] } @@ -1470,7 +1470,7 @@ def test_fail_when_sync_job_skipped(self, frozen_now): "camaraproject/ReleaseTest", poll_timeout=10, dry_run=False, ) assert report.passed is False - assert "sync-issue job" in report.detail + assert "finalize job" in report.detail assert "'skipped'" in report.detail def test_fail_when_run_failed(self, frozen_now): @@ -1538,7 +1538,7 @@ def test_fail_when_sync_job_skipped(self, frozen_now): poll_timeout=10, dry_run=False, expected_titles=_EXPECTED_ALL, ) assert report.passed is False - assert "sync-issue job" in report.detail + assert "finalize job" in report.detail def test_fail_when_only_ack_visible(self, frozen_now): ack = "\n**⏳ Validating `/sync-issue`...**" diff --git a/shared-actions/post-bot-comment/action.yml b/shared-actions/post-bot-comment/action.yml index 37d62414..ec00870e 100644 --- a/shared-actions/post-bot-comment/action.yml +++ b/shared-actions/post-bot-comment/action.yml @@ -75,61 +75,28 @@ runs: print(f"Added to sys.path: {tooling_root}") from release_automation.scripts.bot_responder import BotResponder - from release_automation.scripts.context_builder import build_context - - # Parse context - base_context_json = os.environ.get('BASE_CONTEXT_JSON', '{}') - context_json = os.environ['CONTEXT_JSON'] base_context = {} + base_context_json = os.environ.get('BASE_CONTEXT_JSON', '{}') if base_context_json: try: base_context = json.loads(base_context_json) - if not isinstance(base_context, dict): - base_context = {} except json.JSONDecodeError: print("::warning::Invalid base_context JSON, ignoring") try: - raw_context_delta = json.loads(context_json) + context_delta = json.loads(os.environ['CONTEXT_JSON']) except json.JSONDecodeError as e: print(f"::error::Invalid JSON context: {e}") sys.exit(1) - # Merge delta into base - raw_context = base_context.copy() - raw_context.update(raw_context_delta) - - # Convert apis_json to apis list if present - # Handle both cases: string (from shell) or list (from jq JSON parsing) - if 'apis_json' in raw_context: - apis_val = raw_context['apis_json'] - if isinstance(apis_val, str): - try: - raw_context['apis'] = json.loads(apis_val) - except (json.JSONDecodeError, TypeError): - raw_context['apis'] = [] - elif isinstance(apis_val, list): - raw_context['apis'] = apis_val - else: - raw_context['apis'] = [] - del raw_context['apis_json'] - - # Guarantee schema completeness via build_context() - # - Fills missing keys with type-appropriate defaults - # - Derives boolean flags from string fields (e.g., is_missing_file from error_type) - # - Ignores unknown keys silently - context = build_context(**raw_context) - - # Render template + # Delta over base, apis_json to apis, full schema via build_context() template_name = os.environ['TEMPLATE_NAME'] release_tag = os.environ['RELEASE_TAG'] run_id = os.environ['RUN_ID'] - responder = BotResponder() - try: - content = responder.render_with_marker(template_name, context, release_tag) + content = BotResponder().render_comment(template_name, base_context, context_delta, release_tag) except Exception as e: print(f"::error::Failed to render template '{template_name}': {e}") sys.exit(1) diff --git a/shared-actions/sync-release-issue/action.yml b/shared-actions/sync-release-issue/action.yml deleted file mode 100644 index 12f69faf..00000000 --- a/shared-actions/sync-release-issue/action.yml +++ /dev/null @@ -1,297 +0,0 @@ -name: sync-release-issue -description: | - Synchronizes the Release Issue with the current release state. - Creates a new Release Issue if none exists and state is PLANNED, - or updates an existing workflow-owned issue when state changes. - - Only manages issues containing the workflow marker. Manually created - issues are ignored. - -inputs: - release_tag: - description: 'Target release tag (e.g., "r4.1")' - required: true - state: - description: 'Current release state from derive-state' - required: true - snapshot_branch: - description: 'Snapshot branch name if active' - required: false - default: '' - release_pr_number: - description: 'Release PR number if exists' - required: false - default: '' - state_override: - description: 'Authoritative state override for same-run transitions' - required: false - default: '' - snapshot_branch_override: - description: 'Authoritative snapshot branch override for same-run transitions' - required: false - default: '' - release_pr_number_override: - description: 'Authoritative release PR number override for same-run transitions' - required: false - default: '' - draft_release_url_override: - description: 'Authoritative draft release URL override for same-run transitions' - required: false - default: '' - force_update: - description: 'Force a Release Issue refresh even if state appears unchanged' - required: false - default: 'false' - trigger_type: - description: 'What triggered the workflow (slash_command, pr_merge, issue_event, workflow_dispatch)' - required: false - default: 'workflow_dispatch' - github_token: - description: 'GitHub token with issue write permissions' - required: true - common_cache_status: - description: 'Common cache sync status from derive-state ("stale", "in_sync", or "")' - required: false - default: '' - common_cache_details: - description: 'Human-readable cache staleness description' - required: false - default: '' - common_sync_pr_url: - description: 'URL of open sync-common PR if any' - required: false - default: '' - -outputs: - issue_number: - description: 'Release Issue number (created or updated)' - value: ${{ steps.sync.outputs.issue_number }} - issue_action: - description: 'What happened: created, updated, unchanged, skipped' - value: ${{ steps.sync.outputs.issue_action }} - issue_url: - description: 'URL to the Release Issue' - value: ${{ steps.sync.outputs.issue_url }} - -runs: - using: composite - steps: - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - - name: Install dependencies - shell: bash - run: pip install --quiet -r "${{ github.action_path }}/../../requirements.txt" - - - name: Sync Release Issue - id: sync - shell: python - env: - GITHUB_TOKEN: ${{ inputs.github_token }} - RELEASE_TAG: ${{ inputs.release_tag }} - CURRENT_STATE: ${{ inputs.state }} - SNAPSHOT_BRANCH: ${{ inputs.snapshot_branch }} - RELEASE_PR_NUMBER: ${{ inputs.release_pr_number }} - STATE_OVERRIDE: ${{ inputs.state_override }} - SNAPSHOT_BRANCH_OVERRIDE: ${{ inputs.snapshot_branch_override }} - RELEASE_PR_NUMBER_OVERRIDE: ${{ inputs.release_pr_number_override }} - DRAFT_RELEASE_URL_OVERRIDE: ${{ inputs.draft_release_url_override }} - FORCE_UPDATE: ${{ inputs.force_update }} - TRIGGER_TYPE: ${{ inputs.trigger_type }} - COMMON_CACHE_STATUS: ${{ inputs.common_cache_status }} - COMMON_CACHE_DETAILS: ${{ inputs.common_cache_details }} - COMMON_SYNC_PR_URL: ${{ inputs.common_sync_pr_url }} - REPO: ${{ github.repository }} - SCRIPTS_PATH: ${{ github.action_path }}/../../release_automation/scripts - run: | - import os - import sys - # Add scripts path to module search path - scripts_path = os.environ['SCRIPTS_PATH'] - # Go up two levels: scripts -> release_automation -> tooling root - tooling_root = os.path.dirname(os.path.dirname(scripts_path)) - sys.path.insert(0, tooling_root) - print(f"Added to sys.path: {tooling_root}") - - from release_automation.scripts.github_client import GitHubClient - from release_automation.scripts.state_manager import ReleaseStateManager, ReleaseState - from release_automation.scripts.issue_manager import IssueManager - from release_automation.scripts.bot_responder import BotResponder - from release_automation.scripts.issue_sync import IssueSyncManager - - # Get inputs - repo = os.environ['REPO'] - token = os.environ['GITHUB_TOKEN'] - release_tag = os.environ.get('RELEASE_TAG', '').strip() - current_state = os.environ.get('CURRENT_STATE', '').strip() - state_override_raw = os.environ.get('STATE_OVERRIDE', '').strip() - snapshot_branch_override = os.environ.get('SNAPSHOT_BRANCH_OVERRIDE', '').strip() - release_pr_number_override = os.environ.get('RELEASE_PR_NUMBER_OVERRIDE', '').strip() - draft_release_url_override = os.environ.get('DRAFT_RELEASE_URL_OVERRIDE', '').strip() - force_update = os.environ.get('FORCE_UPDATE', 'false').strip().lower() == 'true' - trigger_type = os.environ.get('TRIGGER_TYPE', 'workflow_dispatch') - common_cache_status = os.environ.get('COMMON_CACHE_STATUS', '').strip() - common_cache_details = os.environ.get('COMMON_CACHE_DETAILS', '').strip() - common_sync_pr_url = os.environ.get('COMMON_SYNC_PR_URL', '').strip() - - output_file = os.environ['GITHUB_OUTPUT'] - - def parse_state(value): - normalized = value.strip().lower().replace('-', '_') - if not normalized: - return None - - for state in ReleaseState: - state_value = state.value.lower().replace('-', '_') - if normalized in {state_value, state.name.lower()}: - return state - - raise ValueError(f"Unknown release state override: {value}") - - def write_outputs(issue_number='', issue_action='skipped', issue_url=''): - """Write outputs to GITHUB_OUTPUT file.""" - with open(output_file, 'a') as f: - f.write(f"issue_number={issue_number}\n") - f.write(f"issue_action={issue_action}\n") - f.write(f"issue_url={issue_url}\n") - - try: - state_override = parse_state(state_override_raw) - except ValueError as e: - print(f"::error::{e}") - write_outputs(issue_action='error') - sys.exit(1) - - requested_state = state_override_raw or current_state - context_source = 'override' if state_override else 're-derived' - - print("Effective sync context:") - print(f" Source: {context_source}") - print(f" Requested state: {requested_state or '(none)'}") - print(f" Snapshot branch override: {snapshot_branch_override or '(none)'}") - print(f" Release PR override: {release_pr_number_override or '(none)'}") - print(f" Draft release URL override present: {'yes' if draft_release_url_override else 'no'}") - print(f" Force update: {force_update}") - - # Skip if no release tag or state indicates no action needed - if not release_tag: - print("No release tag provided - skipping issue sync") - write_outputs(issue_action='skipped') - sys.exit(0) - - # Skip for states where no issue management is needed - skip_states = ['published', ''] - if requested_state.lower() in skip_states: - print(f"State '{requested_state}' does not require issue sync - skipping") - # Write workflow summary for visibility - summary_file = os.environ.get('GITHUB_STEP_SUMMARY', '') - if summary_file: - with open(summary_file, 'a') as f: - if requested_state.lower() == 'published': - f.write(f"## Release Status: Published\n\n") - f.write(f"Release `{release_tag}` has already been published. No further actions needed.\n") - else: - f.write(f"## Release Status\n\n") - f.write(f"No release state detected for `{release_tag}`. Check release-plan.yaml configuration.\n") - write_outputs(issue_action='skipped') - sys.exit(0) - - # Initialize clients - print(f"Initializing clients for {repo}") - gh = GitHubClient(repo=repo, token=token) - state_manager = ReleaseStateManager(github_client=gh) - issue_manager = IssueManager() - bot_responder = BotResponder() - - # Create IssueSyncManager - sync_manager = IssueSyncManager( - github_client=gh, - state_manager=state_manager, - issue_manager=issue_manager, - bot_responder=bot_responder - ) - - # Read release-plan.yaml to get full configuration - print(f"Reading release-plan.yaml for {release_tag}") - release_plan = gh.get_file_content('release-plan.yaml', ref='main') - - if release_plan is None: - print("::warning::Could not read release-plan.yaml - skipping issue sync") - write_outputs(issue_action='skipped') - sys.exit(0) - - # Parse YAML - import yaml - try: - release_plan_data = yaml.safe_load(release_plan) - except yaml.YAMLError as e: - print(f"::warning::Could not parse release-plan.yaml: {e}") - write_outputs(issue_action='skipped') - sys.exit(0) - - if not isinstance(release_plan_data, dict): - print("::warning::release-plan.yaml is not a valid YAML mapping") - write_outputs(issue_action='skipped') - sys.exit(0) - - # Verify release tag matches - plan_tag = release_plan_data.get('repository', {}).get('target_release_tag', '') - if plan_tag != release_tag: - print(f"::warning::release-plan.yaml tag '{plan_tag}' doesn't match input '{release_tag}'") - # Continue anyway - use the input release_tag as authoritative - - # Call sync_release_issue - print(f"Syncing Release Issue for {release_tag} (state: {requested_state or current_state})") - try: - result = sync_manager.sync_release_issue( - release_plan_data, - state_override=state_override, - snapshot_branch_override=snapshot_branch_override or None, - release_pr_number_override=release_pr_number_override or None, - draft_release_url_override=draft_release_url_override or None, - force_update=force_update, - common_cache_status=common_cache_status, - common_cache_details=common_cache_details, - common_sync_pr_url=common_sync_pr_url, - ) - - # Extract results - action = result.action # 'created', 'updated', 'none' - issue = result.issue - reason = result.reason - - if action == 'none': - print(f"No action taken: {reason}") - # Write workflow summary when no issue exists for not-planned state - if reason == 'no_planned_release': - summary_file = os.environ.get('GITHUB_STEP_SUMMARY', '') - if summary_file: - with open(summary_file, 'a') as f: - f.write(f"## Release Status: Not Planned\n\n") - f.write(f"No release is currently planned for `{release_tag}` (`target_release_type: none`).\n") - f.write(f"No open Release Issue exists.\n") - write_outputs(issue_action='unchanged') - elif issue: - issue_number = issue.get('number', '') - issue_url = issue.get('html_url', '') - print(f"Issue {action}: #{issue_number}") - print(f" URL: {issue_url}") - write_outputs( - issue_number=str(issue_number), - issue_action=action, - issue_url=issue_url - ) - else: - print(f"Action {action} but no issue returned") - write_outputs(issue_action=action) - - except Exception as e: - print(f"::error::Issue sync failed: {e}") - import traceback - traceback.print_exc() - write_outputs(issue_action='error') - sys.exit(1) - - print("Issue sync completed successfully") diff --git a/validation/tests/test_release_automation_workflow.py b/validation/tests/test_release_automation_workflow.py index 5c35a837..6e70ac82 100644 --- a/validation/tests/test_release_automation_workflow.py +++ b/validation/tests/test_release_automation_workflow.py @@ -69,7 +69,7 @@ def test_tooling_checkouts_resolve_through_job_context(): and step.get("with", {}).get("path") == "_tooling" ] - assert len(checkouts) == 7 + assert len(checkouts) == 6 for step in checkouts: assert step["with"]["repository"] == "${{ job.workflow_repository }}" assert step["with"]["ref"] == "${{ job.workflow_sha }}" @@ -171,3 +171,30 @@ def test_folded_jobs_are_gone_and_compat_outputs_cover_every_reader(): if name != "plan": needs = job.get("needs", []) assert "plan" in ([needs] if isinstance(needs, str) else needs), name + + +def test_finalize_replaces_the_tail_jobs(): + text = WORKFLOW.read_text(encoding="utf-8") + jobs = yaml.safe_load(text)["jobs"] + + for gone in ("update-issue", "post-result", "create-sync-pr"): + assert gone not in jobs + assert f"needs.{gone}." not in text + + finalize = jobs["finalize"] + assert finalize["if"] == "always() && needs.plan.outputs.finalize == 'true'" + assert set(finalize["needs"]) == set(jobs) - {"finalize"} + step = _step_by_name(finalize["steps"], "Finalize") + assert step["run"].strip() == "python -m release_automation.scripts.finalize" + assert step["env"]["NEEDS_JSON"] == "${{ toJSON(needs) }}" + + +def test_finalize_reads_only_defined_outputs(): + """finalize.py reads plan and command-job outputs from toJSON(needs), not expressions.""" + jobs = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"] + source = (WORKFLOW.parents[2] / "release_automation" / "scripts" / "finalize.py").read_text(encoding="utf-8") + + plan_reads = set(re.findall(r'\.plan\("([a-z_]+)"\)', source)) + assert plan_reads and plan_reads <= set(jobs["plan"]["outputs"]) + for job, name in re.findall(r'\.out\("([a-z-]+)", "([a-z_]+)"\)', source): + assert name in jobs[job]["outputs"], (job, name)