feat: recover failed post-merge checks (Closes #1366)
CI / lint (pull_request) Successful in 3m16s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m30s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-24 21:36:16 +00:00
1 parent d4ddac07ab
commit 966116e5ee
7 files changed
+694 -5

No files matched your search

@@ -0,0 +1,91 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged release-failure recovery runs only in the browser gate", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 390, "height": 844},
])
def test_failed_release_check_is_diagnosable_and_retryable_on_phone(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "release-receipt.js")
result = page.evaluate("""async () => {
const calls=[];
const values=new Map();
const storage={
getItem:key=>values.get(key)||null,
setItem:(key,value)=>values.set(key,value),
removeItem:key=>values.delete(key),
};
const receipt=createReleaseReceipt({
storage,
getLogin:()=> 'timmy',
listNode:document.querySelector('#release-watchlist'),
confirmAction:()=>true,
fetchJson:async(path, options={})=>{
calls.push({path, method:options.method || 'GET'});
if (path.endsWith('/failure')) return {
commit_sha:'abc1234', run_id:91, job_index:3,
name:'CI / browser', excerpt:'AssertionError: mobile viewport overflow',
};
if (path.endsWith('/retry')) return {commit_sha:'abc1234', status:'queued'};
return {
commit_sha:'abc1234', ci_state:'failure', release:null,
checks:[{
name:'CI / browser with a deliberately long workflow name',
state:'failure', description:'Playwright failed at the narrow viewport',
url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',
recovery:{run_id:91, job_index:3},
}],
};
},
});
receipt.capture(
{repository:'stackchain/api-with-a-long-mobile-name', number:7, key:'stackchain/api-with-a-long-mobile-name#7'},
{merge_commit_sha:'abc1234'},
);
document.querySelector('#release-receipt-sheet').showModal();
await receipt.refresh();
const review=document.querySelector('.release-failure-summary button');
const reviewHeight=review.getBoundingClientRect().height;
review.click();
await new Promise(resolve=>setTimeout(resolve, 0));
const recovery=document.querySelector('.release-failure-recovery');
const retry=recovery.querySelector('button');
const retryHeight=retry.getBoundingClientRect().height;
const excerpt=recovery.querySelector('pre').textContent;
const sha=recovery.querySelector('strong').textContent;
const job=recovery.querySelector('a').href;
const overflow=document.documentElement.scrollWidth > document.documentElement.clientWidth;
retry.click();
await new Promise(resolve=>setTimeout(resolve, 0));
return {
calls, reviewHeight, retryHeight, excerpt, sha, job, overflow,
state:receipt.restore()[0].status.label,
};
}""")
expect(page.locator("#release-receipt-sheet")).to_be_visible()
assert result["reviewHeight"] >= 44
assert result["retryHeight"] >= 44
assert result["excerpt"] == "AssertionError: mobile viewport overflow"
assert result["sha"] == "Merge abc1234"
assert result["job"].endswith("/actions/runs/91/jobs/3")
assert result["overflow"] is False
assert result["state"] == "Checks running"
assert [call["method"] for call in result["calls"]] == ["GET", "GET", "POST"]
browser.close()
+194
View File
@@ -2051,6 +2051,200 @@ async def test_release_receipt_matches_only_the_captured_commit_and_reports_chec
assert response.json()["release"] is None
@pytest.mark.anyio
async def test_release_receipt_preserves_only_safe_actions_recovery_for_failed_exact_commit(monkeypatch):
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example/git")
async def handler(request):
if request.url.path.endswith("/commits/merge456/status"):
return httpx.Response(200, json={
"state": "failure",
"statuses": [
{
"context": "CI / browser (push)",
"status": "failure",
"description": "Playwright failed",
"target_url": "/git/stackchain/api/actions/runs/91/jobs/3",
},
{
"context": "external",
"status": "failure",
"description": "External check failed",
"target_url": "https://checks.example/jobs/4",
},
],
})
if request.url.path.endswith("/releases"):
return httpx.Response(200, json=[])
raise AssertionError(request.url.path)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.release_receipt_status("stackchain/api", "merge456")
finally:
await gitea_proxy.stop_client()
assert result["checks"] == [
{
"name": "CI / browser (push)",
"state": "failure",
"description": "Playwright failed",
"url": "https://forge.example/git/stackchain/api/actions/runs/91/jobs/3",
"recovery": {"run_id": 91, "job_index": 3},
},
{
"name": "external",
"state": "failure",
"description": "External check failed",
"url": "",
},
]
@pytest.mark.anyio
async def test_release_action_failure_excerpt_is_bound_to_exact_merge_commit(monkeypatch):
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "http://test/git")
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/commits/merge456/status"):
return httpx.Response(200, json={"statuses": [{
"context": "CI / browser (push)",
"status": "failure",
"target_url": "/git/stackchain/api/actions/runs/91/jobs/3",
}]})
if request.url.path.endswith("/actions/runs/91/jobs/3/logs"):
return httpx.Response(200, text="GITEA_TOKEN=secret\nAssertionError: mobile journey failed")
raise AssertionError(request.url.path)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.release_action_failure_excerpt(
"stackchain/api", "merge456", 91, 3
)
finally:
await gitea_proxy.stop_client()
assert result == {
"commit_sha": "merge456",
"run_id": 91,
"job_index": 3,
"name": "CI / browser (push)",
"excerpt": "GITEA_TOKEN=[redacted]\nAssertionError: mobile journey failed",
}
assert requests == [
("GET", "/git/api/v1/repos/stackchain/api/commits/merge456/status"),
("GET", "/git/stackchain/api/actions/runs/91/jobs/3/logs"),
]
@pytest.mark.anyio
async def test_retry_release_action_job_revalidates_failed_exact_commit(monkeypatch):
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "http://test/git")
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/commits/merge456/status"):
return httpx.Response(200, json={"statuses": [{
"context": "CI / browser (push)",
"status": "failure",
"target_url": "/git/stackchain/api/actions/runs/91/jobs/3",
}]})
if request.method == "GET" and request.url.path.endswith("/actions/runs/91/jobs/3"):
return httpx.Response(200, text="<script>csrfToken: 'csrf-123'</script>")
if request.method == "POST" and request.url.path.endswith("/actions/runs/91/jobs/3/rerun"):
assert request.headers["x-csrf-token"] == "csrf-123"
return httpx.Response(303)
raise AssertionError(request.url.path)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.retry_release_action_job(
"stackchain/api", "merge456", 91, 3
)
finally:
await gitea_proxy.stop_client()
assert result == {
"commit_sha": "merge456", "run_id": 91, "job_index": 3, "status": "queued"
}
assert requests == [
("GET", "/git/api/v1/repos/stackchain/api/commits/merge456/status"),
("GET", "/git/stackchain/api/actions/runs/91/jobs/3"),
("POST", "/git/stackchain/api/actions/runs/91/jobs/3/rerun"),
]
@pytest.mark.anyio
async def test_release_failure_recovery_endpoints_authorize_pull_and_bind_merge_commit(monkeypatch):
calls = []
async def release_access(repository, number, commit_sha):
calls.append(("access", repository, number, commit_sha))
return True
async def excerpt(repository, commit_sha, run_id, job_index):
calls.append(("excerpt", repository, commit_sha, run_id, job_index))
return {
"commit_sha": commit_sha, "run_id": run_id, "job_index": job_index,
"name": "CI / browser", "excerpt": "AssertionError: failed",
}
async def retry(repository, commit_sha, run_id, job_index):
calls.append(("retry", repository, commit_sha, run_id, job_index))
return {
"commit_sha": commit_sha, "run_id": run_id, "job_index": job_index,
"status": "queued",
}
monkeypatch.setattr(main.gitea_proxy, "can_recover_merged_release", release_access, raising=False)
monkeypatch.setattr(main.gitea_proxy, "release_action_failure_excerpt", excerpt, raising=False)
monkeypatch.setattr(main.gitea_proxy, "retry_release_action_job", retry, raising=False)
path = "/api/v1/repos/stackchain/api/pulls/7/release-receipt/abc1234/checks/91/jobs/3"
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
failure = await client.get(path + "/failure")
retried = await client.post(path + "/retry")
assert failure.status_code == 200
assert failure.headers["cache-control"] == "no-store"
assert failure.json()["commit_sha"] == "abc1234"
assert retried.status_code == 202
assert retried.headers["cache-control"] == "no-store"
assert retried.json()["status"] == "queued"
assert calls == [
("access", "stackchain/api", 7, "abc1234"),
("excerpt", "stackchain/api", "abc1234", 91, 3),
("access", "stackchain/api", 7, "abc1234"),
("retry", "stackchain/api", "abc1234", 91, 3),
]
@pytest.mark.anyio
async def test_merged_release_recovery_access_requires_exact_commit_and_participant():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"state": "closed", "merged": True, "merge_commit_sha": "abc1234",
"user": {"login": "timmy"}, "assignees": [],
})
raise AssertionError(request.url.path)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
exact = await gitea_proxy.can_recover_merged_release("stackchain/api", 7, "abc1234")
other = await gitea_proxy.can_recover_merged_release("stackchain/api", 7, "def5678")
finally:
await gitea_proxy.stop_client()
assert exact is True
assert other is False
@pytest.mark.anyio
async def test_gitea_release_receipt_ignores_other_commits_and_normalizes_matching_assets():
forge = gitea_proxy.GITEA_URL.rstrip("/")
+84
View File
@@ -96,6 +96,84 @@ receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {
assert output["restored"] == []
def test_failed_release_check_loads_exact_commit_diagnostics_and_retries_single_flight():
output = run_node(r"""
const values=new Map(), calls=[];
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let finishRetry;
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',confirmAction:()=>true,fetchJson:async(path,options={})=>{
calls.push({path,method:options.method||'GET'});
if (path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'AssertionError: failed'};
if (path.endsWith('/retry')) return await new Promise(resolve=>{finishRetry=()=>resolve({commit_sha:'abc1234',status:'queued'});});
return {commit_sha:'abc1234',ci_state:'failure',checks:[{name:'CI / browser',state:'failure',description:'Playwright failed',url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',recovery:{run_id:91,job_index:3}}],release:null};
}});
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'abc1234'});
(async()=>{
const failed=(await receipt.refresh())[0];
const detail=await receipt.reviewFailure('stackchain/api','abc1234',91,3);
const first=receipt.retryFailure('stackchain/api','abc1234',91,3);
const second=receipt.retryFailure('stackchain/api','abc1234',91,3);
await new Promise(resolve=>setTimeout(resolve,0)); finishRetry();
const results=await Promise.all([first,second]);
process.stdout.write(JSON.stringify({failed,detail,results,calls,after:receipt.restore()[0].status}));
})();
""")
assert output["failed"]["failures"] == [{
"name": "CI / browser",
"description": "Playwright failed",
"url": "https://forge.example/git/stackchain/api/actions/runs/91/jobs/3",
"recovery": {"run_id": 91, "job_index": 3},
}]
assert output["detail"]["commit_sha"] == "abc1234"
assert output["detail"]["excerpt"] == "AssertionError: failed"
assert output["results"] == [True, True]
assert [call["method"] for call in output["calls"]] == ["GET", "GET", "POST"]
assert output["calls"][1]["path"].endswith(
"/pulls/7/release-receipt/abc1234/checks/91/jobs/3/failure"
)
assert output["calls"][2]["path"].endswith("/checks/91/jobs/3/retry")
assert output["after"]["label"] == "Checks running"
assert output["after"]["checks"] == []
def test_failed_release_row_renders_phone_recovery_with_exact_sha_log_and_job_fallback():
output = run_node(r"""
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',className:'',disabled:false,append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; }
global.document={createElement:tag=>node(tag)};
const values=new Map(), listNode=node();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',listNode,confirmAction:()=>true,fetchJson:async path=>{
if (path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'AssertionError: mobile viewport overflow'};
return {commit_sha:'abc1234',ci_state:'failure',checks:[{name:'CI / browser',state:'failure',description:'Playwright failed',url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',recovery:{run_id:91,job_index:3}}]};
}});
const flatten=root=>[root,...root.children.flatMap(flatten)];
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'abc1234'});
(async()=>{
await receipt.refresh();
const review=flatten(listNode).find(value=>value.textContent==='Review failure');
await review.click();
const nodes=flatten(listNode);
process.stdout.write(JSON.stringify({
reviewLabel:review['aria-label'],
texts:nodes.map(value=>value.textContent).filter(Boolean),
links:nodes.filter(value=>value.tag==='a').map(value=>({text:value.textContent,href:value.href})),
classes:nodes.map(value=>value.className).filter(Boolean),
}));
})();
""")
assert output["reviewLabel"] == "Review failed check CI / browser for abc1234"
assert "Merge abc1234" in output["texts"]
assert "AssertionError: mobile viewport overflow" in output["texts"]
assert "Retry failed check" in output["texts"]
assert output["links"] == [{
"text": "Open job",
"href": "https://forge.example/git/stackchain/api/actions/runs/91/jobs/3",
}]
assert "release-failure-recovery" in output["classes"]
def test_watchlist_preserves_distinct_merges_deduplicates_and_dismisses_one():
output = run_node(r"""
const values=new Map();
@@ -276,3 +354,9 @@ def test_mobile_release_receipt_is_wired_into_the_merge_flow_and_phone_safe():
cleanup_css = css[css.index(".release-watchlist-actions"):]
assert "min-height:44px" in cleanup_css
assert "grid-template-columns:1fr" in cleanup_css
recovery_css = css[css.index(".release-failure-summary"):]
assert "overflow-x:hidden" in recovery_css
assert "white-space:pre-wrap" in recovery_css
assert "min-height:44px" in recovery_css
assert ".release-failure-actions" in recovery_css
assert "grid-template-columns:1fr" in recovery_css