From 93373c47c061f25160c27542df05a599b081bdc1 Mon Sep 17 00:00:00 2001 From: Herbert Damker <52109189+hdamker@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:33:43 +0200 Subject: [PATCH] refactor(ra): replace the command jobs with act and snapshot jobs that emit one JSON outcome --- .../workflows/release-automation-reusable.yml | 855 ++---------------- release_automation/scripts/act.py | 130 +++ release_automation/scripts/command_policy.py | 113 ++- release_automation/scripts/finalize.py | 179 ++-- .../scripts/release_publisher.py | 8 +- release_automation/tests/test_act.py | 217 +++++ .../tests/test_command_policy.py | 121 ++- release_automation/tests/test_finalize.py | 94 +- .../tests/test_release_automation_workflow.py | 89 +- 9 files changed, 851 insertions(+), 955 deletions(-) create mode 100644 release_automation/scripts/act.py create mode 100644 release_automation/tests/test_act.py diff --git a/.github/workflows/release-automation-reusable.yml b/.github/workflows/release-automation-reusable.yml index caeefaa..f00359a 100644 --- a/.github/workflows/release-automation-reusable.yml +++ b/.github/workflows/release-automation-reusable.yml @@ -2,10 +2,14 @@ # # This reusable workflow orchestrates the CAMARA release automation process. # It handles: -# - Slash commands: /create-snapshot, /discard-snapshot, /delete-draft, /sync-issue +# - Slash commands: /create-snapshot, /discard-snapshot, /delete-draft, +# /publish-release, /sync-issue # - Issue events: close (with auto-reopen if needed), reopen # - PR merge events: on release-snapshot branches (draft creation) -# - Manual dispatch: for testing and recovery +# - Manual dispatch and push: Release Issue sync +# +# Four jobs: plan decides (one JSON plan), snapshot or act executes the +# plan's action (one JSON outcome), finalize reports and syncs. # # Called by: release-automation-caller.yml in API repositories @@ -38,7 +42,7 @@ permissions: jobs: # ───────────────────────────────────────────────────────────────────────────── - # Phase 1: Plan — classify the trigger, derive state, apply the command + # Plan — classify the trigger, derive state, apply the command # policy, post the one response (ack update, rejection, config error or # publish confirmation). # ───────────────────────────────────────────────────────────────────────────── @@ -46,45 +50,11 @@ jobs: runs-on: ubuntu-latest outputs: plan: ${{ steps.policy.outputs.plan }} + action: ${{ steps.policy.outputs.action }} 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. - trigger_type: ${{ steps.detect.outputs.trigger_type }} - command: ${{ steps.detect.outputs.command }} - command_args: ${{ steps.detect.outputs.command_args }} - issue_number: ${{ steps.detect.outputs.issue_number }} - user: ${{ steps.detect.outputs.user }} - should_continue: ${{ steps.detect.outputs.should_continue }} - confirm_tag: ${{ steps.detect.outputs.confirm_tag }} - trigger: ${{ steps.detect.outputs.trigger }} - release_tag: ${{ steps.state.outputs.release_tag }} - state: ${{ steps.state.outputs.state }} - snapshot_id: ${{ steps.state.outputs.snapshot_id }} - snapshot_branch: ${{ steps.state.outputs.snapshot_branch }} - release_review_branch: ${{ steps.state.outputs.release_review_branch }} - release_pr_number: ${{ steps.state.outputs.release_pr_number }} - release_issue_number: ${{ steps.state.outputs.release_issue_number }} - src_commit_sha: ${{ steps.state.outputs.src_commit_sha }} - release_type: ${{ steps.state.outputs.release_type }} - apis_json: ${{ steps.state.outputs.apis_json }} - commonalities_release: ${{ steps.state.outputs.commonalities_release }} - identity_consent_management_release: ${{ steps.state.outputs.identity_consent_management_release }} - source: ${{ steps.state.outputs.source }} - config_error: ${{ steps.state.outputs.config_error }} - config_error_type: ${{ steps.state.outputs.config_error_type }} - release_plan_url: ${{ steps.state.outputs.release_plan_url }} - src_commit_sha_short: ${{ steps.state.outputs.src_commit_sha_short }} - meta_release: ${{ steps.state.outputs.meta_release }} - common_cache_status: ${{ steps.cache-sync.outputs.common_cache_status }} - common_cache_details: ${{ steps.cache-sync.outputs.common_cache_details }} - common_sync_pr_url: ${{ steps.cache-sync.outputs.common_sync_pr_url }} - allowed: ${{ steps.policy.outputs.allowed }} - error_message: ${{ steps.policy.outputs.error_message }} - base_context: ${{ steps.assemble.outputs.base_context }} - comment_id: ${{ steps.respond.outputs.comment_id || steps.ack.outputs.comment_id }} - # True where the former post-interim job succeeded - interim_posted: ${{ steps.policy.outputs.response == 'interim_processing' && steps.respond.outcome == 'success' }} + # The acknowledgment updated with interim_processing; finalize replaces + # it with the result comment. + result_comment_id: ${{ steps.policy.outputs.response == 'interim_processing' && steps.respond.outcome == 'success' && steps.respond.outputs.comment_id || '' }} steps: - name: Fork Guard - Check Issues Enabled id: fork-guard @@ -204,25 +174,6 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: python -m release_automation.scripts.common_sync - # ── Command policy: Plan or Rejection, and the response to post ── - - name: Apply Command Policy - id: policy - if: steps.detect.outputs.should_continue == 'true' - working-directory: _tooling - env: - TRIGGER_JSON: ${{ steps.detect.outputs.trigger }} - STATE: ${{ steps.state.outputs.state }} - RELEASE_TAG: ${{ steps.state.outputs.release_tag }} - RELEASE_TYPE: ${{ steps.state.outputs.release_type }} - SRC_COMMIT_SHA: ${{ steps.state.outputs.src_commit_sha }} - CONFIG_ERROR: ${{ steps.state.outputs.config_error }} - CONFIG_ERROR_TYPE: ${{ steps.state.outputs.config_error_type }} - COMMON_CACHE_STATUS: ${{ steps.cache-sync.outputs.common_cache_status }} - COMMON_CACHE_DETAILS: ${{ steps.cache-sync.outputs.common_cache_details }} - COMMON_SYNC_PR_URL: ${{ steps.cache-sync.outputs.common_sync_pr_url }} - GITHUB_TOKEN: ${{ github.token }} - run: python -m release_automation.scripts.command_policy - # ── Assemble Context (skipped on config error) ── - name: Assemble Context id: assemble @@ -252,6 +203,21 @@ jobs: CTX_COMMON_CACHE_DETAILS: ${{ steps.cache-sync.outputs.common_cache_details }} GITHUB_TOKEN: ${{ github.token }} + # ── Command policy: Plan or Rejection, the action, the response ── + - name: Apply Command Policy + id: policy + if: steps.detect.outputs.should_continue == 'true' + working-directory: _tooling + env: + TRIGGER_JSON: ${{ steps.detect.outputs.trigger }} + STATE_JSON: ${{ toJSON(steps.state.outputs) }} + BASE_CONTEXT: ${{ steps.assemble.outputs.base_context }} + COMMON_CACHE_STATUS: ${{ steps.cache-sync.outputs.common_cache_status }} + COMMON_CACHE_DETAILS: ${{ steps.cache-sync.outputs.common_cache_details }} + COMMON_SYNC_PR_URL: ${{ steps.cache-sync.outputs.common_sync_pr_url }} + GITHUB_TOKEN: ${{ github.token }} + run: python -m release_automation.scripts.command_policy + # ── Response: one bot comment, template chosen by the policy ── # interim_processing and command_rejected update the ack with GITHUB_TOKEN # (no extra issue_comment runs); config_error and publish_confirmation use @@ -316,23 +282,15 @@ jobs: exit 1 # ───────────────────────────────────────────────────────────────────────────── - # Phase 5b: Execute create-snapshot command + # Snapshot — /create-snapshot: pre-snapshot validation, snapshot branch, + # release-review branch and Release PR. Own job for the validation toolchain. # ───────────────────────────────────────────────────────────────────────────── - create-snapshot: + snapshot: needs: [plan] - if: | - needs.plan.outputs.command == 'create-snapshot' && - needs.plan.outputs.allowed == 'true' + if: needs.plan.outputs.action == 'create-snapshot' runs-on: ubuntu-latest outputs: - success: ${{ steps.create.outputs.success || steps.validation-gate.outputs.success }} - snapshot_id: ${{ steps.create.outputs.snapshot_id }} - snapshot_branch: ${{ steps.create.outputs.snapshot_branch }} - release_review_branch: ${{ steps.create.outputs.release_review_branch }} - release_pr_number: ${{ steps.create.outputs.release_pr_number }} - release_pr_url: ${{ steps.create.outputs.release_pr_url }} - error_message: ${{ steps.create.outputs.error_message || steps.validation-gate.outputs.error_message }} - apis_json: ${{ steps.create.outputs.apis_json }} + outcome: ${{ steps.outcome.outputs.outcome }} steps: - name: Generate App Token id: app-token @@ -508,153 +466,35 @@ jobs: id: create uses: ./_tooling/shared-actions/create-snapshot with: - release_tag: ${{ needs.plan.outputs.release_tag }} + release_tag: ${{ fromJSON(needs.plan.outputs.plan).release_tag }} base_branch: main github_token: ${{ steps.app-token.outputs.token || github.token }} tooling_path: ${{ github.workspace }}/_tooling bot_name: ${{ steps.bot-identity.outputs.bot_name }} bot_email: ${{ steps.bot-identity.outputs.bot_email }} - - name: Log Result + - name: Outcome + id: outcome if: always() - run: | - echo "Pre-snapshot validation: ${{ steps.validation.outputs.result || 'skipped' }}" - if [ "${{ steps.validation-gate.outcome }}" == "failure" ]; then - echo " Validation blocked snapshot creation" - echo " Summary: ${{ steps.validation.outputs.summary }}" - fi - echo "Create snapshot result:" - echo " Success: ${{ steps.create.outputs.success }}" - echo " Snapshot ID: ${{ steps.create.outputs.snapshot_id }}" - echo " Snapshot branch: ${{ steps.create.outputs.snapshot_branch }}" - echo " Release PR: #${{ steps.create.outputs.release_pr_number }}" - if [ -n "${{ steps.create.outputs.error_message }}" ]; then - echo " Error: ${{ steps.create.outputs.error_message }}" - fi - - # ───────────────────────────────────────────────────────────────────────────── - # Phase 5c: Execute discard-snapshot command - # ───────────────────────────────────────────────────────────────────────────── - discard-snapshot: - needs: [plan] - if: | - needs.plan.outputs.command == 'discard-snapshot' && - needs.plan.outputs.allowed == 'true' - runs-on: ubuntu-latest - outputs: - success: ${{ steps.discard.outputs.success }} - error_message: ${{ steps.discard.outputs.error_message }} - renamed_review_branch: ${{ steps.cleanup.outputs.renamed_review_branch }} - 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: Discard Snapshot - id: discard - uses: actions/github-script@v9 - with: - github-token: ${{ steps.app-token.outputs.token || github.token }} - script: | - const snapshotBranch = '${{ needs.plan.outputs.snapshot_branch }}'; - const prNumber = '${{ needs.plan.outputs.release_pr_number }}'; - const releaseTag = '${{ needs.plan.outputs.release_tag }}'; - - console.log(`Discarding snapshot for ${releaseTag}`); - console.log(` Snapshot branch: ${snapshotBranch}`); - console.log(` Release PR: #${prNumber}`); - - try { - // Step 1: Close Release PR (without merging) - if (prNumber) { - console.log(`Closing Release PR #${prNumber}...`); - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: parseInt(prNumber), - state: 'closed' - }); - console.log('Release PR closed'); - } - - // Step 2: Delete snapshot branch - if (snapshotBranch) { - console.log(`Deleting snapshot branch: ${snapshotBranch}...`); - await github.rest.git.deleteRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `heads/${snapshotBranch}` - }); - console.log('Snapshot branch deleted'); - } - - core.setOutput('success', 'true'); - core.setOutput('error_message', ''); - } catch (error) { - console.log(`::error::Discard failed: ${error.message}`); - core.setOutput('success', 'false'); - core.setOutput('error_message', error.message); - } - - # Step 3: Rename review branch to free name for next create-snapshot - - name: Cleanup review branch - id: cleanup - if: steps.discard.outputs.success == 'true' - uses: actions/github-script@v9 - with: - github-token: ${{ steps.app-token.outputs.token || github.token }} - script: | - const reviewBranch = '${{ needs.plan.outputs.release_review_branch }}'; - if (!reviewBranch) return; - - const newName = `${reviewBranch}-preserved`; - try { - const ref = await github.rest.git.getRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `heads/${reviewBranch}` - }); - - // Delete old -preserved branch if it exists (repeated cycles) - try { - await github.rest.git.deleteRef({ - owner: context.repo.owner, repo: context.repo.repo, - ref: `heads/${newName}` - }); - console.log(`Deleted previous ${newName}`); - } catch (_) { /* doesn't exist */ } - - await github.rest.git.createRef({ - owner: context.repo.owner, repo: context.repo.repo, - ref: `refs/heads/${newName}`, sha: ref.data.object.sha - }); - await github.rest.git.deleteRef({ - owner: context.repo.owner, repo: context.repo.repo, - ref: `heads/${reviewBranch}` - }); - console.log(`Renamed: ${reviewBranch} → ${newName}`); - core.setOutput('renamed_review_branch', newName); - } catch (e) { - console.log(`Could not rename review branch: ${e.message}`); - } + working-directory: _tooling + env: + CREATE_JSON: ${{ toJSON(steps.create.outputs) }} + GATE_JSON: ${{ toJSON(steps.validation-gate.outputs) }} + VALIDATION_RESULT: ${{ steps.validation.outputs.result }} + run: python -m release_automation.scripts.act snapshot # ───────────────────────────────────────────────────────────────────────────── - # Phase 5d: Execute delete-draft command + # Act — the plan's other actions (discard-snapshot, delete-draft, + # publish-release, create-draft, publish-on-merge, reopen-issue), dispatched + # by release_automation/scripts/act.py. # ───────────────────────────────────────────────────────────────────────────── - delete-draft: + act: + name: act (${{ needs.plan.outputs.action }}) needs: [plan] - if: | - needs.plan.outputs.command == 'delete-draft' && - needs.plan.outputs.allowed == 'true' + if: needs.plan.outputs.action != '' && needs.plan.outputs.action != 'create-snapshot' runs-on: ubuntu-latest outputs: - success: ${{ steps.delete.outputs.success }} - error_message: ${{ steps.delete.outputs.error_message }} - renamed_review_branch: ${{ steps.cleanup.outputs.renamed_review_branch }} + outcome: ${{ steps.act.outputs.outcome }} steps: - name: Generate App Token id: app-token @@ -664,133 +504,6 @@ jobs: client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - - name: Delete Draft Release - id: delete - uses: actions/github-script@v9 - with: - github-token: ${{ steps.app-token.outputs.token || github.token }} - script: | - const releaseTag = '${{ needs.plan.outputs.release_tag }}'; - const snapshotBranch = '${{ needs.plan.outputs.snapshot_branch }}'; - - console.log(`Deleting draft release for ${releaseTag}`); - console.log(` Snapshot branch: ${snapshotBranch}`); - - try { - // Step 1: Find and delete the draft release - const releases = await github.rest.repos.listReleases({ - owner: context.repo.owner, - repo: context.repo.repo - }); - - const draftRelease = releases.data.find( - r => r.tag_name === releaseTag && r.draft === true - ); - - if (draftRelease) { - console.log(`Deleting draft release: ${draftRelease.name} (ID: ${draftRelease.id})`); - await github.rest.repos.deleteRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - release_id: draftRelease.id - }); - console.log('Draft release deleted'); - } else { - console.log(`Warning: No draft release found for tag ${releaseTag}`); - } - - // Step 2: Delete the release tag (created by draft release) - try { - await github.rest.git.deleteRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `tags/${releaseTag}` - }); - console.log(`Deleted tag: ${releaseTag}`); - } catch (e) { - // Tag may not exist (draft may not have created it) - console.log(`Tag ${releaseTag} not found or already deleted`); - } - - // Step 3: Delete snapshot branch - if (snapshotBranch) { - console.log(`Deleting snapshot branch: ${snapshotBranch}...`); - await github.rest.git.deleteRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `heads/${snapshotBranch}` - }); - console.log('Snapshot branch deleted'); - } - - core.setOutput('success', 'true'); - core.setOutput('error_message', ''); - } catch (error) { - console.log(`::error::Delete draft failed: ${error.message}`); - core.setOutput('success', 'false'); - core.setOutput('error_message', error.message); - } - - # Step 4: Rename review branch to free name for next create-snapshot - - name: Cleanup review branch - id: cleanup - if: steps.delete.outputs.success == 'true' - uses: actions/github-script@v9 - with: - github-token: ${{ steps.app-token.outputs.token || github.token }} - script: | - const reviewBranch = '${{ needs.plan.outputs.release_review_branch }}'; - if (!reviewBranch) return; - - const newName = `${reviewBranch}-preserved`; - try { - const ref = await github.rest.git.getRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `heads/${reviewBranch}` - }); - - // Delete old -preserved branch if it exists (repeated cycles) - try { - await github.rest.git.deleteRef({ - owner: context.repo.owner, repo: context.repo.repo, - ref: `heads/${newName}` - }); - console.log(`Deleted previous ${newName}`); - } catch (_) { /* doesn't exist */ } - - await github.rest.git.createRef({ - owner: context.repo.owner, repo: context.repo.repo, - ref: `refs/heads/${newName}`, sha: ref.data.object.sha - }); - await github.rest.git.deleteRef({ - owner: context.repo.owner, repo: context.repo.repo, - ref: `heads/${reviewBranch}` - }); - console.log(`Renamed: ${reviewBranch} → ${newName}`); - core.setOutput('renamed_review_branch', newName); - } catch (e) { - console.log(`Could not rename review branch: ${e.message}`); - } - - # ───────────────────────────────────────────────────────────────────────────── - # Phase 5f: Handle publish-release (with --confirm flag) - # ───────────────────────────────────────────────────────────────────────────── - publish-release: - name: "Publish Release" - needs: [plan] - if: | - needs.plan.outputs.command == 'publish-release' && - needs.plan.outputs.confirm_tag != '' && - needs.plan.outputs.allowed == 'true' - runs-on: ubuntu-latest - outputs: - success: ${{ steps.publish.outputs.success }} - release_url: ${{ steps.publish.outputs.release_url }} - reference_tag: ${{ steps.publish.outputs.reference_tag }} - pointer_branch: ${{ steps.publish.outputs.pointer_branch }} - error_message: ${{ steps.publish.outputs.error_message }} - steps: - name: Checkout tooling uses: actions/checkout@v7 with: @@ -812,476 +525,22 @@ jobs: - name: Install dependencies run: pip install --quiet -r _tooling/requirements.txt - - 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: Execute Publish Flow - id: publish - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} - OWNER: ${{ github.repository_owner }} - REPO: ${{ github.event.repository.name }} - RELEASE_TAG: ${{ needs.plan.outputs.release_tag }} - SNAPSHOT_BRANCH: ${{ needs.plan.outputs.snapshot_branch }} - RELEASE_REVIEW_BRANCH: ${{ needs.plan.outputs.release_review_branch }} - SRC_COMMIT_SHA: ${{ needs.plan.outputs.src_commit_sha }} - RELEASE_ISSUE_NUMBER: ${{ needs.plan.outputs.release_issue_number }} - run: | - cd _tooling - python3 -u - <<'PYTHON_SCRIPT' - import os - import sys - import json - - sys.path.insert(0, '.') - from release_automation.scripts.github_client import GitHubClient - from release_automation.scripts.release_publisher import ReleasePublisher - - def set_output(name, value): - with open(os.environ['GITHUB_OUTPUT'], 'a') as f: - # Handle multi-line values - if '\n' in str(value): - import uuid - delimiter = uuid.uuid4().hex - f.write(f'{name}<<{delimiter}\n{value}\n{delimiter}\n') - else: - f.write(f'{name}={value}\n') - - # Initialize clients - token = os.environ['GITHUB_TOKEN'] - owner = os.environ['OWNER'] - repo = os.environ['REPO'] - release_tag = os.environ['RELEASE_TAG'] - snapshot_branch = os.environ['SNAPSHOT_BRANCH'] - release_review_branch = os.environ['RELEASE_REVIEW_BRANCH'] - src_commit_sha = os.environ['SRC_COMMIT_SHA'] - - gh = GitHubClient(f'{owner}/{repo}', token) - publisher = ReleasePublisher(gh) - # Note: the sync PR and the issue close are done in the finalize job - - warnings = [] - release_url = '' - reference_tag = '' - pointer_branch = '' - - # Step 1: Publish the release (CRITICAL) - print('Step 1: Publishing release...') - publish_result = publisher.publish_release(release_tag, snapshot_branch) - if not publish_result.success: - set_output('success', 'false') - set_output('error_message', publish_result.error_message or 'Unknown error') - set_output('release_url', '') - set_output('reference_tag', '') - set_output('pointer_branch', '') - print(f'::error::Publication failed: {publish_result.error_message}') - sys.exit(0) # Exit cleanly; finalize posts publish_failed - - release_url = publish_result.release_url or '' - is_prerelease = publish_result.is_prerelease - print(f'Release published: {release_url}') - - # Step 2: Create reference tag (non-critical) - print('Step 2: Creating reference tag...') - ref_tag = publisher.create_reference_tag(release_tag, src_commit_sha) - if ref_tag: - reference_tag = ref_tag - print(f'Reference tag created: {reference_tag}') - else: - warnings.append('Failed to create reference tag') - print('::warning::Failed to create reference tag') - - # Step 3: Create pointer branch (non-critical) - print('Step 3: Creating pointer branch...') - ptr_branch = publisher.create_pointer_branch(release_tag, is_prerelease) - if ptr_branch: - pointer_branch = ptr_branch - print(f'Pointer branch created: {pointer_branch}') - else: - warnings.append('Failed to create pointer branch') - print('::warning::Failed to create pointer branch') - - # Step 4: Cleanup branches (non-critical) - print('Step 4: Cleaning up branches...') - cleanup_result = publisher.cleanup_branches(snapshot_branch, release_review_branch) - for op, status in cleanup_result.items(): - if status == 'error': - warnings.append(f'Branch cleanup issue: {op}') - print(f' {op}: {status}') - - # Set outputs - set_output('success', 'true') - set_output('release_url', release_url) - set_output('reference_tag', reference_tag) - set_output('pointer_branch', pointer_branch) - if warnings: - set_output('error_message', '; '.join(warnings)) - else: - set_output('error_message', '') - - print('Publish flow completed successfully') - PYTHON_SCRIPT - - # ───────────────────────────────────────────────────────────────────────────── - # Phase 5g: Handle PR merge (draft release creation) - # ───────────────────────────────────────────────────────────────────────────── - handle-pr-merge: - needs: [plan] - # Runs on a config error too (the plan job then fails on purpose), as it - # did when a separate job reported the config error. - if: | - !cancelled() && - needs.plan.outputs.trigger_type == 'pr_merge' && - (needs.plan.result == 'success' || needs.plan.outputs.config_error != '') - runs-on: ubuntu-latest - outputs: - success: ${{ steps.create-draft.outputs.success }} - draft_release_url: ${{ steps.create-draft.outputs.draft_release_url }} - error_message: ${{ steps.create-draft.outputs.error_message }} - 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: Extract CHANGELOG release notes - id: changelog - uses: actions/github-script@v9 - with: - github-token: ${{ steps.app-token.outputs.token || github.token }} - script: | - const snapshotBranch = '${{ needs.plan.outputs.snapshot_branch }}'; - const releaseTag = '${{ needs.plan.outputs.release_tag }}'; - - // Derive meta-release from tag: r4.1 → r4, r3.2 → r3 - const metaRelease = releaseTag.match(/^(r\d+)/)?.[1] || ''; - - // Try CHANGELOG/CHANGELOG-rX.md first (new convention), fall back to CHANGELOG.md - const candidates = []; - if (metaRelease) { - candidates.push(`CHANGELOG/CHANGELOG-${metaRelease}.md`); - } - candidates.push('CHANGELOG.md'); - - let changelogContent = ''; - let changelogPath = ''; - let changelogSha = ''; - for (const path of candidates) { - try { - const response = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: path, - ref: snapshotBranch - }); - changelogContent = Buffer.from(response.data.content, 'base64').toString('utf-8'); - changelogPath = path; - changelogSha = response.data.sha; - console.log(`Fetched ${path} (${changelogContent.length} chars)`); - break; - } catch (error) { - console.log(`${path} not found, trying next...`); - } - } - - if (!changelogContent) { - console.log('::warning::No CHANGELOG file found on snapshot branch'); - core.setOutput('release_notes', ''); - core.setOutput('candidate_block_stripped', ''); - return; - } - - // --- Strip AUTOGENERATED:CANDIDATE_CHANGES block if present --- - const START_MARKER = ''; - const END_MARKER = ''; - - const startPos = changelogContent.indexOf(START_MARKER); - const endPos = changelogContent.indexOf(END_MARKER); - let candidateBlockStripped = false; - - if (startPos !== -1 && endPos !== -1 && endPos > startPos) { - // Both markers present — strip the entire block (inclusive) - changelogContent = ( - changelogContent.substring(0, startPos) + - changelogContent.substring(endPos + END_MARKER.length) - ).replace(/\n{3,}/g, '\n\n'); - candidateBlockStripped = true; - console.log('Stripped full candidate changes block'); - } else if (startPos !== -1 || endPos !== -1) { - // Orphan marker — codeowner partially deleted the block. - // Strip just the orphan marker line (HTML comment, always safe to remove). - const orphanMarker = startPos !== -1 ? START_MARKER : END_MARKER; - changelogContent = changelogContent - .split('\n') - .filter(line => line.trim() !== orphanMarker) - .join('\n'); - candidateBlockStripped = true; - console.log(`Stripped orphan marker: ${orphanMarker}`); - } - // If neither marker found: codeowner already removed the block — no action needed - - if (candidateBlockStripped) { - await github.rest.repos.createOrUpdateFileContents({ - owner: context.repo.owner, - repo: context.repo.repo, - path: changelogPath, - message: 'chore: auto-remove candidate changes block from CHANGELOG', - content: Buffer.from(changelogContent).toString('base64'), - sha: changelogSha, - branch: snapshotBranch - }); - console.log(`Committed cleaned CHANGELOG to ${snapshotBranch}`); - } - - core.setOutput('candidate_block_stripped', candidateBlockStripped ? 'true' : ''); - - // Extract section from "## Release Notes" to next "# rX.Y" heading or EOF - const lines = changelogContent.split('\n'); - let startIdx = -1; - let endIdx = lines.length; - - // Find "## Release Notes" line - for (let i = 0; i < lines.length; i++) { - if (lines[i].match(/^## Release Notes/i)) { - startIdx = i; - break; - } - } - - if (startIdx === -1) { - console.log(`::warning::No "## Release Notes" section found in ${changelogPath}`); - core.setOutput('release_notes', ''); - return; - } - - // Find end: next "# rX.Y" heading (top-level release heading) - for (let i = startIdx + 1; i < lines.length; i++) { - if (lines[i].match(/^# r\d+/)) { - endIdx = i; - break; - } - } - - // Trim trailing blank lines - while (endIdx > startIdx && lines[endIdx - 1].trim() === '') { - endIdx--; - } - - const releaseNotes = lines.slice(startIdx, endIdx).join('\n'); - console.log(`Extracted release notes: ${releaseNotes.length} chars (lines ${startIdx + 1}-${endIdx})`); - - // Write to output using delimiter for multiline - const outputFile = process.env.GITHUB_OUTPUT; - const fs = require('fs'); - const delimiter = 'RELEASE_NOTES_EOF'; - fs.appendFileSync(outputFile, `release_notes<<${delimiter}\n${releaseNotes}\n${delimiter}\n`); - - - name: Create Draft Release - id: create-draft - uses: actions/github-script@v9 + - name: Act + id: act + working-directory: _tooling env: - RELEASE_NOTES: ${{ steps.changelog.outputs.release_notes }} - with: - github-token: ${{ steps.app-token.outputs.token || github.token }} - script: | - const releaseTag = '${{ needs.plan.outputs.release_tag }}'; - const snapshotBranch = '${{ needs.plan.outputs.snapshot_branch }}'; - const releaseType = '${{ needs.plan.outputs.release_type }}'; - const releaseNotes = process.env.RELEASE_NOTES || ''; - - console.log(`Creating draft release for ${releaseTag}`); - console.log(` Snapshot branch: ${snapshotBranch}`); - console.log(` Release type: ${releaseType}`); - - try { - // Determine if this is a pre-release - const isPrerelease = releaseType.startsWith('pre-release'); - - // Build release body from CHANGELOG content or fallback - let body = ''; - if (releaseNotes) { - body = releaseNotes; - } else { - body = `## Release ${releaseTag}\n\n`; - body += `**Release type:** ${releaseType}\n\n`; - body += `---\n\n`; - body += `_CHANGELOG.md content was not available — please add release notes manually._\n`; - } - - // Create draft release - const release = await github.rest.repos.createRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - tag_name: releaseTag, - target_commitish: snapshotBranch, - name: releaseTag, - body: body, - draft: true, - prerelease: isPrerelease - }); - - console.log(`Draft release created: ${release.data.html_url}`); - core.setOutput('success', 'true'); - core.setOutput('draft_release_url', release.data.html_url); - core.setOutput('error_message', ''); - } catch (error) { - console.log(`::error::Draft release creation failed: ${error.message}`); - core.setOutput('success', 'false'); - core.setOutput('draft_release_url', ''); - core.setOutput('error_message', error.message); - } - - # release_date is set during /publish-release (finalize_metadata), not at draft creation - - - name: Checkout tooling for bot comment - if: steps.create-draft.outputs.success == 'true' && needs.plan.outputs.release_issue_number != '' - uses: actions/checkout@v7 - with: - repository: ${{ job.workflow_repository }} - ref: ${{ job.workflow_sha }} - path: _tooling - sparse-checkout: | - release_automation - shared-actions - tooling_lib - validation - linting/config - - - name: Post draft_created comment - if: steps.create-draft.outputs.success == 'true' && needs.plan.outputs.release_issue_number != '' - uses: ./_tooling/shared-actions/post-bot-comment - with: - issue_number: ${{ needs.plan.outputs.release_issue_number }} - release_tag: ${{ needs.plan.outputs.release_tag }} - run_id: ${{ github.run_id }} - template: draft_created - base_context: ${{ needs.plan.outputs.base_context }} - github_token: ${{ steps.app-token.outputs.token || github.token }} - context: | - { - "draft_release_url": "${{ steps.create-draft.outputs.draft_release_url }}", - "release_pr_number": "${{ github.event.pull_request.number }}", - "release_pr_url": "${{ github.event.pull_request.html_url }}", - "candidate_block_stripped": "${{ steps.changelog.outputs.candidate_block_stripped }}" - } - - # ───────────────────────────────────────────────────────────────────────────── - # Phase 5f: Handle issue close/reopen events - # ───────────────────────────────────────────────────────────────────────────── - handle-issue-event: - needs: [plan] - if: | - always() && - needs.plan.result == 'success' && - needs.plan.outputs.config_error == '' && - (needs.plan.outputs.trigger_type == 'issue_close' || - needs.plan.outputs.trigger_type == 'issue_reopen') - runs-on: ubuntu-latest - outputs: - action_taken: ${{ steps.handle.outputs.action_taken }} - 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 - with: - repository: ${{ job.workflow_repository }} - ref: ${{ job.workflow_sha }} - path: _tooling - sparse-checkout: | - release_automation - shared-actions - tooling_lib - validation - linting/config - - - name: Handle Issue Event - id: handle - uses: actions/github-script@v9 - with: - github-token: ${{ steps.app-token.outputs.token || github.token }} - script: | - const triggerType = '${{ needs.plan.outputs.trigger_type }}'; - const currentState = '${{ needs.plan.outputs.state }}'; - const issueNumber = parseInt('${{ needs.plan.outputs.issue_number }}'); - - console.log(`Handling ${triggerType} event`); - console.log(`Current state: ${currentState}`); - - // States that block issue closure - const blockClosureStates = ['snapshot-active', 'draft-ready']; - - if (triggerType === 'issue_close') { - // Check if we need to auto-reopen - if (blockClosureStates.includes(currentState)) { - console.log(`Auto-reopening issue - cannot close in state '${currentState}'`); - - // Reopen the issue - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - state: 'open' - }); - - core.setOutput('action_taken', 'reopened'); - console.log('Issue reopened successfully'); - } else { - console.log(`Issue closure allowed in state '${currentState}'`); - core.setOutput('action_taken', 'allowed_close'); - } - } else if (triggerType === 'issue_reopen') { - // Just log for now - no special handling needed - console.log('Issue was reopened'); - core.setOutput('action_taken', 'noted_reopen'); - } - - - name: Post Auto-Reopen Comment - if: steps.handle.outputs.action_taken == 'reopened' - 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: issue_reopened - base_context: ${{ needs.plan.outputs.base_context }} - github_token: ${{ steps.app-token.outputs.token || github.token }} - context: | - { - "reason": "Cannot close Release Issue while release is in progress" - } + PLAN_JSON: ${{ needs.plan.outputs.plan }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + run: python -m release_automation.scripts.act # ───────────────────────────────────────────────────────────────────────────── # 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. + # and notifications, result comment, published Release Issue. Runs after + # snapshot/act even when they failed; release_automation/scripts/finalize.py + # decides which parts apply from the plan and the outcome. # ───────────────────────────────────────────────────────────────────────────── finalize: - needs: - [ - plan, - create-snapshot, - discard-snapshot, - delete-draft, - publish-release, - handle-pr-merge, - handle-issue-event, - ] + needs: [plan, snapshot, act] if: always() && needs.plan.outputs.finalize == 'true' runs-on: ubuntu-latest steps: diff --git a/release_automation/scripts/act.py b/release_automation/scripts/act.py new file mode 100644 index 0000000..7064e24 --- /dev/null +++ b/release_automation/scripts/act.py @@ -0,0 +1,130 @@ +""" +Act phase of CAMARA release automation. + +Executes the action the plan names and writes one JSON outcome for finalize. +The plan decides (command_policy); this module dispatches: + + discard-snapshot draft_release.discard_snapshot + delete-draft draft_release.delete_draft + publish-release release_publisher.publish_draft + create-draft draft_release.create_draft, then the draft_created comment + publish-on-merge draft_release.publish_on_merge + reopen-issue issue_events.reopen_release_issue + +create-snapshot runs in the snapshot job (it needs the validation toolchain); +snapshot_outcome() turns that job's step outputs into the same outcome form. + +Outcome values are strings, under the output names of the former command jobs. + +Usage (workflow steps): + python3 -m release_automation.scripts.act # act job + python3 -m release_automation.scripts.act snapshot # last step of the snapshot job +""" + +import json +import os +import sys +from dataclasses import asdict +from typing import Any, Dict, List + +from .bot_responder import post_comment +from .github_client import GitHubClientError + +SNAPSHOT_FIELDS = ( + "success", "snapshot_id", "snapshot_branch", "release_review_branch", + "release_pr_number", "release_pr_url", "error_message", "apis_json", +) + + +def _outcome(action: str, result) -> Dict[str, str]: + values = {} + for name, value in asdict(result).items(): + if name == "success": + values[name] = "true" if value else "false" + elif isinstance(value, bool): + values[name] = "true" if value else "" + else: + values[name] = str(value) + return {"action": action, **values} + + +def act(plan: Dict[str, Any], gh, event: Dict[str, Any]) -> Dict[str, str]: + from .draft_release import create_draft, delete_draft, discard_snapshot, publish_on_merge + from .issue_events import reopen_release_issue + from .release_publisher import publish_draft + + action = plan["action"] + tag = plan["release_tag"] + snapshot = plan["snapshot_branch"] + review = plan["release_review_branch"] + print(f"Action: {action} ({tag})") + + match action: + case "discard-snapshot": + return _outcome(action, discard_snapshot(gh, plan["release_pr_number"], snapshot, review)) + case "delete-draft": + return _outcome(action, delete_draft(gh, tag, snapshot, review)) + case "publish-release": + return _outcome(action, publish_draft(gh, tag, snapshot, review, plan["src_commit_sha"])) + case "create-draft": + result = create_draft(gh, tag, snapshot, plan["release_type"]) + outcome = _outcome(action, result) + if result.success and plan["release_issue_number"]: + pr = event.get("pull_request") or {} + try: + post_comment(gh, "draft_created", plan["release_issue_number"], plan["base_context"], { + "draft_release_url": result.draft_release_url, + "release_pr_number": str(pr.get("number", "")), + "release_pr_url": pr.get("html_url", ""), + "candidate_block_stripped": outcome["candidate_block_stripped"], + }, tag) + except GitHubClientError as e: + print(f"::error::Could not post draft_created: {e}") + outcome["comment_failed"] = "true" + return outcome + case "publish-on-merge": + return _outcome(action, publish_on_merge( + gh, tag, snapshot, review, plan["release_type"], plan["src_commit_sha"] + )) + case "reopen-issue": + return _outcome(action, reopen_release_issue(gh, int(plan["issue_number"]), tag, plan["base_context"])) + case _: + raise ValueError(f"unknown action: {action}") + + +def snapshot_outcome(create: Dict[str, str], gate: Dict[str, str]) -> Dict[str, str]: + """Outcome of the snapshot job from the Create Snapshot and validation-gate step outputs.""" + outcome = {"action": "create-snapshot"} + for name in SNAPSHOT_FIELDS: + outcome[name] = create.get(name, "") or (gate.get(name, "") if name in ("success", "error_message") else "") + return outcome + + +def main(argv: List[str], gh=None) -> int: + from .trigger import write_outputs + + env = os.environ + if argv == ["snapshot"]: + outcome = snapshot_outcome(json.loads(env.get("CREATE_JSON") or "{}"), json.loads(env.get("GATE_JSON") or "{}")) + print(f"Pre-snapshot validation: {env.get('VALIDATION_RESULT') or 'skipped'}") + for name in ("success", "snapshot_id", "snapshot_branch", "release_pr_number", "error_message"): + print(f" {name}: {outcome[name]}") + write_outputs(env["GITHUB_OUTPUT"], {"outcome": json.dumps(outcome)}) + return 0 + + plan = json.loads(env["PLAN_JSON"]) + with open(env["GITHUB_EVENT_PATH"], encoding="utf-8") as f: + event = json.load(f) + if gh is None: + from .github_client import GitHubClient + gh = GitHubClient(repo=env["GITHUB_REPOSITORY"], token=env.get("GITHUB_TOKEN") or None) + + outcome = act(plan, gh, event) + write_outputs(env["GITHUB_OUTPUT"], {"outcome": json.dumps(outcome)}) + print(f"Outcome: {json.dumps(outcome)}") + # A failed action fails the job; finalize runs regardless and reports it. + return 0 if outcome["success"] == "true" and not outcome.get("comment_failed") else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/release_automation/scripts/command_policy.py b/release_automation/scripts/command_policy.py index 39b0b37..ac31ab1 100644 --- a/release_automation/scripts/command_policy.py +++ b/release_automation/scripts/command_policy.py @@ -10,6 +10,9 @@ Policy values that are expected to change (RM#675) sit in PUBLISH_MODES and PUBLISHERS; the initial values reproduce the behaviour before this module. +A Plan names the action the snapshot or act job executes (action_for); the +plan job writes it with the release references as one JSON document. + Usage (workflow step): python3 -m release_automation.scripts.command_policy """ @@ -62,6 +65,16 @@ WRITE_PERMISSIONS = ("admin", "maintain", "write") +# A Release Issue closed in these states is reopened +BLOCK_CLOSURE_STATES = (config.STATE_SNAPSHOT_ACTIVE, config.STATE_DRAFT_READY) + +# Actions of the snapshot job (create-snapshot) and the act job (all others) +ACTION_CREATE_SNAPSHOT = "create-snapshot" +ACTION_CREATE_DRAFT = "create-draft" +ACTION_PUBLISH_ON_MERGE = "publish-on-merge" +ACTION_REOPEN_ISSUE = "reopen-issue" +COMMAND_ACTIONS = ("create-snapshot", "discard-snapshot", "delete-draft", "publish-release") + # App bots don't register as collaborators, so the permission API returns # 'none' for them; the write intent is established by the App installation on # the target repo. Used by the Release Automation Regression canary in @@ -92,6 +105,7 @@ class Plan: response: Template the plan job posts ("" for non-slash triggers) base_branch: Branch the release is cut from publish_mode: draft | on_merge, per release type (PUBLISH_MODES) + action: What the snapshot/act job executes ("" for none; action_for) """ trigger_type: str command: str @@ -102,6 +116,7 @@ class Plan: base_branch: str = DEFAULT_BASE_BRANCH publish_mode: str = PUBLISH_MODE_DRAFT response: str = "" + action: str = "" allowed = True @@ -192,6 +207,21 @@ def _check_publisher(user: str, permission: str, release_type: str, client) -> O raise ValueError(f"unknown publisher rule: {rule}") +def action_for(plan: Plan) -> str: + """The action a plan executes, or "" when finalize alone follows.""" + match plan.trigger_type: + case "slash_command": + if plan.response == RESPONSE_INTERIM and plan.command in COMMAND_ACTIONS: + return plan.command + return "" + case "pr_merge": + return ACTION_PUBLISH_ON_MERGE if plan.publish_mode == PUBLISH_MODE_ON_MERGE else ACTION_CREATE_DRAFT + case "issue_close": + return ACTION_REOPEN_ISSUE if plan.state in BLOCK_CLOSURE_STATES else "" + case _: + return "" + + def evaluate( trigger: Trigger, release_info: ReleaseInfoResult, @@ -210,6 +240,16 @@ def evaluate( Returns: Plan to execute, or Rejection to answer """ + result = _evaluate(trigger, release_info, cache, client) + return replace(result, action=action_for(result)) if result.allowed else result + + +def _evaluate( + trigger: Trigger, + release_info: ReleaseInfoResult, + cache: CommonCache = CommonCache(), + client=None, +) -> Union[Plan, Rejection]: is_slash = trigger.trigger_type == TRIGGER_SLASH_COMMAND if not release_info.success: @@ -280,27 +320,62 @@ def finalize_due(result: Union[Plan, Rejection]) -> bool: return result.allowed and result.response != RESPONSE_PUBLISH_CONFIRMATION -def release_info_from_env(env) -> ReleaseInfoResult: +def release_info_from_state(state_outputs: dict) -> ReleaseInfoResult: """Rebuild a ReleaseInfoResult from the derive-release-state outputs.""" - if env.get("CONFIG_ERROR"): + if state_outputs.get("config_error"): return ReleaseInfoResult( success=False, config_error=ConfigurationError( - error_type=env.get("CONFIG_ERROR_TYPE", ""), - message=env["CONFIG_ERROR"], + error_type=state_outputs.get("config_error_type", ""), + message=state_outputs["config_error"], file_path=config.RELEASE_PLAN_FILE, ), ) - state = env.get("STATE", "") + state = state_outputs.get("state", "") return ReleaseInfoResult( success=True, - release_tag=env.get("RELEASE_TAG", ""), + release_tag=state_outputs.get("release_tag", ""), state=ReleaseState(state) if state else None, - release_type=env.get("RELEASE_TYPE", ""), + release_type=state_outputs.get("release_type", ""), ) -def response_context(result: Union[Plan, Rejection], trigger: Trigger, env) -> dict: +# Release references from derive-release-state that the snapshot, act and finalize jobs read +STATE_REFS = ( + "snapshot_branch", + "release_review_branch", + "release_pr_number", + "release_issue_number", + "src_commit_sha", + "commonalities_release", + "identity_consent_management_release", +) + + +def plan_document( + result: Union[Plan, Rejection], + trigger: Trigger, + state_outputs: dict, + cache: CommonCache, + base_context: dict, +) -> dict: + """The plan job's one output: the decision, plus for a Plan what the later jobs read.""" + document = json.loads(result.to_json()) + if result.allowed: + document.update( + command_args=trigger.command_args, + issue_number=trigger.issue_number, + user=trigger.user, + **{name: state_outputs.get(name, "") for name in STATE_REFS}, + common_cache_status=cache.status, + common_cache_details=cache.details, + common_sync_pr_url=cache.sync_pr_url, + base_context=base_context, + ) + return document + + +def response_context(result: Union[Plan, Rejection], trigger: Trigger, env, state_outputs: dict) -> dict: """Template variables the plan job's bot comment needs beyond the base context.""" run_url = ( f"{env.get('GITHUB_SERVER_URL', '')}/{env.get('GITHUB_REPOSITORY', '')}" @@ -322,7 +397,7 @@ def response_context(result: Union[Plan, Rejection], trigger: Trigger, env) -> d "draft_release_url": ( f"{env.get('GITHUB_SERVER_URL', '')}/{env.get('GITHUB_REPOSITORY', '')}/releases" ), - "src_commit_sha_short": env.get("SRC_COMMIT_SHA", "")[:7], + "src_commit_sha_short": state_outputs.get("src_commit_sha", "")[:7], } return {} @@ -332,12 +407,18 @@ def main(client=None) -> int: env = os.environ trigger = Trigger(**json.loads(env["TRIGGER_JSON"])) - release_info = release_info_from_env(env) + state_outputs = json.loads(env.get("STATE_JSON") or "{}") + release_info = release_info_from_state(state_outputs) cache = CommonCache( status=env.get("COMMON_CACHE_STATUS", ""), details=env.get("COMMON_CACHE_DETAILS", ""), sync_pr_url=env.get("COMMON_SYNC_PR_URL", ""), ) + try: + base_context = json.loads(env.get("BASE_CONTEXT") or "{}") + except json.JSONDecodeError: + print("::warning::Invalid base_context JSON, ignoring") + base_context = {} if client is None: client = GitHubClient(repo=env["GITHUB_REPOSITORY"], token=env.get("GITHUB_TOKEN")) @@ -347,23 +428,19 @@ def main(client=None) -> int: result = evaluate(trigger, release_info, cache, client=client) if result.allowed: - print("Command validated successfully") + print(f"Command validated successfully; action: {result.action or 'none'}") else: print(f"Rejected: {result.error_message}") - # allowed/error_message keep the meaning they had as Validate Command - # outputs: set for slash commands with a derived state, empty otherwise. - validated = trigger.trigger_type == TRIGGER_SLASH_COMMAND and release_info.success write_outputs(env["GITHUB_OUTPUT"], { - "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(), + "plan": json.dumps(plan_document(result, trigger, state_outputs, cache, base_context)), + "action": result.action if result.allowed else "", "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 "") ), - "response_context": json.dumps(response_context(result, trigger, env)), + "response_context": json.dumps(response_context(result, trigger, env, state_outputs)), }) return 0 diff --git a/release_automation/scripts/finalize.py b/release_automation/scripts/finalize.py index db969d2..f56ffb8 100644 --- a/release_automation/scripts/finalize.py +++ b/release_automation/scripts/finalize.py @@ -7,12 +7,14 @@ 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 +3. the result comment on a slash command, updating its acknowledgment; after + a publication on the Release PR merge (publish_mode on_merge), a new + comment on the Release Issue 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. +What ran before comes from toJSON(needs): the plan job's JSON plan, and the +result and JSON outcome of the snapshot or act job that executed its action. Usage (workflow step): python3 -m release_automation.scripts.finalize @@ -32,15 +34,16 @@ 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") +# Actions after which the Release Issue is synced (when they succeeded) +STATE_CHANGING_ACTIONS = ("create-snapshot", "discard-snapshot", "delete-draft", "create-draft") +PUBLISH_ACTIONS = ("publish-release", "publish-on-merge") -# Template of the result comment per command; the second is the failure template +# Template of the result comment per command: success, failure 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"), + "create-snapshot": ("snapshot_created", "snapshot_failed"), + "discard-snapshot": ("snapshot_discarded", "snapshot_failed"), + "delete-draft": ("draft_revoked", "snapshot_failed"), + "publish-release": ("release_published", "publish_failed"), } @@ -53,25 +56,57 @@ class Run: 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 _outputs(self, job: str) -> Dict[str, Any]: + return (self.needs.get(job) or {}).get("outputs") or {} - def result(self, job: str) -> str: - return (self.needs.get(job) or {}).get("result", "") + def _json(self, job: str, name: str) -> Dict[str, Any]: + return json.loads(self._outputs(job).get(name) or "{}") def plan(self, name: str) -> str: - return self.out("plan", name) + value = self._json("plan", "plan").get(name, "") + if isinstance(value, bool): + return "true" if value else "false" + return str(value or "") - 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" + @property + def base_context(self) -> Dict[str, Any]: + value = self._json("plan", "plan").get("base_context") + return value if isinstance(value, dict) else {} + + @property + def result_comment_id(self) -> str: + """The acknowledgment the plan job updated with interim_processing, "" if none.""" + return self._outputs("plan").get("result_comment_id", "") or "" + + @property + def action(self) -> str: + return self.plan("action") + + @property + def _action_job(self) -> str: + return "snapshot" if self.action == "create-snapshot" else "act" + + def out(self, name: str) -> str: + """A value of the action's outcome ("" without an action or outcome).""" + if not self.action: + return "" + return str(self._json(self._action_job, "outcome").get(name, "") or "") + + @property + def result(self) -> str: + """Conclusion of the job that executed the action.""" + return (self.needs.get(self._action_job) or {}).get("result", "") if self.action else "" + + def succeeded(self, action: str) -> bool: + """The action ran, its job concluded success and it reported success.""" + return self.action == action and self.result == "success" and self.out("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" + return self.action in PUBLISH_ACTIONS and self.out("success") == "true" # ── when each part runs ────────────────────────────────────────────────────── @@ -79,22 +114,21 @@ def published(self) -> bool: 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"): + if not run.plan("state"): return False + issue_event = run.plan("trigger_type") in ("issue_close", "issue_reopen") and ( + not run.action or run.succeeded("reopen-issue") + ) return ( - any(run.succeeded(job) for job in COMMAND_JOBS) - or run.result("handle-issue-event") == "success" + any(run.succeeded(action) for action in STATE_CHANGING_ACTIONS) + or issue_event 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" - ) + return run.plan("trigger_type") == "slash_command" and run.result_comment_id != "" # ── Release Issue sync ─────────────────────────────────────────────────────── @@ -112,17 +146,17 @@ def sync_overrides(run: Run) -> Dict[str, Any]: 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"), + snapshot_branch_override=run.out("snapshot_branch"), + release_pr_number_override=run.out("release_pr_number"), force_update=True, ) - elif run.succeeded("handle-pr-merge"): + elif run.succeeded("create-draft"): 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"), + draft_release_url_override=run.out("draft_release_url"), force_update=True, ) elif run.succeeded("discard-snapshot") or run.succeeded("delete-draft"): @@ -171,8 +205,8 @@ def result_template(run: Run) -> str: 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 + success, failure = RESULT_TEMPLATES[command] + return success if run.action == command and run.out("success") == "true" else failure def result_context(run: Run, sync: Optional[SyncPrOutcome]) -> Dict[str, str]: @@ -183,45 +217,41 @@ def result_context(run: Run, sync: Optional[SyncPrOutcome]) -> Dict[str, str]: 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)) + ok = run.out("success") == "true" + renamed = run.out("renamed_review_branch") + if run.action in ("discard-snapshot", "delete-draft") and ok 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") + if run.action == "create-snapshot" and ok: + snapshot_branch = run.out("snapshot_branch") + review_branch = run.out("release_review_branch") context.update( - snapshot_id=run.out("create-snapshot", "snapshot_id"), + snapshot_id=run.out("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"), + release_pr_number=run.out("release_pr_number"), + release_pr_url=run.out("release_pr_url"), + apis_json=run.out("apis_json"), ) if run.published: - reference_tag = run.out("publish-release", "reference_tag") + reference_tag = run.out("reference_tag") context.update( - release_url=run.out("publish-release", "release_url"), + release_url=run.out("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 "" - ) + warnings = post_publish_warnings(run.out("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 + if run.out("success") == "false": + context["error_message"] = run.out("error_message") return context @@ -266,12 +296,7 @@ class Report: def post_comment(clients: Clients, run: Run, template: str, issue: str, delta: Dict[str, Any], comment_id: str = "") -> None: from .bot_responder import post_comment as post - try: - base_context = json.loads(run.plan("base_context") or "{}") - except json.JSONDecodeError: - print("::warning::Invalid base_context JSON, ignoring") - base_context = {} - post(clients.gh, template, issue, base_context, delta, run.plan("release_tag"), comment_id=comment_id) + post(clients.gh, template, issue, run.base_context, delta, run.plan("release_tag"), comment_id=comment_id) def run_sync_pr(run: Run, clients: Clients) -> SyncPrOutcome: @@ -342,20 +367,40 @@ def run_issue_sync(run: Run, clients: Clients, report: Report, summary_path: str 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"), + result_context(run, report.sync_pr), comment_id=run.result_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 + finalize_published(run, clients, report) + + +def run_merge_publication(run: Run, clients: Clients, report: Report) -> None: + """publish_mode on_merge: the result goes to the Release Issue as a new comment.""" + report.result_template = "release_published" if run.published else "publish_failed" + issue = run.plan("release_issue_number") + if not issue: + print("::warning::No Release Issue to report the publication on") + return + try: + post_comment(clients, run, report.result_template, issue, result_context(run, report.sync_pr)) + except Exception as e: + print(f"::error::Could not post the result comment: {e}") + report.errors.append("result comment failed") + return + finalize_published(run, clients, report) + + +def finalize_published(run: Run, clients: Clients, report: Report) -> None: + """After a publication: STATE/ACTIONS, label, and the close when the sync PR was created.""" + from .issue_manager import IssueManager + from .issue_sync import IssueSyncManager if run.published and run.plan("release_issue_number"): sync = report.sync_pr or SyncPrOutcome(status="") @@ -363,11 +408,11 @@ def run_result(run: Run, clients: Clients, report: Report) -> None: 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"), + release_url=run.out("release_url"), + reference_tag=run.out("reference_tag"), sync_pr_url=sync.pr_url, sync_status=sync.status, - publish_warnings=run.out("publish-release", "error_message"), + publish_warnings=run.out("error_message"), ) @@ -380,6 +425,8 @@ def finalize(run: Run, clients: Clients, summary_path: str = "") -> Report: run_issue_sync(run, clients, report, summary_path) if result_due(run): run_result(run, clients, report) + elif run.action == "publish-on-merge": + run_merge_publication(run, clients, report) write_summary(run, report, summary_path) return report diff --git a/release_automation/scripts/release_publisher.py b/release_automation/scripts/release_publisher.py index 17cbc37..971e146 100644 --- a/release_automation/scripts/release_publisher.py +++ b/release_automation/scripts/release_publisher.py @@ -243,13 +243,19 @@ def publish_new_release( release_tag, snapshot_branch, release_tag, body, draft=False, prerelease=is_prerelease, - make_latest=None if is_prerelease else "true", ) except GitHubClientError as e: return PublishResult( success=False, error_message=f"Failed to publish release: {e}" ) + + # make_latest in its own call, as in publish_release() + if not is_prerelease: + try: + self.gh.update_release(created.get("id"), make_latest="true") + except GitHubClientError: + logger.warning(f"Failed to mark {release_tag} as Latest release") return PublishResult( success=True, release_url=created.get("html_url"), diff --git a/release_automation/tests/test_act.py b/release_automation/tests/test_act.py new file mode 100644 index 0000000..bd433c6 --- /dev/null +++ b/release_automation/tests/test_act.py @@ -0,0 +1,217 @@ +"""Tests for act.py: the plan's action → module call → JSON outcome.""" + +import json + +import pytest + +from release_automation.scripts import act as act_mod +from release_automation.scripts import draft_release +from release_automation.scripts.act import act, snapshot_outcome +from release_automation.tests.fake_github import REPO_URL, FakeGitHub, gh_error + +SNAPSHOT = "release-snapshot/r1.4-abc1234" +REVIEW = "release-review/r1.4-abc1234" +CHANGELOG = "# r1.4\n\n## Release Notes\n\nNotes.\n\n\nx\n\n" +METADATA = "repository:\n release_tag: r1.4\n release_date: null\n" +MERGE_EVENT = {"pull_request": {"number": 41, "html_url": f"{REPO_URL}/pull/41"}} + + +@pytest.fixture(autouse=True) +def no_sleep(monkeypatch): + monkeypatch.setattr(draft_release, "_sleep", lambda s: None) + + +def plan(action, **kw): + return { + "action": action, + "trigger_type": "slash_command", + "command": action, + "release_tag": "r1.4", + "state": "snapshot-active", + "release_type": "pre-release-rc", + "publish_mode": "draft", + "issue_number": "40", + "snapshot_branch": SNAPSHOT, + "release_review_branch": REVIEW, + "release_pr_number": "41", + "release_issue_number": "40", + "src_commit_sha": "abc1234def", + "base_context": {"release_tag": "r1.4", "state": "snapshot-active"}, + **kw, + } + + +def repo(**kw): + return FakeGitHub( + files={(SNAPSHOT, "CHANGELOG/CHANGELOG-r1.md"): CHANGELOG, (SNAPSHOT, "release-metadata.yaml"): METADATA}, + branches={SNAPSHOT: "s1", REVIEW: "v1"}, + **kw, + ) + + +def test_discard_snapshot(): + gh = repo() + + assert act(plan("discard-snapshot"), gh, {}) == { + "action": "discard-snapshot", + "success": "true", + "error_message": "", + "renamed_review_branch": f"{REVIEW}-preserved", + } + assert gh.closed_prs == [41] + + +def test_delete_draft_failure_outcome(): + gh = repo() + del gh.branches[SNAPSHOT] + + outcome = act(plan("delete-draft", state="draft-ready"), gh, {}) + + assert outcome == { + "action": "delete-draft", + "success": "false", + "error_message": f"Snapshot branch {SNAPSHOT} not found", + "renamed_review_branch": "", + } + + +def test_publish_release(): + gh = repo(releases=[{"id": 7, "tag": "r1.4", "target": SNAPSHOT, "draft": True, "prerelease": True, "html_url": "u"}]) + + outcome = act(plan("publish-release", state="draft-ready"), gh, {}) + + assert outcome == { + "action": "publish-release", + "success": "true", + "release_url": f"{REPO_URL}/releases/tag/r1.4", + "reference_tag": "source/r1.4", + "pointer_branch": "pre-release/r1.4", + "error_message": "", + } + + +def test_create_draft_posts_draft_created(): + gh = repo() + + outcome = act(plan("create-draft", trigger_type="pr_merge", command="", issue_number=""), gh, MERGE_EVENT) + + url = gh.releases[0]["html_url"] + assert outcome == { + "action": "create-draft", + "success": "true", + "draft_release_url": url, + "candidate_block_stripped": "true", + "error_message": "", + } + comment = gh.comments[0] + assert comment["issue"] == 40 + assert comment["body"].startswith("\n**📦 Draft release created — State: `draft-ready`**") + assert f"Triggered by merge of [Release PR #41]({REPO_URL}/pull/41)." in comment["body"] + assert f"**Draft release:** [`r1.4`]({url})" in comment["body"] + assert "Release notes cleaned (candidate block removed)." in comment["body"] + + +def test_create_draft_without_release_issue_posts_nothing(): + gh = repo() + + outcome = act(plan("create-draft", trigger_type="pr_merge", release_issue_number=""), gh, MERGE_EVENT) + + assert outcome["success"] == "true" and gh.comments == [] + + +def test_draft_created_comment_failure_is_flagged(capsys): + gh = repo(fail={"create_comment": [gh_error("HTTP 502")]}) + + outcome = act(plan("create-draft", trigger_type="pr_merge"), gh, MERGE_EVENT) + + assert outcome["success"] == "true" and outcome["comment_failed"] == "true" + assert "::error::Could not post draft_created: gh command failed: HTTP 502" in capsys.readouterr().out + + +def test_failed_draft_posts_no_comment(): + gh = repo(fail={"create_release": [gh_error("HTTP 422")]}) + + outcome = act(plan("create-draft", trigger_type="pr_merge"), gh, MERGE_EVENT) + + assert outcome["success"] == "false" and gh.comments == [] + + +def test_publish_on_merge(): + gh = repo() + + outcome = act(plan("publish-on-merge", trigger_type="pr_merge", publish_mode="on_merge"), gh, MERGE_EVENT) + + assert outcome["action"] == "publish-on-merge" and outcome["success"] == "true" + assert outcome["release_url"] == f"{REPO_URL}/releases/tag/r1.4" + assert not gh.releases[0]["draft"] + assert gh.comments == [] + + +def test_reopen_issue(): + gh = repo() + + outcome = act(plan("reopen-issue", trigger_type="issue_close", command=""), gh, {}) + + assert outcome == {"action": "reopen-issue", "success": "true", "action_taken": "reopened", "error_message": ""} + assert gh.reopened == [40] + + +def test_unknown_action_is_an_error(): + with pytest.raises(ValueError, match="unknown action: sync-issue"): + act(plan("sync-issue"), repo(), {}) + + +def test_main_writes_outcome_and_exit_code(tmp_path, monkeypatch): + output = tmp_path / "output" + output.write_text("") + event = tmp_path / "event.json" + event.write_text("{}") + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event)) + monkeypatch.setenv("PLAN_JSON", json.dumps(plan("discard-snapshot"))) + gh = repo() + del gh.branches[SNAPSHOT] + + assert act_mod.main([], gh) == 1 + + from release_automation.tests.test_trigger import read_outputs + assert json.loads(read_outputs(output)["outcome"])["success"] == "false" + + +def test_main_success_exit_code(tmp_path, monkeypatch): + output = tmp_path / "output" + output.write_text("") + event = tmp_path / "event.json" + event.write_text("{}") + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event)) + monkeypatch.setenv("PLAN_JSON", json.dumps(plan("discard-snapshot"))) + + assert act_mod.main([], repo()) == 0 + + +# ── snapshot job outcome ───────────────────────────────────────────────────── + + +def test_snapshot_outcome_from_create_snapshot(): + create = { + "success": "true", "snapshot_id": "r1.4-abc1234", "snapshot_branch": SNAPSHOT, + "release_review_branch": REVIEW, "release_pr_number": "41", + "release_pr_url": f"{REPO_URL}/pull/41", "error_message": "", "apis_json": "[]", + } + + assert snapshot_outcome(create, {}) == {"action": "create-snapshot", **create} + + +def test_snapshot_outcome_from_validation_gate(): + gate = {"success": "false", "error_message": "Pre-snapshot validation failed: 2 errors."} + + outcome = snapshot_outcome({}, gate) + + assert outcome["success"] == "false" + assert outcome["error_message"] == "Pre-snapshot validation failed: 2 errors." + assert outcome["snapshot_branch"] == "" + + +def test_snapshot_outcome_when_nothing_ran(): + assert snapshot_outcome({}, {})["success"] == "" diff --git a/release_automation/tests/test_command_policy.py b/release_automation/tests/test_command_policy.py index cb4f474..fadc3bb 100644 --- a/release_automation/tests/test_command_policy.py +++ b/release_automation/tests/test_command_policy.py @@ -389,6 +389,7 @@ def test_slash_command_plan_responds_with_interim(command): state=state.value, release_type="pre-release-alpha", response="interim_processing", + action="" if command == "sync-issue" else command, ) @@ -418,6 +419,7 @@ def test_non_slash_triggers_are_not_validated(trigger, state): state=state.value, release_type="pre-release-alpha", response="", + action=policy.action_for(result), ) assert client.calls == [] @@ -475,6 +477,7 @@ def test_plan_and_rejection_serialise_to_json(): "base_branch": "main", "publish_mode": "draft", "response": "publish_confirmation", + "action": "", } assert json.loads(rejection.to_json()) == { "allowed": False, @@ -487,14 +490,19 @@ def test_plan_and_rejection_serialise_to_json(): # ── main(): env → GITHUB_OUTPUT ────────────────────────────────────────────── +STATE_KEYS = ("STATE", "RELEASE_TAG", "RELEASE_TYPE", "CONFIG_ERROR", "CONFIG_ERROR_TYPE", "SRC_COMMIT_SHA", + "SNAPSHOT_BRANCH", "RELEASE_REVIEW_BRANCH", "RELEASE_PR_NUMBER", "RELEASE_ISSUE_NUMBER", + "COMMONALITIES_RELEASE", "IDENTITY_CONSENT_MANAGEMENT_RELEASE") + + def run_main(tmp_path, monkeypatch, trigger, env, client): output_path = tmp_path / "output" output_path.write_text("") monkeypatch.setenv("GITHUB_OUTPUT", str(output_path)) monkeypatch.setenv("TRIGGER_JSON", json.dumps(trigger.__dict__)) - for name in ("STATE", "RELEASE_TAG", "RELEASE_TYPE", "CONFIG_ERROR", "CONFIG_ERROR_TYPE", - "COMMON_CACHE_STATUS", "COMMON_CACHE_DETAILS", "COMMON_SYNC_PR_URL", - "SRC_COMMIT_SHA"): + # STATE_JSON is toJSON(steps.state.outputs) of the derive-release-state step + monkeypatch.setenv("STATE_JSON", json.dumps({k.lower(): env[k] for k in STATE_KEYS if k in env})) + for name in ("COMMON_CACHE_STATUS", "COMMON_CACHE_DETAILS", "COMMON_SYNC_PR_URL", "BASE_CONTEXT", "ACK_COMMENT_ID"): monkeypatch.setenv(name, env.get(name, "")) monkeypatch.setenv("GITHUB_SERVER_URL", "https://github.com") monkeypatch.setenv("GITHUB_REPOSITORY", "camaraproject/ReleaseTest2") @@ -512,9 +520,10 @@ def test_main_writes_allowed_plan(tmp_path, monkeypatch): FakeClient(permissions={"alice": "write"}), ) - assert out["allowed"] == "true" - assert out["error_message"] == "" - assert json.loads(out["plan"])["response"] == "publish_confirmation" + plan = json.loads(out["plan"]) + assert plan["allowed"] is True + assert plan["response"] == "publish_confirmation" + assert out["action"] == "" def test_main_writes_rejection_with_cache_inputs(tmp_path, monkeypatch): @@ -525,9 +534,10 @@ def test_main_writes_rejection_with_cache_inputs(tmp_path, monkeypatch): FakeClient(permissions={"alice": "write"}), ) - assert out["allowed"] == "false" - assert out["error_message"] == "Common file cache is stale: d. Merge the sync PR: https://x/pull/1" - assert json.loads(out["plan"])["allowed"] is False + plan = json.loads(out["plan"]) + assert plan["allowed"] is False + assert plan["error_message"] == "Common file cache is stale: d. Merge the sync PR: https://x/pull/1" + assert (out["action"], out["finalize"]) == ("", "false") def test_main_config_error_plan(tmp_path, monkeypatch): @@ -568,7 +578,7 @@ def test_main_rejection_response_context_is_valid_json(tmp_path, monkeypatch): ) assert out["response"] == "command_rejected" - assert json.loads(out["response_context"]) == {"error_message": out["error_message"]} + assert json.loads(out["response_context"]) == {"error_message": json.loads(out["plan"])["error_message"]} def test_main_interim_response(tmp_path, monkeypatch): @@ -589,9 +599,6 @@ def test_main_config_error_response(tmp_path, monkeypatch): FakeClient(), ) - # allowed/error_message stay empty on a config error, as when Validate - # Command was skipped; the config_error template answers instead. - assert (out["allowed"], out["error_message"]) == ("", "") assert out["response"] == "config_error" assert out["response_release_tag"] == "unknown" assert json.loads(out["response_context"]) == { @@ -610,10 +617,10 @@ def test_main_config_error_response(tmp_path, monkeypatch): Trigger(trigger_type="issue_close", issue_number="42", user="x", should_continue=True), ], ) -def test_main_non_slash_trigger_leaves_allowed_empty(tmp_path, monkeypatch, trigger): +def test_main_non_slash_trigger_has_no_response(tmp_path, monkeypatch, trigger): out = run_main(tmp_path, monkeypatch, trigger, {"STATE": "planned", "RELEASE_TAG": "r1.2"}, FakeClient()) - assert (out["allowed"], out["error_message"], out["response"]) == ("", "", "") + assert out["response"] == "" assert json.loads(out["plan"])["allowed"] is True @@ -624,7 +631,7 @@ def test_main_non_slash_config_error(tmp_path, monkeypatch): {"CONFIG_ERROR": "bad", "CONFIG_ERROR_TYPE": "malformed_yaml"}, FakeClient(), ) - assert (out["allowed"], out["response"]) == ("", "") + assert (out["response"], out["action"], out["finalize"]) == ("", "", "false") assert json.loads(out["plan"])["error_type"] == "malformed_yaml" @@ -662,3 +669,85 @@ def test_main_writes_finalize(tmp_path, monkeypatch): ) assert out["finalize"] == "true" + + +# ── action: what snapshot/act execute ──────────────────────────────────────── + + +@pytest.mark.parametrize( + "trigger,state,action", + [ + (slash("create-snapshot"), ReleaseState.PLANNED, "create-snapshot"), + (slash("discard-snapshot"), ReleaseState.SNAPSHOT_ACTIVE, "discard-snapshot"), + (slash("delete-draft"), ReleaseState.DRAFT_READY, "delete-draft"), + (slash("publish-release", confirm_tag="r1.2"), ReleaseState.DRAFT_READY, "publish-release"), + (slash("publish-release"), ReleaseState.DRAFT_READY, ""), # confirmation request + (slash("sync-issue"), ReleaseState.PLANNED, ""), + (slash("create-snapshot"), ReleaseState.SNAPSHOT_ACTIVE, ""), # rejected + (Trigger(trigger_type="pr_merge", user="x", should_continue=True), ReleaseState.SNAPSHOT_ACTIVE, "create-draft"), + (Trigger(trigger_type="issue_close", issue_number="42", should_continue=True), ReleaseState.SNAPSHOT_ACTIVE, "reopen-issue"), + (Trigger(trigger_type="issue_close", issue_number="42", should_continue=True), ReleaseState.DRAFT_READY, "reopen-issue"), + (Trigger(trigger_type="issue_close", issue_number="42", should_continue=True), ReleaseState.PLANNED, ""), + (Trigger(trigger_type="issue_close", issue_number="42", should_continue=True), ReleaseState.PUBLISHED, ""), + (Trigger(trigger_type="issue_reopen", issue_number="42", should_continue=True), ReleaseState.DRAFT_READY, ""), + (Trigger(trigger_type="push", command="sync-issue", should_continue=True), ReleaseState.PLANNED, ""), + ], +) +def test_action(trigger, state, action): + result = evaluate(trigger, info(state), client=FakeClient(permissions={"alice": "write"}, codeowners="")) + + assert (result.action if result.allowed else "") == action + + +def test_pr_merge_publishes_on_merge_when_the_policy_says_so(monkeypatch): + monkeypatch.setitem(policy.PUBLISH_MODES, "pre-release-rc", "on_merge") + trigger = Trigger(trigger_type="pr_merge", user="x", should_continue=True) + + rc = evaluate(trigger, info(ReleaseState.SNAPSHOT_ACTIVE, release_type="pre-release-rc"), client=FakeClient()) + public = evaluate(trigger, info(ReleaseState.SNAPSHOT_ACTIVE, release_type="public-release"), client=FakeClient()) + + assert (rc.publish_mode, rc.action) == ("on_merge", "publish-on-merge") + assert (public.publish_mode, public.action) == ("draft", "create-draft") + + +def test_main_writes_the_plan_document(tmp_path, monkeypatch): + base = {"release_tag": "r1.2", "state": "snapshot-active", "user": "alice"} + out = run_main( + tmp_path, monkeypatch, slash("discard-snapshot", args="redo"), + {"STATE": "snapshot-active", "RELEASE_TAG": "r1.2", "RELEASE_TYPE": "pre-release-rc", + "SRC_COMMIT_SHA": "abcdef0123", "SNAPSHOT_BRANCH": "release-snapshot/r1.2-abcdef0", + "RELEASE_REVIEW_BRANCH": "release-review/r1.2-abcdef0", "RELEASE_PR_NUMBER": "24", + "RELEASE_ISSUE_NUMBER": "23", "COMMONALITIES_RELEASE": "r4.3", + "IDENTITY_CONSENT_MANAGEMENT_RELEASE": "r4.3", + "COMMON_CACHE_STATUS": "in_sync", "BASE_CONTEXT": json.dumps(base), "ACK_COMMENT_ID": "555"}, + FakeClient(permissions={"alice": "write"}), + ) + + assert (out["action"], out["finalize"]) == ("discard-snapshot", "true") + assert json.loads(out["plan"]) == { + "allowed": True, + "trigger_type": "slash_command", + "command": "discard-snapshot", + "release_tag": "r1.2", + "state": "snapshot-active", + "release_type": "pre-release-rc", + "confirm_tag": "", + "base_branch": "main", + "publish_mode": "draft", + "response": "interim_processing", + "action": "discard-snapshot", + "command_args": "redo", + "issue_number": "42", + "user": "alice", + "snapshot_branch": "release-snapshot/r1.2-abcdef0", + "release_review_branch": "release-review/r1.2-abcdef0", + "release_pr_number": "24", + "release_issue_number": "23", + "src_commit_sha": "abcdef0123", + "commonalities_release": "r4.3", + "identity_consent_management_release": "r4.3", + "common_cache_status": "in_sync", + "common_cache_details": "", + "common_sync_pr_url": "", + "base_context": base, + } diff --git a/release_automation/tests/test_finalize.py b/release_automation/tests/test_finalize.py index c1abc9c..94e4dbe 100644 --- a/release_automation/tests/test_finalize.py +++ b/release_automation/tests/test_finalize.py @@ -24,17 +24,14 @@ 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": "", + "allowed": True, + "action": "", "state": "planned", "release_tag": "r4.1", "release_issue_number": "42", @@ -44,17 +41,25 @@ "common_cache_details": "", "common_sync_pr_url": "", "commonalities_release": "r4.3", - "base_context": json.dumps({"release_tag": "r4.1", "state": "planned"}), - "comment_id": "555", + "base_context": {"release_tag": "r4.1", "state": "planned"}, } 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} + """jobs: {action: (job result, outcome)} — at most one action per run.""" + plan = {**PLAN, **(plan or {})} + result_comment_id = plan.pop("result_comment_id", "555") + needs = { + "snapshot": {"result": "skipped", "outputs": {}}, + "act": {"result": "skipped", "outputs": {}}, + } + for action, (result, outcome) in (jobs or {}).items(): + plan["action"] = action + job = "snapshot" if action == "create-snapshot" else "act" + needs[job] = {"result": result, "outputs": {"outcome": json.dumps({"action": action, **outcome})} if outcome else {}} + needs["plan"] = {"result": "success", "outputs": { + "plan": json.dumps(plan), "action": plan["action"], "finalize": "true", "result_comment_id": result_comment_id, + }} return Run( needs=needs, event=event or {}, @@ -81,17 +86,20 @@ def failed(**outputs): ({}, {"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), + ({"trigger_type": "pr_merge"}, {"create-draft": ok()}, True), + ({"trigger_type": "issue_close", "command": ""}, {"reopen-issue": ok(action_taken="reopened")}, True), + ({"trigger_type": "issue_close", "command": ""}, {}, True), + ({"trigger_type": "issue_reopen", "command": ""}, {}, True), ({"command": "sync-issue"}, {}, True), - ({"trigger_type": "push", "command": "sync-issue", "allowed": ""}, {}, True), + ({"trigger_type": "push", "command": "sync-issue"}, {}, True), ({}, {"create-snapshot": failed()}, False), ({}, {"create-snapshot": ("failure", {"success": "true"})}, False), - ({"trigger_type": "pr_merge"}, {"handle-pr-merge": ("failure", {})}, False), + ({"trigger_type": "pr_merge"}, {"create-draft": ("failure", {})}, False), + ({"trigger_type": "issue_close", "command": ""}, {"reopen-issue": ("failure", {"success": "false"})}, False), # A publication never syncs the issue here; the published finalisation does. ({"command": "publish-release"}, {"publish-release": ok()}, False), + ({"trigger_type": "pr_merge"}, {"publish-on-merge": ok()}, False), ({"command": "sync-issue", "state": ""}, {}, False), - ({"command": "sync-issue", "config_error": "missing"}, {}, False), ], ) def test_issue_sync_due(plan, jobs, due): @@ -103,8 +111,7 @@ def test_issue_sync_due(plan, jobs, due): [ ({}, True), ({"trigger_type": "push"}, False), - ({"allowed": "false"}, False), - ({"interim_posted": "false"}, False), + ({"result_comment_id": ""}, False), ], ) def test_result_due(plan, due): @@ -129,7 +136,7 @@ def test_overrides_after_create_snapshot(): 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")}, + {"create-draft": ok(draft_release_url="https://github.com/camaraproject/Example/releases/tag/untagged-1")}, event={"pull_request": {"number": 14}}, ) @@ -318,10 +325,10 @@ def test_context_after_discard_without_rename(): assert result_context(run, None) == {"reason": "No reason provided"} -def publish(error_message=""): - return {"publish-release": ok( +def publish(error_message="", reference_tag="src/r4.1", action="publish-release"): + return {action: ok( release_url="https://github.com/camaraproject/Example/releases/tag/r4.1", - reference_tag="src/r4.1", + reference_tag=reference_tag, error_message=error_message, )} @@ -342,9 +349,7 @@ def test_context_after_publish(): 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"] = "" + run = make_run({"command": "publish-release"}, publish("Failed to create pointer branch", reference_tag="")) context = result_context(run, SyncPrOutcome(status="no_changes")) @@ -504,7 +509,7 @@ def test_publish_with_failed_sync_pr_leaves_the_issue_open(stubs, tmp_path): 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)) + run_finalize(make_run({**PUBLISH_PLAN, "result_comment_id": ""}, publish()), clients(gh, tmp_path)) assert kinds(gh) == ["sync-pr"] @@ -522,7 +527,7 @@ def test_result_comment_failure_skips_the_published_issue(stubs, tmp_path): def test_sync_issue_command(stubs, tmp_path): gh = FakeGitHub() - base = json.dumps({"release_tag": "r4.1", "command": "sync-issue", "user": "alice"}) + base = {"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"] @@ -534,7 +539,7 @@ def test_sync_issue_command(stubs, tmp_path): 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", + "trigger_type": "push", "command": "sync-issue", "result_comment_id": "", "common_cache_status": "stale", "common_cache_details": "code/common/.sync-manifest.yaml is missing", "common_sync_pr_url": "", }) @@ -601,7 +606,36 @@ def test_main_exit_code(stubs, tmp_path, monkeypatch): 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.setenv("NEEDS_JSON", json.dumps(make_run({"command": "sync-issue", "result_comment_id": ""}).needs)) monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False) assert finalize.main(clients(FakeGitHub(), tmp_path)) == 1 + + +# ── publish_mode on_merge ──────────────────────────────────────────────────── + +MERGE_PLAN = {"trigger_type": "pr_merge", "command": "", "user": "bob", "issue_number": "", "state": "snapshot-active", + "result_comment_id": ""} + + +def test_publish_on_merge_reports_on_the_release_issue_and_closes(stubs, tmp_path): + gh = FakeGitHub() + + report = run_finalize(make_run(MERGE_PLAN, publish(action="publish-on-merge")), clients(gh, tmp_path)) + + assert kinds(gh) == ["sync-pr", "comment", "issue-body", "remove-labels", "add-labels", "close"] + _, issue, body = gh.events[1] + assert issue == 42 + assert body.startswith("\n**🚀 Release published") + assert report.result_template == "release_published" and report.issue_finalized is True + + +def test_failed_publish_on_merge_posts_publish_failed(stubs, tmp_path): + gh = FakeGitHub() + run = make_run(MERGE_PLAN, {"publish-on-merge": failed(error_message="Failed to publish release: 422")}) + + report = run_finalize(run, clients(gh, tmp_path)) + + assert kinds(gh) == ["comment"] + assert report.result_template == "publish_failed" + assert "Failed to publish release: 422" in gh.events[0][2] diff --git a/validation/tests/test_release_automation_workflow.py b/validation/tests/test_release_automation_workflow.py index 6e70ac8..0258d24 100644 --- a/validation/tests/test_release_automation_workflow.py +++ b/validation/tests/test_release_automation_workflow.py @@ -20,7 +20,7 @@ def _create_snapshot_steps() -> list[dict]: workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) - return workflow["jobs"]["create-snapshot"]["steps"] + return workflow["jobs"]["snapshot"]["steps"] def _step_by_name(steps: list[dict], name: str) -> dict: @@ -69,7 +69,8 @@ def test_tooling_checkouts_resolve_through_job_context(): and step.get("with", {}).get("path") == "_tooling" ] - assert len(checkouts) == 6 + # One per phase job: plan, snapshot, act, finalize + assert len(checkouts) == 4 for step in checkouts: assert step["with"]["repository"] == "${{ job.workflow_repository }}" assert step["with"]["ref"] == "${{ job.workflow_sha }}" @@ -129,8 +130,8 @@ def test_plan_runs_policy_then_one_response(): "Detect Trigger Type", "Derive Release State", "Check common cache sync", - "Apply Command Policy", "Assemble Context", + "Apply Command Policy", "Post Response", ] assert [n for n in names if n in order] == order @@ -138,6 +139,8 @@ def test_plan_runs_policy_then_one_response(): policy = _step_by_name(plan["steps"], "Apply Command Policy") assert policy["run"].strip() == "python -m release_automation.scripts.command_policy" assert policy["env"]["TRIGGER_JSON"] == "${{ steps.detect.outputs.trigger }}" + assert policy["env"]["STATE_JSON"] == "${{ toJSON(steps.state.outputs) }}" + assert policy["env"]["BASE_CONTEXT"] == "${{ steps.assemble.outputs.base_context }}" cache = _step_by_name(plan["steps"], "Check common cache sync") assert cache["run"].strip() == "python -m release_automation.scripts.common_sync" # The existing-PR lookup is part of common_sync (exact sync-common/ head). @@ -155,46 +158,80 @@ def test_plan_runs_policy_then_one_response(): assert [s["name"] for s in scripts] == ["Post Acknowledgment"] -def test_folded_jobs_are_gone_and_compat_outputs_cover_every_reader(): +def test_four_phase_jobs_and_three_job_conditions(): text = WORKFLOW.read_text(encoding="utf-8") - workflow = yaml.safe_load(text) - jobs = workflow["jobs"] + jobs = yaml.safe_load(text)["jobs"] - for gone in ("check-trigger", "derive-state", "handle-config-error", - "post-interim", "post-rejection", "publish-confirmation"): - assert gone not in jobs + assert list(jobs) == ["plan", "snapshot", "act", "finalize"] + for gone in ("check-trigger", "derive-state", "handle-config-error", "post-interim", "post-rejection", + "publish-confirmation", "update-issue", "post-result", "create-sync-pr", + "create-snapshot", "discard-snapshot", "delete-draft", "publish-release", + "handle-pr-merge", "handle-issue-event"): assert f"needs.{gone}." not in text - - read = set(re.findall(r"needs\.plan\.outputs\.([a-z_]+)", text)) - assert read <= set(jobs["plan"]["outputs"]) - for name, job in jobs.items(): - if name != "plan": - needs = job.get("needs", []) - assert "plan" in ([needs] if isinstance(needs, str) else needs), name + assert [name for name, job in jobs.items() if "if" in job] == ["snapshot", "act", "finalize"] + assert jobs["snapshot"]["if"] == "needs.plan.outputs.action == 'create-snapshot'" + assert jobs["act"]["if"] == "needs.plan.outputs.action != '' && needs.plan.outputs.action != 'create-snapshot'" + assert jobs["act"]["name"] == "act (${{ needs.plan.outputs.action }})" -def test_finalize_replaces_the_tail_jobs(): +def test_plan_has_one_json_plan_and_no_compatibility_outputs(): 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 + assert set(jobs["plan"]["outputs"]) == {"plan", "action", "finalize", "result_comment_id"} + read = set(re.findall(r"needs\.plan\.outputs\.([a-z_]+)", text)) + assert read == {"plan", "action", "finalize"} + for name in ("snapshot", "act", "finalize"): + needs = jobs[name]["needs"] + assert "plan" in ([needs] if isinstance(needs, str) else needs), name + + +def test_snapshot_and_act_emit_one_outcome(): + jobs = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"] + + assert jobs["snapshot"]["outputs"] == {"outcome": "${{ steps.outcome.outputs.outcome }}"} + outcome = _step_by_name(jobs["snapshot"]["steps"], "Outcome") + assert outcome["if"] == "always()" + assert outcome["run"].strip() == "python -m release_automation.scripts.act snapshot" + assert jobs["act"]["outputs"] == {"outcome": "${{ steps.act.outputs.outcome }}"} + step = _step_by_name(jobs["act"]["steps"], "Act") + assert step["run"].strip() == "python -m release_automation.scripts.act" + assert step["env"]["PLAN_JSON"] == "${{ needs.plan.outputs.plan }}" + # No inline scripts in the action phase + for name in ("snapshot", "act"): + assert not [s for s in jobs[name]["steps"] if str(s.get("uses", "")).startswith("actions/github-script")] + + +def test_finalize_replaces_the_tail_jobs(): + jobs = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"] finalize = jobs["finalize"] assert finalize["if"] == "always() && needs.plan.outputs.finalize == 'true'" - assert set(finalize["needs"]) == set(jobs) - {"finalize"} + assert finalize["needs"] == ["plan", "snapshot", "act"] 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.""" +def test_finalize_reads_only_defined_plan_outputs(): + """finalize.py reads the plan job's outputs and the action jobs' outcome from toJSON(needs).""" 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): + for name in re.findall(r'_outputs\("plan"\)\.get\("([a-z_]+)"', source): + assert name in jobs["plan"]["outputs"], name + for job, name in re.findall(r'_json\("([a-z]+)", "([a-z_]+)"\)', source): assert name in jobs[job]["outputs"], (job, name) + + +def test_finalize_and_act_read_only_plan_document_keys(): + from release_automation.scripts.command_policy import CommonCache, Plan, plan_document + from release_automation.scripts.trigger import Trigger + + document = plan_document(Plan("slash_command", "sync-issue", "r1.2", "planned", ""), Trigger(), {}, CommonCache(), {}) + scripts = WORKFLOW.parents[2] / "release_automation" / "scripts" + finalize_reads = set(re.findall(r'\.plan\("([a-z_]+)"\)', (scripts / "finalize.py").read_text(encoding="utf-8"))) + act_reads = set(re.findall(r'plan\["([a-z_]+)"\]', (scripts / "act.py").read_text(encoding="utf-8"))) + + assert finalize_reads and act_reads + assert finalize_reads | act_reads <= set(document)