Skip to content
Open
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
64 changes: 64 additions & 0 deletions .github/landing-checklist/environment-release.schema.json
Original file line number Diff line number Diff line change
@@ -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}
}
}
}
}
}
11 changes: 8 additions & 3 deletions docs/landing-checklist/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions docs/landing-checklist/environment-release.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions enterprise/server/services/landing_notifications/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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__ = [
Expand All @@ -52,20 +61,25 @@
'GuidanceKind',
'GuidanceSource',
'LandingStage',
'LinearClient',
'LinearCommentPlan',
'LinearIssueContext',
'NotificationContent',
'PullRequestRecord',
'RecipientProfile',
'ReleaseArtifact',
'ReleaseComponent',
'ReleaseEvidence',
'ReleaseOperationPlan',
'ReleasePlan',
'TestGuidance',
'TestInstruction',
'build_test_guidance',
'derive_stage',
'execute_delivery_attempts',
'plan_delivery_attempts',
'plan_linear_comment',
'plan_release',
'plan_release_operations',
'render_notification',
]
90 changes: 90 additions & 0 deletions enterprise/server/services/landing_notifications/linear_updates.py
Original file line number Diff line number Diff line change
@@ -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'<!-- environment-release:{release.event_id} -->\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()
68 changes: 68 additions & 0 deletions enterprise/server/services/landing_notifications/orchestrator.py
Original file line number Diff line number Diff line change
@@ -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),
)
Loading
Loading