diff --git a/.github/landing-checklist/environment-release.schema.json b/.github/landing-checklist/environment-release.schema.json new file mode 100644 index 000000000..589d7fd31 --- /dev/null +++ b/.github/landing-checklist/environment-release.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/OpenHands/enterprise/.github/landing-checklist/environment-release.schema.json", + "title": "OpenHands environment-release event", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "event_id", + "environment", + "status", + "released_at", + "producer_repo", + "producer_sha", + "run_url", + "environment_url", + "artifact", + "components" + ], + "properties": { + "schema_version": {"const": 1}, + "event_id": {"type": "string", "minLength": 1}, + "environment": { + "enum": [ + "saas-staging", + "saas-production", + "replicated-unstable", + "replicated-beta", + "replicated-stable" + ] + }, + "status": {"const": "ready"}, + "released_at": {"type": "string", "format": "date-time"}, + "producer_repo": {"type": "string", "pattern": "^[^/\\s]+/[^/\\s]+$"}, + "producer_sha": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "run_url": {"type": "string", "format": "uri"}, + "environment_url": {"type": "string", "format": "uri"}, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "version"], + "properties": { + "kind": {"type": "string", "minLength": 1}, + "version": {"type": "string", "minLength": 1}, + "sequence": {"type": ["integer", "null"], "minimum": 1}, + "kots_cursor": {"type": ["integer", "null"], "minimum": 1} + } + }, + "components": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "previous_ref", "released_ref"], + "properties": { + "repo": {"type": "string", "pattern": "^[^/\\s]+/[^/\\s]+$"}, + "previous_ref": {"type": "string", "minLength": 1}, + "released_ref": {"type": "string", "minLength": 1} + } + } + } + } +} diff --git a/docs/landing-checklist/README.md b/docs/landing-checklist/README.md index 96b1caa76..72f55dcce 100644 --- a/docs/landing-checklist/README.md +++ b/docs/landing-checklist/README.md @@ -15,15 +15,20 @@ The implementation is intentionally split into stacked changes: 3. A guidance engine links verified E2E tests or derives labelled suggestions from PR and Linear evidence. 4. A delivery layer renders idempotent Slack and email notifications. +5. An operations layer composes delivery and Linear updates and documents the + producer handoff. -The environment producers remain in their owning repositories. The policy file -references the expected workflows and GitOps paths without attempting to deploy -cross-repository changes from this repo. +The environment producers remain in their owning repositories. The policy and +release-contract documents reference the expected workflows and GitOps paths +without attempting to deploy cross-repository changes from this repo. ## Source of truth - `.github/landing-checklist/repos.yml`: environment, delivery, and notification policy. +- `.github/landing-checklist/environment-release.schema.json`: versioned producer + event contract. +- `docs/landing-checklist/environment-release.md`: release-lane handoff and rollout. - `.github/landing-checklist/tracker-format.md`: GitHub, Slack, and email display contract. - `docs/landing-checklist/linear/state-machine.md`: lifecycle and evidence rules. diff --git a/docs/landing-checklist/environment-release.md b/docs/landing-checklist/environment-release.md new file mode 100644 index 000000000..a8c454a41 --- /dev/null +++ b/docs/landing-checklist/environment-release.md @@ -0,0 +1,68 @@ +# Environment release events + +A deployment producer emits one `environment-release` event only after an environment is ready for developer verification. The versioned contract is [`environment-release.schema.json`](../../.github/landing-checklist/environment-release.schema.json). Consumers must deduplicate on `event_id` and channel delivery keys. + +## Release lanes + +| Environment | Success signal | Producer change | +| --- | --- | --- | +| `saas-staging` | Argo CD reports the staging application healthy and synced after the promotion PR merges | Add a post-sync notification or hook in `OpenHands/saas-deploy`; a merge alone is not release success | +| `saas-production` | Argo CD reports the production application healthy and synced after the reviewed promotion merges | Add the same post-sync event with the production environment and URL | +| `replicated-unstable` | `OpenHands/OpenHands-Cloud` [`release-replicated-unstable.yml`](https://github.com/OpenHands/OpenHands-Cloud/blob/main/.github/workflows/release-replicated-unstable.yml) completes publication | Emit after the existing release job succeeds | +| `replicated-beta` | `OpenHands/OpenHands-Cloud` [`release-replicated-beta.yml`](https://github.com/OpenHands/OpenHands-Cloud/blob/main/.github/workflows/release-replicated-beta.yml) completes publication | Emit after the existing release job succeeds | +| `replicated-stable` | A stable artifact is published and its KOTS cursor is available | Add a dedicated stable promotion workflow; generic manual deployment is not a reliable release signal | + +`OpenHands/enterprise` owns consumption, contributor attribution, landing-tracker updates, test guidance, and email/Slack delivery. Producer repositories should only emit the shared event. Cross-repository changes are intentionally not implemented in this repository. + +## Required payload + +```json +{ + "schema_version": 1, + "event_id": "openhands-cloud:replicated-beta:1450", + "environment": "replicated-beta", + "status": "ready", + "released_at": "2026-08-26T18:04:00Z", + "producer_repo": "OpenHands/OpenHands-Cloud", + "producer_sha": "0123456789abcdef0123456789abcdef01234567", + "run_url": "https://github.com/OpenHands/OpenHands-Cloud/actions/runs/123", + "environment_url": "https://beta.example.com", + "artifact": { + "kind": "replicated-release", + "version": "1.2.3", + "sequence": 1450, + "kots_cursor": null + }, + "components": [ + { + "repo": "OpenHands/enterprise", + "previous_ref": "1111111111111111111111111111111111111111", + "released_ref": "2222222222222222222222222222222222222222" + } + ] +} +``` + +Each component range is the source of truth for GitHub PR attribution. Producers must not attempt to identify developers or tests. + +## Consumer behavior + +1. Validate the event against schema version 1 and reject non-`ready` statuses. +2. Resolve merged PRs in every component range and exclude automated changes and bot accounts. +3. Match registered feature PRs to their Linear landing trackers. +4. Store release evidence and derive the next landing stage from configured test and final targets. +5. Discover declared or changed E2E tests at the released commit. Clearly label PR- or Linear-derived steps as suggestions rather than verified tests. +6. Notify each contributor according to per-environment email and optional Slack preferences. +7. Update the Linear tracker with the environment URL, artifact, release run, and stage transition. +8. Record `event_id:github_login:channel` only after provider success so retries remain safe. + +Provider execution defaults to dry-run. Live delivery requires Resend and Slack credentials; Linear updates require a Linear API key. Recipient addresses and channel preferences belong in a secret-backed runtime directory, not in source control. + +## Rollout order + +1. Deploy the Enterprise consumer in dry-run and replay one recent event from each automated lane. +2. Enable Linear comments and verify event markers prevent repeated tracker evidence. +3. Enable email for an internal pilot group, then optional Slack DMs. +4. Add SaaS post-sync emitters. +5. Add Replicated unstable and beta emitters. +6. Create the stable promotion workflow and enable the final lane. diff --git a/enterprise/server/services/landing_notifications/__init__.py b/enterprise/server/services/landing_notifications/__init__.py index 2a5460b6b..bef235995 100644 --- a/enterprise/server/services/landing_notifications/__init__.py +++ b/enterprise/server/services/landing_notifications/__init__.py @@ -17,6 +17,11 @@ TestGuidance, TestInstruction, ) +from server.services.landing_notifications.linear_updates import ( + LinearClient, + LinearCommentPlan, + plan_linear_comment, +) from server.services.landing_notifications.models import ( DeliveryPolicy, Environment, @@ -37,6 +42,10 @@ plan_delivery_attempts, render_notification, ) +from server.services.landing_notifications.orchestrator import ( + ReleaseOperationPlan, + plan_release_operations, +) from server.services.landing_notifications.policy import derive_stage __all__ = [ @@ -52,6 +61,8 @@ 'GuidanceKind', 'GuidanceSource', 'LandingStage', + 'LinearClient', + 'LinearCommentPlan', 'LinearIssueContext', 'NotificationContent', 'PullRequestRecord', @@ -59,6 +70,7 @@ 'ReleaseArtifact', 'ReleaseComponent', 'ReleaseEvidence', + 'ReleaseOperationPlan', 'ReleasePlan', 'TestGuidance', 'TestInstruction', @@ -66,6 +78,8 @@ 'derive_stage', 'execute_delivery_attempts', 'plan_delivery_attempts', + 'plan_linear_comment', 'plan_release', + 'plan_release_operations', 'render_notification', ] diff --git a/enterprise/server/services/landing_notifications/linear_updates.py b/enterprise/server/services/landing_notifications/linear_updates.py new file mode 100644 index 000000000..c9f6d10a5 --- /dev/null +++ b/enterprise/server/services/landing_notifications/linear_updates.py @@ -0,0 +1,90 @@ +from typing import Any + +import httpx +from pydantic import BaseModel, Field +from server.services.landing_notifications.consumer_models import FeatureReleaseUpdate +from server.services.landing_notifications.models import EnvironmentRelease + +_LINEAR_API_URL = 'https://api.linear.app/graphql' + + +class LinearCommentPlan(BaseModel): + issue_id: str = Field(min_length=1) + event_id: str = Field(min_length=1) + body: str = Field(min_length=1) + + +def plan_linear_comment( + release: EnvironmentRelease, + update: FeatureReleaseUpdate, +) -> LinearCommentPlan: + transition = '' + if update.became_testable: + transition = ( + '\n\nThis feature is now ready for its planned environment testing.' + ) + if update.became_production_enabled: + transition = '\n\nAll configured final targets are ready; production enablement is unblocked.' + + return LinearCommentPlan( + issue_id=update.linear_issue_id, + event_id=release.event_id, + body=( + f'\n' + f'**{release.environment.value} is ready**\n\n' + f'- Stage: `{update.previous_stage.value}` → `{update.current_stage.value}`\n' + f'- Artifact: `{release.artifact.version}`\n' + f'- Environment: {release.environment_url}\n' + f'- Release evidence: {release.run_url}' + f'{transition}' + ), + ) + + +class LinearClient: + def __init__( + self, + api_key: str, + *, + client: httpx.Client | None = None, + ) -> None: + self._client = client or httpx.Client(timeout=20) + self._owns_client = client is None + self._headers = { + 'Authorization': api_key, + 'Content-Type': 'application/json', + } + + def create_comment(self, plan: LinearCommentPlan) -> str: + response = self._client.post( + _LINEAR_API_URL, + headers=self._headers, + json={ + 'query': ( + 'mutation($input: CommentCreateInput!) {' + ' commentCreate(input: $input) {' + ' success comment { id }' + ' }' + '}' + ), + 'variables': {'input': {'issueId': plan.issue_id, 'body': plan.body}}, + }, + ) + response.raise_for_status() + payload: dict[str, Any] = response.json() + if errors := payload.get('errors'): + raise RuntimeError(f'Linear comment creation failed: {errors}') + result = payload['data']['commentCreate'] + if not result['success']: + raise RuntimeError('Linear comment creation was not successful') + return str(result['comment']['id']) + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def __enter__(self) -> 'LinearClient': + return self + + def __exit__(self, *_: object) -> None: + self.close() diff --git a/enterprise/server/services/landing_notifications/orchestrator.py b/enterprise/server/services/landing_notifications/orchestrator.py new file mode 100644 index 000000000..f6183b57c --- /dev/null +++ b/enterprise/server/services/landing_notifications/orchestrator.py @@ -0,0 +1,68 @@ +from pydantic import BaseModel +from server.services.landing_notifications.consumer import plan_release +from server.services.landing_notifications.consumer_models import ( + FeatureRegistration, + PullRequestRecord, + ReleasePlan, +) +from server.services.landing_notifications.guidance_models import LinearIssueContext +from server.services.landing_notifications.linear_updates import ( + LinearCommentPlan, + plan_linear_comment, +) +from server.services.landing_notifications.models import EnvironmentRelease +from server.services.landing_notifications.notification_models import ( + DeliveryAttempt, + RecipientProfile, +) +from server.services.landing_notifications.notifications import ( + plan_delivery_attempts, + render_notification, +) + + +class ReleaseOperationPlan(BaseModel): + release_plan: ReleasePlan + linear_comments: tuple[LinearCommentPlan, ...] + deliveries: tuple[DeliveryAttempt, ...] + + +def plan_release_operations( + release: EnvironmentRelease, + pull_requests: list[PullRequestRecord], + features: list[FeatureRegistration], + recipients: list[RecipientProfile], + *, + linear_issues: dict[str, LinearIssueContext] | None = None, + delivered_keys: set[str] | None = None, +) -> ReleaseOperationPlan: + release_plan = plan_release(release, pull_requests, features) + profiles = {profile.github_login: profile for profile in recipients} + deliveries: list[DeliveryAttempt] = [] + for contributor in release_plan.contributors: + profile = profiles.get(contributor.login) + if not profile: + continue + content = render_notification( + release, + contributor, + pull_requests, + linear_issues, + ) + deliveries.extend( + plan_delivery_attempts( + release, + profile, + content, + delivered_keys or set(), + ) + ) + + return ReleaseOperationPlan( + release_plan=release_plan, + linear_comments=tuple( + plan_linear_comment(release, update) + for update in release_plan.feature_updates + ), + deliveries=tuple(deliveries), + ) diff --git a/enterprise/tests/unit/landing_notifications/test_contract.py b/enterprise/tests/unit/landing_notifications/test_contract.py new file mode 100644 index 000000000..f65718667 --- /dev/null +++ b/enterprise/tests/unit/landing_notifications/test_contract.py @@ -0,0 +1,47 @@ +import json +from datetime import UTC, datetime +from pathlib import Path + +from jsonschema import validate +from server.services.landing_notifications.models import ( + Environment, + EnvironmentRelease, + ReleaseArtifact, + ReleaseComponent, +) + +_SCHEMA_PATH = ( + Path(__file__).parents[4] + / '.github' + / 'landing-checklist' + / 'environment-release.schema.json' +) + + +def test_runtime_event_matches_published_schema() -> None: + event = EnvironmentRelease( + event_id='openhands-cloud:replicated-stable:1450', + environment=Environment.REPLICATED_STABLE, + released_at=datetime(2026, 8, 26, tzinfo=UTC), + producer_repo='OpenHands/OpenHands-Cloud', + producer_sha='a' * 40, + run_url='https://github.com/OpenHands/OpenHands-Cloud/actions/runs/1', + environment_url='https://stable.example.com', + artifact=ReleaseArtifact( + kind='replicated-release', + version='1.2.3', + sequence=1450, + kots_cursor=271, + ), + components=( + ReleaseComponent( + repo='OpenHands/enterprise', + previous_ref='1.2.2', + released_ref='1.2.3', + ), + ), + ) + schema = json.loads(_SCHEMA_PATH.read_text()) + + validate(event.model_dump(mode='json'), schema) + assert set(schema['properties']['environment']['enum']) == set(Environment) diff --git a/enterprise/tests/unit/landing_notifications/test_linear_updates.py b/enterprise/tests/unit/landing_notifications/test_linear_updates.py new file mode 100644 index 000000000..f5ad2b6a8 --- /dev/null +++ b/enterprise/tests/unit/landing_notifications/test_linear_updates.py @@ -0,0 +1,64 @@ +from datetime import UTC, datetime + +from server.services.landing_notifications.consumer_models import FeatureReleaseUpdate +from server.services.landing_notifications.linear_updates import plan_linear_comment +from server.services.landing_notifications.models import ( + Environment, + EnvironmentRelease, + FeatureProgress, + LandingStage, + ReleaseArtifact, + ReleaseComponent, + ReleaseEvidence, +) + + +def test_linear_comment_records_event_and_testable_transition() -> None: + release = EnvironmentRelease( + event_id='openhands-cloud:replicated-beta:1450', + environment=Environment.REPLICATED_BETA, + released_at=datetime(2026, 8, 26, tzinfo=UTC), + producer_repo='OpenHands/OpenHands-Cloud', + producer_sha='a' * 40, + run_url='https://github.com/OpenHands/OpenHands-Cloud/actions/runs/1', + environment_url='https://app.beta.example.com', + artifact=ReleaseArtifact(kind='release', version='1.2.3'), + components=( + ReleaseComponent( + repo='OpenHands/enterprise', + previous_ref='1.2.2', + released_ref='1.2.3', + ), + ), + ) + evidence = ReleaseEvidence( + environment=release.environment, + event_id=release.event_id, + artifact_version=release.artifact.version, + environment_url=release.environment_url, + run_url=release.run_url, + released_at=release.released_at, + ) + update = FeatureReleaseUpdate( + linear_issue_id='linear-uuid', + linear_identifier='FEAT-42', + linear_url='https://linear.app/openhands/issue/FEAT-42', + previous_stage=LandingStage.MERGED, + current_stage=LandingStage.TESTABLE, + became_testable=True, + became_production_enabled=False, + evidence=evidence, + progress=FeatureProgress( + merged=True, + evidence={release.environment: evidence}, + ), + ) + + plan = plan_linear_comment(release, update) + + assert plan.issue_id == 'linear-uuid' + assert ( + '' in plan.body + ) + assert '`merged` → `testable`' in plan.body + assert 'ready for its planned environment testing' in plan.body diff --git a/enterprise/tests/unit/landing_notifications/test_orchestrator.py b/enterprise/tests/unit/landing_notifications/test_orchestrator.py new file mode 100644 index 000000000..8e0c37090 --- /dev/null +++ b/enterprise/tests/unit/landing_notifications/test_orchestrator.py @@ -0,0 +1,81 @@ +from datetime import UTC, datetime + +from server.services.landing_notifications.consumer_models import ( + FeatureRegistration, + PullRequestRecord, +) +from server.services.landing_notifications.models import ( + DeliveryPolicy, + Environment, + EnvironmentRelease, + FeatureProgress, + LandingStage, + ReleaseArtifact, + ReleaseComponent, +) +from server.services.landing_notifications.notification_models import ( + DeliveryChannel, + RecipientProfile, +) +from server.services.landing_notifications.orchestrator import plan_release_operations + + +def test_release_event_plans_tracker_update_and_actionable_delivery() -> None: + release = EnvironmentRelease( + event_id='openhands-cloud:replicated-beta:1450', + environment=Environment.REPLICATED_BETA, + released_at=datetime(2026, 8, 26, tzinfo=UTC), + producer_repo='OpenHands/OpenHands-Cloud', + producer_sha='a' * 40, + run_url='https://github.com/OpenHands/OpenHands-Cloud/actions/runs/1', + environment_url='https://app.beta.example.com', + artifact=ReleaseArtifact(kind='release', version='1.2.3'), + components=( + ReleaseComponent( + repo='OpenHands/enterprise', + previous_ref='1.2.2', + released_ref='1.2.3', + ), + ), + ) + pull_request = PullRequestRecord( + repo='OpenHands/enterprise', + number=42, + merge_sha='b' * 40, + title='feat(orgs): migrate memberships', + url='https://github.com/OpenHands/enterprise/pull/42', + author_login='alice', + body='- Primary E2E test: `frontend/e2e/org-migration.spec.ts`', + linear_identifier='FEAT-42', + ) + feature = FeatureRegistration( + repo='OpenHands/enterprise', + pr_number=42, + merge_sha=pull_request.merge_sha, + linear_issue_id='linear-uuid', + linear_identifier='FEAT-42', + linear_url='https://linear.app/openhands/issue/FEAT-42', + policy=DeliveryPolicy( + test_targets={Environment.REPLICATED_BETA}, + final_targets={Environment.SAAS_PRODUCTION}, + ), + progress=FeatureProgress(merged=True), + ) + recipient = RecipientProfile( + github_login='alice', + email='alice@openhands.dev', + channels={Environment.REPLICATED_BETA: {DeliveryChannel.EMAIL}}, + ) + + plan = plan_release_operations( + release, + [pull_request], + [feature], + [recipient], + ) + + update = plan.release_plan.feature_updates[0] + assert update.current_stage == LandingStage.TESTABLE + assert plan.linear_comments[0].issue_id == 'linear-uuid' + assert plan.deliveries[0].delivery_key.endswith(':alice:email') + assert 'frontend/e2e/org-migration.spec.ts' in plan.deliveries[0].payload['text']