Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 23 additions & 70 deletions .github/workflows/release-automation-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions release_automation/scripts/bot_identity.py
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 2 additions & 4 deletions release_automation/scripts/finalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
3 changes: 2 additions & 1 deletion release_automation/scripts/snapshot_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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"
)

Expand Down
105 changes: 105 additions & 0 deletions release_automation/scripts/snapshot_prep.py
Original file line number Diff line number Diff line change
@@ -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 <step>
"""

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:]))
41 changes: 41 additions & 0 deletions release_automation/tests/test_bot_identity.py
Original file line number Diff line number Diff line change
@@ -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]"
30 changes: 30 additions & 0 deletions release_automation/tests/test_snapshot_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading