Release 0.2.0: graduation merge race fix + VITE_APP_NAME + versioning infrastructure

- Fix §13.3 step 4 race against Gitea's async mergeability
  computation: gitea.wait_for_mergeable polls the mergeable
  field before calling merge, and bot._merge_with_retry rides
  out the transient 'Please try again later' response.
- Frontend now requires VITE_APP_NAME at build time
  (frontend/vite.config.js fails the build if unset). Header
  brand, landing H1, and browser title read the deployment's
  configured name.
- Establish framework versioning: VERSION (canonical) +
  frontend/package.json#version mirror; CHANGELOG.md as the
  release log with RFC 2119 / 8174 normative-language
  upgrade-steps; SPEC.md §20 as the binding policy; CLAUDE.md
  capturing the separation-of-concerns rule; docs/DEPLOYMENTS.md
  as the downstream operating manual; docs/QUICKSTART-VISION.md
  staking the future one-command-deploy capability.
- Surgical fix to SPEC.md opening so the spec no longer asserts
  OHM is the corpus the framework produces.
- SPEC.md §19.2 gains the 'Deployment-supplied subject framing'
  candidate that drives the 0.3.0 release.
This commit is contained in:
Ben Stull
2026-05-26 06:37:48 -07:00
parent 46049d296b
commit 29c96ea300
14 changed files with 1027 additions and 37 deletions
+78 -7
View File
@@ -22,11 +22,67 @@ anywhere.
"""
from __future__ import annotations
import asyncio
import json
import logging
from dataclasses import dataclass
from . import db, notify
from .gitea import Gitea
from .gitea import Gitea, GiteaError
log = logging.getLogger(__name__)
_MERGE_TRANSIENT_HINTS = (
"please try again later",
"is not ready",
"not ready to be merged",
)
async def _merge_with_retry(
gitea: Gitea,
org: str,
meta_repo: str,
pr_number: int,
*,
merge_message_title: str,
merge_message_body: str,
attempts: int = 3,
delay_seconds: float = 1.0,
) -> None:
"""Call gitea.merge_pull and retry on Gitea's transient
"Please try again later" / "not ready" responses. After
`wait_for_mergeable` returns this is rare, but Gitea has been
observed to flip back to the unready state momentarily on a
write-amplified instance; a small bounded retry closes the gap.
Any other GiteaError propagates immediately so the §13.3
orchestrator can fail the step and run rollback.
"""
last_error: GiteaError | None = None
for attempt in range(1, attempts + 1):
try:
await gitea.merge_pull(
org, meta_repo, pr_number,
merge_message_title=merge_message_title,
merge_message_body=merge_message_body,
style="merge",
)
return
except GiteaError as e:
detail = (e.detail or "").lower()
if not any(hint in detail for hint in _MERGE_TRANSIENT_HINTS):
raise
last_error = e
log.warning(
"merge_pull transient (attempt %d/%d) for %s/%s#%d: %s",
attempt, attempts, org, meta_repo, pr_number, e.detail,
)
if attempt < attempts:
await asyncio.sleep(delay_seconds)
assert last_error is not None
raise last_error
@dataclass(frozen=True)
@@ -823,14 +879,29 @@ class Bot:
) -> None:
"""§13.3 step 4: auto-merge the graduation PR with the admin as
merge actor. Distinct action_kind so the audit log carries the
graduation as a linkable sequence per §13.3's transactional shape."""
graduation as a linkable sequence per §13.3's transactional shape.
Gitea computes a PR's mergeability asynchronously in a background
job. Step 3 (`open_graduation_pr`) returns the moment the PR is
created, which is typically before that job has finished — calling
`merge_pull` immediately produces a `405 "Please try again later"`
and the §13.3 orchestrator interprets it as a terminal failure.
We wait for the mergeability computation to settle before the
merge call, then retry the merge a couple of times if Gitea still
reports the transient state (belt-and-suspenders against the same
race appearing in a different shape).
"""
subject = f"Graduate {slug}{rfc_id}"
body = _trailer(actor)
await self._gitea.merge_pull(
org, meta_repo, pr_number,
merge_message_title=subject,
merge_message_body=body,
style="merge",
try:
await self._gitea.wait_for_mergeable(org, meta_repo, pr_number)
except TimeoutError as e:
raise GiteaError(409, str(e)) from e
await _merge_with_retry(
self._gitea, org, meta_repo, pr_number,
merge_message_title=subject, merge_message_body=body,
)
_log(
actor,