From 0c533371715f94e73d35c16eea7ee6839d88a772 Mon Sep 17 00:00:00 2001 From: Herbert Damker <52109189+hdamker@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:35:23 +0200 Subject: [PATCH] refactor(ra): move the snapshot job glue into snapshot_prep and log bundling as a note --- .../workflows/release-automation-reusable.yml | 93 +++--------- release_automation/scripts/bot_identity.py | 18 +++ release_automation/scripts/finalize.py | 6 +- .../scripts/snapshot_creator.py | 3 +- release_automation/scripts/snapshot_prep.py | 105 ++++++++++++++ release_automation/tests/test_bot_identity.py | 41 ++++++ .../tests/test_snapshot_creator.py | 30 ++++ .../tests/test_snapshot_prep.py | 136 ++++++++++++++++++ shared-actions/create-snapshot/action.yml | 5 + .../tests/test_release_automation_workflow.py | 36 ++++- 10 files changed, 395 insertions(+), 78 deletions(-) create mode 100644 release_automation/scripts/bot_identity.py create mode 100644 release_automation/scripts/snapshot_prep.py create mode 100644 release_automation/tests/test_bot_identity.py create mode 100644 release_automation/tests/test_snapshot_prep.py diff --git a/.github/workflows/release-automation-reusable.yml b/.github/workflows/release-automation-reusable.yml index f00359a3..249768d8 100644 --- a/.github/workflows/release-automation-reusable.yml +++ b/.github/workflows/release-automation-reusable.yml @@ -329,6 +329,9 @@ jobs: with: python-version: ${{ env.PYTHON_VERSION }} + - name: Install dependencies + run: pip install --quiet -r _tooling/requirements.txt + # Deliberately no `cache: npm` here, unlike the validation reusable # workflow. Snapshot creation is slash-command driven, so this job # always runs on an `issue_comment` event, and the Actions cache @@ -363,62 +366,22 @@ jobs: path: _tooling-config - name: Validate central settings against schema - shell: bash - run: | - pip install --quiet -r _tooling/requirements.txt - python3 - <<'PY' - import sys - from pathlib import Path - - import yaml - from jsonschema import Draft202012Validator - - config_path = Path("_tooling-config/config/validation-settings.yaml") - schema_path = Path("_tooling/validation/schemas/validation-settings-schema.yaml") - - if not config_path.exists(): - print( - f"::error title=Validation settings missing::" - f"{config_path} not present on tooling/main; " - f"aborting before snapshot creation" - ) - sys.exit(1) - if not schema_path.exists(): - print( - f"::error title=Validation settings schema missing::" - f"{schema_path} not present on the pinned tooling ref" - ) - sys.exit(1) - - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) - schema = yaml.safe_load(schema_path.read_text(encoding="utf-8")) - - errors = list(Draft202012Validator(schema).iter_errors(config)) - if errors: - for err in errors: - loc = ".".join(str(p) for p in err.absolute_path) or "(root)" - print( - f"::error title=Validation settings invalid::" - f"{loc}: {err.message}" - ) - print( - "::error::Aborting before snapshot creation — " - "validation settings on tooling/main fail schema validation" - ) - sys.exit(1) - - print(f"Validation settings: {config_path} (main) — schema-valid") - PY + working-directory: _tooling + env: + SETTINGS_PATH: ${{ github.workspace }}/_tooling-config/config/validation-settings.yaml + SCHEMA_PATH: ${{ github.workspace }}/_tooling/validation/schemas/validation-settings-schema.yaml + run: python -m release_automation.scripts.snapshot_prep settings - name: Resolve published release history id: release-history if: hashFiles('_repo/release-plan.yaml') != '' env: GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + BASE_REF: ${{ github.event.repository.default_branch || github.ref_name || 'main' }} run: | python3 _tooling/validation/scripts/resolve-release-history.py \ - --repo "${{ github.repository }}" \ - --base-ref "${{ github.event.repository.default_branch || github.ref_name || 'main' }}" \ + --repo "$GITHUB_REPOSITORY" \ + --base-ref "$BASE_REF" \ --output "${RUNNER_TEMP}/release-history.json" - name: Run pre-snapshot validation @@ -435,32 +398,21 @@ jobs: - name: Gate on validation result id: validation-gate if: always() && steps.validation.outputs.should_fail == 'true' - run: | - echo "::error::Pre-snapshot validation failed — fix issues on main before re-running /create-snapshot" - echo "success=false" >> "$GITHUB_OUTPUT" - SUMMARY="${{ steps.validation.outputs.summary }}" - RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - echo "error_message=Pre-snapshot validation failed: ${SUMMARY}. [View workflow logs](${RUN_URL})" >> "$GITHUB_OUTPUT" - exit 1 + working-directory: _tooling + env: + VALIDATION_SUMMARY: ${{ steps.validation.outputs.summary }} + run: python -m release_automation.scripts.snapshot_prep gate # ── Snapshot creation ───────────────────────────────────────── - name: Resolve Bot Identity id: bot-identity + working-directory: _tooling env: - GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} - run: | - if [ -n "${{ vars.RELEASE_APP_SLUG }}" ] && [ -n "${{ steps.app-token.outputs.token }}" ]; then - BOT_LOGIN="${{ vars.RELEASE_APP_SLUG }}[bot]" - else - BOT_LOGIN="github-actions[bot]" - fi - BOT_INFO=$(gh api "/users/$BOT_LOGIN" --jq '{id: .id, login: .login}') - BOT_NAME=$(echo "$BOT_INFO" | jq -r '.login') - BOT_ID=$(echo "$BOT_INFO" | jq -r '.id') - echo "Committing as: $BOT_NAME <${BOT_ID}+${BOT_NAME}@users.noreply.github.com>" - echo "bot_name=$BOT_NAME" >> $GITHUB_OUTPUT - echo "bot_email=${BOT_ID}+${BOT_NAME}@users.noreply.github.com" >> $GITHUB_OUTPUT + GITHUB_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + APP_TOKEN_USED: ${{ steps.app-token.outputs.token != '' }} + RELEASE_APP_SLUG: ${{ vars.RELEASE_APP_SLUG }} + run: python -m release_automation.scripts.snapshot_prep bot-identity - name: Create Snapshot id: create @@ -489,7 +441,6 @@ jobs: # by release_automation/scripts/act.py. # ───────────────────────────────────────────────────────────────────────────── act: - name: act (${{ needs.plan.outputs.action }}) needs: [plan] if: needs.plan.outputs.action != '' && needs.plan.outputs.action != 'create-snapshot' runs-on: ubuntu-latest @@ -525,7 +476,9 @@ jobs: - name: Install dependencies run: pip install --quiet -r _tooling/requirements.txt - - name: Act + # The action is in the step name: a skipped job's name expression is not + # evaluated. + - name: Act (${{ needs.plan.outputs.action }}) id: act working-directory: _tooling env: diff --git a/release_automation/scripts/bot_identity.py b/release_automation/scripts/bot_identity.py new file mode 100644 index 00000000..8e41976d --- /dev/null +++ b/release_automation/scripts/bot_identity.py @@ -0,0 +1,18 @@ +""" +Commit identity of release automation. + +Shared by the snapshot job (snapshot commits) and finalize (sync PR commits). +The identity is the App bot when an App token is in use, otherwise +github-actions[bot]; the noreply address needs the bot's user ID, which is +not the App ID. +""" + +from typing import Tuple + + +def resolve_bot_identity(gh, app_slug: str, app_token_used: bool) -> Tuple[str, str]: + """Return (name, email) for commits made with the token in use.""" + login = f"{app_slug}[bot]" if app_slug and app_token_used else "github-actions[bot]" + user = gh.get_user(login) + name = user["login"] + return name, f"{user['id']}+{name}@users.noreply.github.com" diff --git a/release_automation/scripts/finalize.py b/release_automation/scripts/finalize.py index f56ffb89..73d1d59b 100644 --- a/release_automation/scripts/finalize.py +++ b/release_automation/scripts/finalize.py @@ -28,6 +28,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from .bot_identity import resolve_bot_identity from .common_sync import CommonSyncOutcome from .issue_sync import WorkflowSync, post_publish_warnings from .sync_pr import SyncPrOutcome @@ -275,10 +276,7 @@ def git(self, repo: str, name: str, token: Optional[str] = None): def bot_identity(self) -> Tuple[str, str]: """Commit identity: the App bot when an App token is in use, else github-actions[bot].""" if self._bot is None: - login = f"{self.app_slug}[bot]" if self.app_slug and self.app_token_used else "github-actions[bot]" - user = self.gh.get_user(login) - name = user["login"] - self._bot = (name, f"{user['id']}+{name}@users.noreply.github.com") + self._bot = resolve_bot_identity(self.gh, self.app_slug, self.app_token_used) return self._bot diff --git a/release_automation/scripts/snapshot_creator.py b/release_automation/scripts/snapshot_creator.py index 2635c5bc..7f60bd7b 100644 --- a/release_automation/scripts/snapshot_creator.py +++ b/release_automation/scripts/snapshot_creator.py @@ -58,6 +58,7 @@ class SnapshotResult: transformation_summary: Dict[str, Any] = field(default_factory=dict) errors: List[str] = field(default_factory=list) warnings: List[str] = field(default_factory=list) + notes: List[str] = field(default_factory=list) # informational, logged as plain lines def to_bot_context(self) -> Dict[str, Any]: """ @@ -247,7 +248,7 @@ def create_snapshot( # Step 7c: Bundle API specs (resolve external $ref) bundled_count = self._bundle_specs(temp_dir) if bundled_count > 0: - result.warnings.append( + result.notes.append( f"Bundled {bundled_count} API spec(s) — external $ref resolved" ) diff --git a/release_automation/scripts/snapshot_prep.py b/release_automation/scripts/snapshot_prep.py new file mode 100644 index 00000000..76c08d9d --- /dev/null +++ b/release_automation/scripts/snapshot_prep.py @@ -0,0 +1,105 @@ +""" +Glue steps of the snapshot job around pre-snapshot validation. + + settings schema-validate the central validation settings before anything + irreversible happens + gate fail the job when pre-snapshot validation said it should + bot-identity commit identity for the Create Snapshot step + +Each prints GitHub annotations and exits non-zero on failure; gate and +bot-identity also write step outputs. + +Usage (workflow steps): + python3 -m release_automation.scripts.snapshot_prep +""" + +import os +import sys +from pathlib import Path +from typing import List, Mapping, Optional + +from .bot_identity import resolve_bot_identity +from .trigger import write_outputs + + +def validate_settings(config_path: Path, schema_path: Path) -> int: + """Schema-validate the central validation settings; 0 when valid.""" + import yaml + from jsonschema import Draft202012Validator + + if not config_path.exists(): + print( + f"::error title=Validation settings missing::" + f"{config_path} not present on tooling/main; " + f"aborting before snapshot creation" + ) + return 1 + if not schema_path.exists(): + print( + f"::error title=Validation settings schema missing::" + f"{schema_path} not present on the pinned tooling ref" + ) + return 1 + + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + schema = yaml.safe_load(schema_path.read_text(encoding="utf-8")) + + errors = list(Draft202012Validator(schema).iter_errors(config)) + if errors: + for err in errors: + loc = ".".join(str(p) for p in err.absolute_path) or "(root)" + print(f"::error title=Validation settings invalid::{loc}: {err.message}") + print( + "::error::Aborting before snapshot creation — " + "validation settings on tooling/main fail schema validation" + ) + return 1 + + print(f"Validation settings: {config_path} (main) — schema-valid") + return 0 + + +def gate_validation(summary: str, run_url: str, output_path: str) -> int: + """Fail the job on a pre-snapshot validation failure, with the reason for the Release Issue.""" + print("::error::Pre-snapshot validation failed — fix issues on main before re-running /create-snapshot") + write_outputs(output_path, { + "success": "false", + "error_message": f"Pre-snapshot validation failed: {summary}. [View workflow logs]({run_url})", + }) + return 1 + + +def _github_client(env: Mapping[str, str]): + from .github_client import GitHubClient + + return GitHubClient(repo=env["GITHUB_REPOSITORY"], token=env.get("GITHUB_TOKEN") or None) + + +def main(argv: List[str], env: Optional[Mapping[str, str]] = None) -> int: + env = os.environ if env is None else env + step = argv[0] if len(argv) == 1 else "" + + if step == "settings": + return validate_settings(Path(env["SETTINGS_PATH"]), Path(env["SCHEMA_PATH"])) + + if step == "gate": + run_url = f"{env['GITHUB_SERVER_URL']}/{env['GITHUB_REPOSITORY']}/actions/runs/{env['GITHUB_RUN_ID']}" + return gate_validation(env.get("VALIDATION_SUMMARY", ""), run_url, env["GITHUB_OUTPUT"]) + + if step == "bot-identity": + try: + name, email = resolve_bot_identity( + _github_client(env), env.get("RELEASE_APP_SLUG", ""), env.get("APP_TOKEN_USED") == "true" + ) + except Exception as e: + print(f"::error::Could not resolve the bot identity: {e}") + return 1 + print(f"Committing as: {name} <{email}>") + write_outputs(env["GITHUB_OUTPUT"], {"bot_name": name, "bot_email": email}) + return 0 + + raise SystemExit("usage: snapshot_prep settings|gate|bot-identity") + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/release_automation/tests/test_bot_identity.py b/release_automation/tests/test_bot_identity.py new file mode 100644 index 00000000..8f4025ac --- /dev/null +++ b/release_automation/tests/test_bot_identity.py @@ -0,0 +1,41 @@ +"""Tests for bot_identity.py: the commit identity shared by the snapshot and finalize jobs.""" + +from release_automation.scripts.bot_identity import resolve_bot_identity + + +class Users: + """Stands in for GitHubClient.get_user; records the login it was asked for.""" + + def __init__(self): + self.logins = [] + + def get_user(self, login): + self.logins.append(login) + return {"id": 7, "login": login} + + +def test_app_bot_when_an_app_token_is_in_use(): + gh = Users() + + assert resolve_bot_identity(gh, "camara-release", app_token_used=True) == ( + "camara-release[bot]", + "7+camara-release[bot]@users.noreply.github.com", + ) + assert gh.logins == ["camara-release[bot]"] + + +def test_github_actions_bot_without_an_app_token(): + gh = Users() + + assert resolve_bot_identity(gh, "camara-release", app_token_used=False) == ( + "github-actions[bot]", + "7+github-actions[bot]@users.noreply.github.com", + ) + + +def test_github_actions_bot_without_an_app_slug(): + gh = Users() + + name, _ = resolve_bot_identity(gh, "", app_token_used=True) + + assert name == "github-actions[bot]" diff --git a/release_automation/tests/test_snapshot_creator.py b/release_automation/tests/test_snapshot_creator.py index 760fdc80..3608f99f 100644 --- a/release_automation/tests/test_snapshot_creator.py +++ b/release_automation/tests/test_snapshot_creator.py @@ -432,6 +432,36 @@ def test_successful_snapshot_creation( assert result.release_pr_number == 42 assert "quality-on-demand" in result.api_versions + @patch("release_automation.scripts.snapshot_creator.tempfile.mkdtemp") + @patch("release_automation.scripts.snapshot_creator.shutil.rmtree") + @patch("release_automation.scripts.snapshot_creator.GitOperations") + @patch("builtins.open", create=True) + def test_bundling_is_a_note_not_a_warning( + self, + mock_open, + mock_git_ops_class, + mock_rmtree, + mock_mkdtemp, + snapshot_creator, + sample_release_plan, + ): + """Bundling is informational: it lands in notes, warnings stay for real problems.""" + mock_mkdtemp.return_value = "/tmp/test-snapshot" + mock_git_ops = MagicMock() + mock_git_ops_class.return_value = mock_git_ops + mock_git_ops.create_pr.return_value = PullRequestInfo( + number=42, url="https://github.com/owner/repo/pull/42" + ) + + with patch.object(snapshot_creator, "_bundle_specs", return_value=2): + result = snapshot_creator.create_snapshot( + sample_release_plan, SnapshotConfig(release_tag="r4.1") + ) + + assert result.success is True + assert result.notes == ["Bundled 2 API spec(s) — external $ref resolved"] + assert not any("Bundled" in w for w in result.warnings) + @patch("release_automation.scripts.snapshot_creator.ChangelogGenerator") @patch("release_automation.scripts.snapshot_creator.tempfile.mkdtemp") @patch("release_automation.scripts.snapshot_creator.shutil.rmtree") diff --git a/release_automation/tests/test_snapshot_prep.py b/release_automation/tests/test_snapshot_prep.py new file mode 100644 index 00000000..183dcd97 --- /dev/null +++ b/release_automation/tests/test_snapshot_prep.py @@ -0,0 +1,136 @@ +"""Tests for snapshot_prep.py: the glue steps of the snapshot job around pre-snapshot validation.""" + +from pathlib import Path + +import pytest + +from release_automation.scripts import snapshot_prep +from release_automation.scripts.snapshot_prep import ( + gate_validation, + main, + validate_settings, +) + +TOOLING_ROOT = Path(__file__).resolve().parents[2] +SCHEMA = TOOLING_ROOT / "validation" / "schemas" / "validation-settings-schema.yaml" +SETTINGS = TOOLING_ROOT / "config" / "validation-settings.yaml" + + +def parse_outputs(path: Path) -> dict: + """Parse a GITHUB_OUTPUT file in heredoc form.""" + outputs, lines = {}, path.read_text().splitlines() + i = 0 + while i < len(lines): + name, _, delimiter = lines[i].partition("<<") + i += 1 + value = [] + while lines[i] != delimiter: + value.append(lines[i]) + i += 1 + i += 1 + outputs[name] = "\n".join(value) + return outputs + + +# ── settings ───────────────────────────────────────────────────────────────── + + +def test_settings_valid(capsys): + assert validate_settings(SETTINGS, SCHEMA) == 0 + + assert f"Validation settings: {SETTINGS} (main) — schema-valid" in capsys.readouterr().out + + +def test_settings_missing(tmp_path, capsys): + assert validate_settings(tmp_path / "absent.yaml", SCHEMA) == 1 + + assert "::error title=Validation settings missing::" in capsys.readouterr().out + + +def test_settings_schema_missing(tmp_path, capsys): + assert validate_settings(SETTINGS, tmp_path / "absent.yaml") == 1 + + assert "::error title=Validation settings schema missing::" in capsys.readouterr().out + + +def test_settings_invalid(tmp_path, capsys): + bad = tmp_path / "settings.yaml" + bad.write_text("version: not-a-number\n") + + assert validate_settings(bad, SCHEMA) == 1 + + out = capsys.readouterr().out + assert "::error title=Validation settings invalid::version:" in out + assert "::error::Aborting before snapshot creation" in out + + +# ── gate ───────────────────────────────────────────────────────────────────── + + +def test_gate_fails_with_summary_and_run_link(tmp_path, capsys): + out_file = tmp_path / "out" + + code = gate_validation("2 errors", "https://github.com/o/r/actions/runs/9", str(out_file)) + + assert code == 1 + assert "::error::Pre-snapshot validation failed — fix issues on main before re-running /create-snapshot" in capsys.readouterr().out + assert parse_outputs(out_file) == { + "success": "false", + "error_message": "Pre-snapshot validation failed: 2 errors. [View workflow logs](https://github.com/o/r/actions/runs/9)", + } + + +# ── main ───────────────────────────────────────────────────────────────────── + + +def test_main_gate_reads_the_environment(tmp_path): + out_file = tmp_path / "out" + env = { + "VALIDATION_SUMMARY": "1 error", + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_REPOSITORY": "camaraproject/Example", + "GITHUB_RUN_ID": "42", + "GITHUB_OUTPUT": str(out_file), + } + + assert main(["gate"], env) == 1 + + assert parse_outputs(out_file)["error_message"] == ( + "Pre-snapshot validation failed: 1 error. " + "[View workflow logs](https://github.com/camaraproject/Example/actions/runs/42)" + ) + + +def test_main_bot_identity_writes_name_and_email(tmp_path, monkeypatch): + class Users: + def get_user(self, login): + return {"id": 5, "login": login} + + monkeypatch.setattr(snapshot_prep, "_github_client", lambda env: Users()) + out_file = tmp_path / "out" + env = {"RELEASE_APP_SLUG": "camara-release", "APP_TOKEN_USED": "true", "GITHUB_OUTPUT": str(out_file)} + + assert main(["bot-identity"], env) == 0 + + assert parse_outputs(out_file) == { + "bot_name": "camara-release[bot]", + "bot_email": "5+camara-release[bot]@users.noreply.github.com", + } + + +def test_main_bot_identity_failure_is_an_error(tmp_path, monkeypatch, capsys): + class Users: + def get_user(self, login): + raise RuntimeError("gh command failed: 404") + + monkeypatch.setattr(snapshot_prep, "_github_client", lambda env: Users()) + env = {"APP_TOKEN_USED": "false", "GITHUB_OUTPUT": str(tmp_path / "out")} + + assert main(["bot-identity"], env) == 1 + + assert "::error::Could not resolve the bot identity: gh command failed: 404" in capsys.readouterr().out + + +def test_main_rejects_an_unknown_step(): + with pytest.raises(SystemExit): + main(["nonsense"], {}) diff --git a/shared-actions/create-snapshot/action.yml b/shared-actions/create-snapshot/action.yml index c90bd4cc..43a6b34a 100644 --- a/shared-actions/create-snapshot/action.yml +++ b/shared-actions/create-snapshot/action.yml @@ -231,6 +231,9 @@ runs: for api_name, version in result.api_versions.items(): print(f" {api_name}: {version}") + for note in result.notes: + print(note) + if result.warnings: for warning in result.warnings: print(f"::warning::{warning}") @@ -255,6 +258,8 @@ runs: error_msg = '; '.join(result.errors) if result.errors else 'Unknown error' print(f"::error::Snapshot creation failed: {error_msg}") + for note in result.notes: + print(note) for warning in result.warnings: print(f"::warning::{warning}") diff --git a/validation/tests/test_release_automation_workflow.py b/validation/tests/test_release_automation_workflow.py index 0258d241..c262ad6e 100644 --- a/validation/tests/test_release_automation_workflow.py +++ b/validation/tests/test_release_automation_workflow.py @@ -43,7 +43,8 @@ def test_create_snapshot_passes_release_history_to_pre_snapshot_validation(): assert resolver["id"] == "release-history" assert "GH_TOKEN" in resolver["env"] assert "_tooling/validation/scripts/resolve-release-history.py" in resolver["run"] - assert '--repo "${{ github.repository }}"' in resolver["run"] + assert '--repo "$GITHUB_REPOSITORY"' in resolver["run"] + assert '--base-ref "$BASE_REF"' in resolver["run"] assert '--output "${RUNNER_TEMP}/release-history.json"' in resolver["run"] validation = _step_by_name(steps, "Run pre-snapshot validation") @@ -171,7 +172,11 @@ def test_four_phase_jobs_and_three_job_conditions(): 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 }})" + # Job names are not evaluated for a skipped job; the action is carried in the step name. + assert "name" not in jobs["act"] + assert [s["name"] for s in jobs["act"]["steps"] if s.get("id") == "act"] == [ + "Act (${{ needs.plan.outputs.action }})" + ] def test_plan_has_one_json_plan_and_no_compatibility_outputs(): @@ -194,7 +199,7 @@ def test_snapshot_and_act_emit_one_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") + step = next(s for s in jobs["act"]["steps"] if s.get("id") == "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 @@ -235,3 +240,28 @@ def test_finalize_and_act_read_only_plan_document_keys(): assert finalize_reads and act_reads assert finalize_reads | act_reads <= set(document) + + +def test_snapshot_glue_runs_in_python_with_inputs_in_env(): + steps = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"]["snapshot"]["steps"] + + settings = _step_by_name(steps, "Validate central settings against schema") + assert settings["run"].strip() == "python -m release_automation.scripts.snapshot_prep settings" + gate = _step_by_name(steps, "Gate on validation result") + assert gate["run"].strip() == "python -m release_automation.scripts.snapshot_prep gate" + assert gate["env"]["VALIDATION_SUMMARY"] == "${{ steps.validation.outputs.summary }}" + identity = _step_by_name(steps, "Resolve Bot Identity") + assert identity["run"].strip() == "python -m release_automation.scripts.snapshot_prep bot-identity" + assert identity["env"]["APP_TOKEN_USED"] == "${{ steps.app-token.outputs.token != '' }}" + + names = [step.get("name") for step in steps] + assert names.index("Install dependencies") < names.index("Validate central settings against schema") + assert names.index("Validate central settings against schema") < names.index("Run pre-snapshot validation") + + +def test_snapshot_run_bodies_hold_no_expressions(): + """Expressions are pre-expanded into a run body; the snapshot job passes them through env.""" + steps = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"]["snapshot"]["steps"] + + for step in steps: + assert "${{" not in step.get("run", ""), step.get("name")