fix: keep PR walkthrough links canonical (#3438)
This commit is contained in:
@@ -8,6 +8,8 @@ import unittest
|
|||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "pr-walkthrough.yml"
|
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "pr-walkthrough.yml"
|
||||||
|
RECONCILE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "pr-walkthrough-reconcile.yml"
|
||||||
|
PREVIEW_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "preview-env.yml"
|
||||||
REVIEW_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "opencode-code-review.yml"
|
REVIEW_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "opencode-code-review.yml"
|
||||||
LINT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml"
|
LINT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml"
|
||||||
SETUP_OPENCODE_ACTION = REPO_ROOT / ".github" / "actions" / "setup-opencode" / "action.yml"
|
SETUP_OPENCODE_ACTION = REPO_ROOT / ".github" / "actions" / "setup-opencode" / "action.yml"
|
||||||
@@ -35,11 +37,34 @@ class PRWalkthroughWorkflowContractTest(unittest.TestCase):
|
|||||||
if not WORKFLOW.is_file():
|
if not WORKFLOW.is_file():
|
||||||
raise AssertionError("PR walkthrough must have a dedicated workflow file")
|
raise AssertionError("PR walkthrough must have a dedicated workflow file")
|
||||||
cls.workflow = WORKFLOW.read_text(encoding="utf-8")
|
cls.workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
cls.reconcile_workflow = (
|
||||||
|
RECONCILE_WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
if RECONCILE_WORKFLOW.is_file()
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
cls.preview_workflow = PREVIEW_WORKFLOW.read_text(encoding="utf-8")
|
||||||
cls.review_workflow = REVIEW_WORKFLOW.read_text(encoding="utf-8")
|
cls.review_workflow = REVIEW_WORKFLOW.read_text(encoding="utf-8")
|
||||||
cls.skill = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
|
cls.skill = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
|
||||||
cls.generation = workflow_job(cls.workflow, "pr-walkthrough-generate")
|
cls.generation = workflow_job(cls.workflow, "pr-walkthrough-generate")
|
||||||
cls.publication = workflow_job(cls.workflow, "pr-walkthrough-publish")
|
cls.publication = workflow_job(cls.workflow, "pr-walkthrough-publish")
|
||||||
cls.link = workflow_job(cls.workflow, "pr-walkthrough-link")
|
cls.link = workflow_job(cls.workflow, "pr-walkthrough-link")
|
||||||
|
cls.reconcile = (
|
||||||
|
workflow_job(cls.reconcile_workflow, "pr-walkthrough-reconcile")
|
||||||
|
if cls.reconcile_workflow
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
cls.preview_deploy = workflow_job(cls.preview_workflow, "deploy-same-repo")
|
||||||
|
cls.preview_fork = workflow_job(cls.preview_workflow, "deploy-fork")
|
||||||
|
cls.preview_cleanup = workflow_job(cls.preview_workflow, "cleanup-preview")
|
||||||
|
cls.preview_description_same = workflow_job(
|
||||||
|
cls.preview_workflow, "update-description-same-repo"
|
||||||
|
)
|
||||||
|
cls.preview_description_fork = workflow_job(
|
||||||
|
cls.preview_workflow, "update-description-fork"
|
||||||
|
)
|
||||||
|
cls.preview_description_cleanup = workflow_job(
|
||||||
|
cls.preview_workflow, "cleanup-preview-description"
|
||||||
|
)
|
||||||
cls.same_repo_review = workflow_job(cls.review_workflow, "opencode-review-same-repo")
|
cls.same_repo_review = workflow_job(cls.review_workflow, "opencode-review-same-repo")
|
||||||
cls.fork_review = workflow_job(cls.review_workflow, "opencode-review-fork")
|
cls.fork_review = workflow_job(cls.review_workflow, "opencode-review-fork")
|
||||||
|
|
||||||
@@ -76,6 +101,82 @@ class PRWalkthroughWorkflowContractTest(unittest.TestCase):
|
|||||||
self.assertIn(condition, self.generation)
|
self.assertIn(condition, self.generation)
|
||||||
self.assertNotIn("safe-to-test", self.generation)
|
self.assertNotIn("safe-to-test", self.generation)
|
||||||
|
|
||||||
|
def test_reconciliation_is_limited_to_authorized_edited_events(self) -> None:
|
||||||
|
self.assertIn("pull_request_target:", self.reconcile_workflow)
|
||||||
|
self.assertIn("types: [edited]", self.reconcile_workflow)
|
||||||
|
for condition in (
|
||||||
|
"vars.PR_WALKTHROUGH_ENABLED == 'true'",
|
||||||
|
"github.event_name == 'pull_request_target'",
|
||||||
|
"github.event.action == 'edited'",
|
||||||
|
"github.event.pull_request.draft == false",
|
||||||
|
"github.event.pull_request.head.repo.full_name == github.repository",
|
||||||
|
"github.event.pull_request.head.repo.full_name != github.repository",
|
||||||
|
"contains(github.event.pull_request.labels.*.name, 'safe-to-review')",
|
||||||
|
"vars.CLAUDE_REVIEW_ALLOWLIST != ''",
|
||||||
|
"contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login)",
|
||||||
|
):
|
||||||
|
self.assertIn(condition, self.reconcile)
|
||||||
|
self.assertNotIn("safe-to-test", self.reconcile)
|
||||||
|
|
||||||
|
def test_reconciliation_uses_trusted_read_only_publication_check(self) -> None:
|
||||||
|
for value in (
|
||||||
|
"ref: ${{ github.workflow_sha }}",
|
||||||
|
"fetch-depth: 1",
|
||||||
|
"persist-credentials: false",
|
||||||
|
'test "$(git rev-parse HEAD)" = "$TRUSTED_SHA"',
|
||||||
|
"WALKTHROUGH_BASE_URL: https://walkthrough.kandev.ai",
|
||||||
|
'SHORT_HEAD_SHA="${HEAD_SHA:0:12}"',
|
||||||
|
'PUBLIC_URL="${WALKTHROUGH_BASE_URL%/}/pr/${PR_NUMBER}/${SHORT_HEAD_SHA}.html"',
|
||||||
|
"RESPONSE_META_PATH=pr-walkthrough-public.response",
|
||||||
|
"curl --fail",
|
||||||
|
"--write-out '%{http_code}\\t%{content_type}\\t%{url_effective}'",
|
||||||
|
"response_status",
|
||||||
|
'response_effective_url" != "$PUBLIC_URL"',
|
||||||
|
"response_content_type",
|
||||||
|
'test -s "$PUBLIC_HTML_PATH"',
|
||||||
|
):
|
||||||
|
self.assertIn(value, self.reconcile)
|
||||||
|
self.assertNotIn("--dump-header", self.reconcile)
|
||||||
|
|
||||||
|
for forbidden in (
|
||||||
|
"OPENCODE_API_KEY",
|
||||||
|
"CLOUDFLARE_R2",
|
||||||
|
"SPRITES_API_TOKEN",
|
||||||
|
"opencode run",
|
||||||
|
"go run",
|
||||||
|
"pull_request.head.repo.full_name }}",
|
||||||
|
):
|
||||||
|
self.assertNotIn(forbidden, self.reconcile)
|
||||||
|
|
||||||
|
self.assertIn("contents: read", self.reconcile)
|
||||||
|
self.assertIn("pull-requests: write", self.reconcile)
|
||||||
|
self.assertIn("github.workflow_sha", self.reconcile)
|
||||||
|
|
||||||
|
def test_reconciliation_reuses_marker_required_race_safe_body_update(self) -> None:
|
||||||
|
for value in (
|
||||||
|
"concurrency:",
|
||||||
|
"group: pr-description-${{ github.event.pull_request.number }}",
|
||||||
|
"cancel-in-progress: false",
|
||||||
|
"for attempt in 1 2 3; do",
|
||||||
|
"--require-existing",
|
||||||
|
"pr-latest-response.json",
|
||||||
|
"jq -j '.body // \"\"'",
|
||||||
|
"cmp -- pr-body-before pr-body-latest",
|
||||||
|
"pr-body-expected",
|
||||||
|
"pr-body-verified",
|
||||||
|
"cmp -- pr-body-expected pr-body-verified",
|
||||||
|
"gh api --method PATCH",
|
||||||
|
"current_head_sha=",
|
||||||
|
"verified_head_sha=",
|
||||||
|
"pr-verified-response.json",
|
||||||
|
"verify_status",
|
||||||
|
):
|
||||||
|
self.assertIn(value, self.reconcile)
|
||||||
|
|
||||||
|
patch_position = self.reconcile.index("gh api --method PATCH")
|
||||||
|
public_check_position = self.reconcile.index("curl --fail")
|
||||||
|
self.assertLess(public_check_position, patch_position)
|
||||||
|
|
||||||
def test_skill_explains_trusted_context_without_provider_names(self) -> None:
|
def test_skill_explains_trusted_context_without_provider_names(self) -> None:
|
||||||
self.assertNotIn("Kandev", self.skill)
|
self.assertNotIn("Kandev", self.skill)
|
||||||
self.assertIn("managed runner", self.skill)
|
self.assertIn("managed runner", self.skill)
|
||||||
@@ -365,12 +466,39 @@ class PRWalkthroughWorkflowContractTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_link_job_retries_transient_github_api_responses(self) -> None:
|
def test_link_job_retries_transient_github_api_responses(self) -> None:
|
||||||
for value in (
|
for value in (
|
||||||
"pr_response_ok=false",
|
|
||||||
"for attempt in 1 2 3; do",
|
"for attempt in 1 2 3; do",
|
||||||
|
'if ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > pr-response.json ||',
|
||||||
"python3 -c 'import json; json.load(open(\"pr-response.json\", encoding=\"utf-8\"))'",
|
"python3 -c 'import json; json.load(open(\"pr-response.json\", encoding=\"utf-8\"))'",
|
||||||
'test "$pr_response_ok" = true',
|
"pr-latest-response.json",
|
||||||
"patch_ok=false",
|
"pr-verified-response.json",
|
||||||
'test "$patch_ok" = true',
|
"sleep 2",
|
||||||
|
):
|
||||||
|
self.assertIn(value, self.link)
|
||||||
|
|
||||||
|
def test_description_writers_compare_fresh_bodies_and_verify_after_patch(self) -> None:
|
||||||
|
for job in (
|
||||||
|
self.link,
|
||||||
|
self.preview_description_same,
|
||||||
|
self.preview_description_fork,
|
||||||
|
self.preview_description_cleanup,
|
||||||
|
):
|
||||||
|
self.assertIn("concurrency:", job)
|
||||||
|
self.assertIn(
|
||||||
|
"group: pr-description-${{ github.event.pull_request.number }}",
|
||||||
|
job,
|
||||||
|
)
|
||||||
|
self.assertIn("cancel-in-progress: false", job)
|
||||||
|
|
||||||
|
for value in (
|
||||||
|
"for attempt in 1 2 3; do",
|
||||||
|
"pr-latest-response.json",
|
||||||
|
"jq -j '.body // \"\"'",
|
||||||
|
"cmp -- pr-body-before pr-body-latest",
|
||||||
|
"pr-body-expected",
|
||||||
|
"pr-body-verified",
|
||||||
|
"cmp -- pr-body-expected pr-body-verified",
|
||||||
|
"pr-verified-response.json",
|
||||||
|
"verify_status",
|
||||||
):
|
):
|
||||||
self.assertIn(value, self.link)
|
self.assertIn(value, self.link)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"""Contract tests for the fork preview authorization workflow."""
|
"""Contract tests for the fork preview authorization workflow."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
@@ -9,6 +10,15 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
|
|||||||
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "preview-env.yml"
|
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "preview-env.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_job(workflow: str, name: str) -> str:
|
||||||
|
marker = f" {name}:\n"
|
||||||
|
_, separator, remainder = workflow.partition(marker)
|
||||||
|
if not separator:
|
||||||
|
raise AssertionError(f"workflow job is missing: {name}")
|
||||||
|
next_job = re.search(r"^ [A-Za-z0-9_-]+:\n", remainder, re.MULTILINE)
|
||||||
|
return remainder[: next_job.start()] if next_job else remainder
|
||||||
|
|
||||||
|
|
||||||
class PreviewEnvironmentWorkflowContractTest(unittest.TestCase):
|
class PreviewEnvironmentWorkflowContractTest(unittest.TestCase):
|
||||||
def test_safe_to_review_and_allowlists_authorize_fork_preview_deployment(self) -> None:
|
def test_safe_to_review_and_allowlists_authorize_fork_preview_deployment(self) -> None:
|
||||||
workflow = WORKFLOW.read_text(encoding="utf-8")
|
workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||||
@@ -30,7 +40,7 @@ class PreviewEnvironmentWorkflowContractTest(unittest.TestCase):
|
|||||||
"contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login)",
|
"contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login)",
|
||||||
deploy_job,
|
deploy_job,
|
||||||
)
|
)
|
||||||
self.assertEqual(workflow.count("persist-credentials: false"), 3)
|
self.assertEqual(workflow.count("persist-credentials: false"), 6)
|
||||||
|
|
||||||
def test_safe_to_review_approval_survives_follow_up_pushes(self) -> None:
|
def test_safe_to_review_approval_survives_follow_up_pushes(self) -> None:
|
||||||
workflow = WORKFLOW.read_text(encoding="utf-8")
|
workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||||
@@ -45,6 +55,32 @@ class PreviewEnvironmentWorkflowContractTest(unittest.TestCase):
|
|||||||
self.assertNotIn(" strip-safe-to-test:", workflow)
|
self.assertNotIn(" strip-safe-to-test:", workflow)
|
||||||
self.assertNotIn("github.rest.issues.removeLabel", workflow)
|
self.assertNotIn("github.rest.issues.removeLabel", workflow)
|
||||||
|
|
||||||
|
def test_description_mutation_isolated_from_preview_lifecycle_jobs(self) -> None:
|
||||||
|
workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
for name in ("deploy-same-repo", "deploy-fork", "cleanup-preview"):
|
||||||
|
job = workflow_job(workflow, name)
|
||||||
|
self.assertNotIn("concurrency:", job)
|
||||||
|
|
||||||
|
for name, command in (
|
||||||
|
("update-description-same-repo", "update-description"),
|
||||||
|
("update-description-fork", "update-description"),
|
||||||
|
("cleanup-preview-description", "remove-description"),
|
||||||
|
):
|
||||||
|
job = workflow_job(workflow, name)
|
||||||
|
self.assertIn("concurrency:", job)
|
||||||
|
self.assertIn(
|
||||||
|
"group: pr-description-${{ github.event.pull_request.number }}",
|
||||||
|
job,
|
||||||
|
)
|
||||||
|
self.assertIn("cancel-in-progress: false", job)
|
||||||
|
self.assertIn(f"go run ./cmd/preview {command}", job)
|
||||||
|
|
||||||
|
for name in ("deploy-same-repo", "deploy-fork"):
|
||||||
|
job = workflow_job(workflow, name)
|
||||||
|
self.assertIn("--skip-description", job)
|
||||||
|
self.assertIn("preview_url=", job)
|
||||||
|
self.assertIn("--skip-description", workflow_job(workflow, "cleanup-preview"))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
name: PR Walkthrough Reconcile
|
||||||
|
|
||||||
|
# A description edit must be able to repair a stale callout without starting
|
||||||
|
# the model-backed generation workflow or executing pull request code.
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [edited]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pr-walkthrough-reconcile-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pr-walkthrough-reconcile:
|
||||||
|
if: >
|
||||||
|
vars.PR_WALKTHROUGH_ENABLED == 'true' &&
|
||||||
|
github.event_name == 'pull_request_target' &&
|
||||||
|
github.event.action == 'edited' &&
|
||||||
|
github.event.pull_request.draft == false &&
|
||||||
|
(
|
||||||
|
github.event.pull_request.head.repo.full_name == github.repository ||
|
||||||
|
(
|
||||||
|
github.event.pull_request.head.repo.full_name != github.repository &&
|
||||||
|
(
|
||||||
|
contains(github.event.pull_request.labels.*.name, 'safe-to-review') ||
|
||||||
|
(vars.CLAUDE_REVIEW_ALLOWLIST != '' &&
|
||||||
|
contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
concurrency:
|
||||||
|
group: pr-description-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout trusted reconciliation helper
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.workflow_sha }}
|
||||||
|
fetch-depth: 1
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Repair stale walkthrough callout
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
TRUSTED_SHA: ${{ github.workflow_sha }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
WALKTHROUGH_BASE_URL: https://walkthrough.kandev.ai
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test "$(git rev-parse HEAD)" = "$TRUSTED_SHA"
|
||||||
|
test -f scripts/pr-walkthrough-pr-body
|
||||||
|
test "$PR_NUMBER" -gt 0
|
||||||
|
[[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||||
|
|
||||||
|
SHORT_HEAD_SHA="${HEAD_SHA:0:12}"
|
||||||
|
PUBLIC_URL="${WALKTHROUGH_BASE_URL%/}/pr/${PR_NUMBER}/${SHORT_HEAD_SHA}.html"
|
||||||
|
RESPONSE_META_PATH=pr-walkthrough-public.response
|
||||||
|
PUBLIC_HTML_PATH=pr-walkthrough-public.html
|
||||||
|
|
||||||
|
if ! curl --fail --silent --show-error --location --max-time 30 \
|
||||||
|
--retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 30 \
|
||||||
|
--output "$PUBLIC_HTML_PATH" \
|
||||||
|
--write-out '%{http_code}\t%{content_type}\t%{url_effective}' \
|
||||||
|
> "$RESPONSE_META_PATH" \
|
||||||
|
"$PUBLIC_URL"; then
|
||||||
|
echo "Canonical walkthrough is not publicly available; skipping repair." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
IFS=$'\t' read -r response_status response_content_type response_effective_url < "$RESPONSE_META_PATH"
|
||||||
|
if [ "$response_status" != "200" ] ||
|
||||||
|
[ "$response_effective_url" != "$PUBLIC_URL" ] ||
|
||||||
|
! printf '%s\n' "$response_content_type" | grep -Eiq '^text/html(;|[[:space:]]|$)' ||
|
||||||
|
! test -s "$PUBLIC_HTML_PATH"; then
|
||||||
|
echo "Canonical walkthrough did not return a non-empty HTML response; skipping repair." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
rm -f \
|
||||||
|
pr-response.json \
|
||||||
|
pr-latest-response.json \
|
||||||
|
pr-verified-response.json \
|
||||||
|
pr-update.json \
|
||||||
|
pr-verify-update.json \
|
||||||
|
pr-body-before \
|
||||||
|
pr-body-latest \
|
||||||
|
pr-body-expected \
|
||||||
|
pr-body-verified
|
||||||
|
|
||||||
|
if ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > pr-response.json ||
|
||||||
|
! python3 -c 'import json; json.load(open("pr-response.json", encoding="utf-8"))'; then
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
echo "Unable to fetch the pull request description." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
set +e
|
||||||
|
python3 scripts/pr-walkthrough-pr-body \
|
||||||
|
--github-response pr-response.json \
|
||||||
|
--output pr-update.json \
|
||||||
|
--url "$PUBLIC_URL" \
|
||||||
|
--pr-number "$PR_NUMBER" \
|
||||||
|
--head-sha "$HEAD_SHA" \
|
||||||
|
--require-existing
|
||||||
|
update_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$update_status" -eq 4 ]; then
|
||||||
|
echo "No existing walkthrough callout found; skipping repair." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$update_status" -eq 3 ]; then
|
||||||
|
echo "PR description already links the canonical walkthrough." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$update_status" -ne 0 ]; then
|
||||||
|
echo "Existing walkthrough markers are malformed; refusing to rewrite the PR description." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
jq -j '.body // ""' pr-update.json > pr-body-expected
|
||||||
|
|
||||||
|
jq -j '.body // ""' pr-response.json > pr-body-before
|
||||||
|
if ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > pr-latest-response.json ||
|
||||||
|
! python3 -c 'import json; json.load(open("pr-latest-response.json", encoding="utf-8"))'; then
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
echo "Unable to refresh the pull request description." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
current_head_sha="$(jq -r '.head.sha // ""' pr-latest-response.json)"
|
||||||
|
if [ "$current_head_sha" != "$HEAD_SHA" ]; then
|
||||||
|
echo "PR head advanced to ${current_head_sha}; skipping stale repair." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
jq -j '.body // ""' pr-latest-response.json > pr-body-latest
|
||||||
|
if ! cmp -- pr-body-before pr-body-latest; then
|
||||||
|
echo "Pull request description changed before repair PATCH; retrying." >&2
|
||||||
|
sleep 2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! gh api --method PATCH \
|
||||||
|
"repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||||
|
--input pr-update.json \
|
||||||
|
--silent; then
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
echo "Unable to repair the pull request description." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > pr-verified-response.json ||
|
||||||
|
! python3 -c 'import json; json.load(open("pr-verified-response.json", encoding="utf-8"))'; then
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
echo "Unable to verify the repaired pull request description." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
verified_head_sha="$(jq -r '.head.sha // ""' pr-verified-response.json)"
|
||||||
|
if [ "$verified_head_sha" != "$HEAD_SHA" ]; then
|
||||||
|
echo "PR head advanced to ${verified_head_sha}; skipping stale repair." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
jq -j '.body // ""' pr-verified-response.json > pr-body-verified
|
||||||
|
if ! cmp -- pr-body-expected pr-body-verified; then
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
echo "Pull request description changed before repair readback verification." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Pull request description changed after repair PATCH; retrying." >&2
|
||||||
|
sleep 2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
set +e
|
||||||
|
python3 scripts/pr-walkthrough-pr-body \
|
||||||
|
--github-response pr-verified-response.json \
|
||||||
|
--output pr-verify-update.json \
|
||||||
|
--url "$PUBLIC_URL" \
|
||||||
|
--pr-number "$PR_NUMBER" \
|
||||||
|
--head-sha "$HEAD_SHA" \
|
||||||
|
--require-existing
|
||||||
|
verify_status=$?
|
||||||
|
set -e
|
||||||
|
if [ "$verify_status" -eq 3 ]; then
|
||||||
|
echo "Repaired the stale walkthrough link in the PR description." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
echo "Pull request description readback did not contain the canonical walkthrough." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Pull request description readback changed; retrying repair." >&2
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Pull request description repair did not converge." >&2
|
||||||
|
exit 1
|
||||||
@@ -367,6 +367,9 @@ jobs:
|
|||||||
needs: pr-walkthrough-publish
|
needs: pr-walkthrough-publish
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
concurrency:
|
||||||
|
group: pr-description-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: false
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -392,47 +395,113 @@ jobs:
|
|||||||
test -f scripts/pr-walkthrough-pr-body
|
test -f scripts/pr-walkthrough-pr-body
|
||||||
test -n "$PUBLIC_URL"
|
test -n "$PUBLIC_URL"
|
||||||
|
|
||||||
pr_response_ok=false
|
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
if gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > pr-response.json &&
|
rm -f \
|
||||||
python3 -c 'import json; json.load(open("pr-response.json", encoding="utf-8"))'; then
|
pr-response.json \
|
||||||
pr_response_ok=true
|
pr-latest-response.json \
|
||||||
break
|
pr-verified-response.json \
|
||||||
fi
|
pr-update.json \
|
||||||
if [ "$attempt" -lt 3 ]; then
|
pr-verify-update.json \
|
||||||
|
pr-body-before \
|
||||||
|
pr-body-latest \
|
||||||
|
pr-body-expected \
|
||||||
|
pr-body-verified
|
||||||
|
|
||||||
|
if ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > pr-response.json ||
|
||||||
|
! python3 -c 'import json; json.load(open("pr-response.json", encoding="utf-8"))'; then
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
echo "Unable to fetch the pull request description." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
|
continue
|
||||||
fi
|
fi
|
||||||
done
|
|
||||||
test "$pr_response_ok" = true
|
|
||||||
|
|
||||||
set +e
|
set +e
|
||||||
python3 scripts/pr-walkthrough-pr-body \
|
python3 scripts/pr-walkthrough-pr-body \
|
||||||
--github-response pr-response.json \
|
--github-response pr-response.json \
|
||||||
--output pr-update.json \
|
--output pr-update.json \
|
||||||
--url "$PUBLIC_URL" \
|
--url "$PUBLIC_URL" \
|
||||||
--pr-number "$PR_NUMBER" \
|
--pr-number "$PR_NUMBER" \
|
||||||
--head-sha "$HEAD_SHA"
|
--head-sha "$HEAD_SHA"
|
||||||
update_status=$?
|
update_status=$?
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
if [ "$update_status" -eq 3 ]; then
|
if [ "$update_status" -eq 3 ]; then
|
||||||
echo "PR description already links the published walkthrough." >> "$GITHUB_STEP_SUMMARY"
|
echo "PR description already links the published walkthrough." >> "$GITHUB_STEP_SUMMARY"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
test "$update_status" -eq 0
|
test "$update_status" -eq 0
|
||||||
|
jq -j '.body // ""' pr-update.json > pr-body-expected
|
||||||
|
|
||||||
patch_ok=false
|
jq -j '.body // ""' pr-response.json > pr-body-before
|
||||||
for attempt in 1 2 3; do
|
if ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > pr-latest-response.json ||
|
||||||
if gh api --method PATCH \
|
! python3 -c 'import json; json.load(open("pr-latest-response.json", encoding="utf-8"))'; then
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
echo "Unable to refresh the pull request description." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
jq -j '.body // ""' pr-latest-response.json > pr-body-latest
|
||||||
|
if ! cmp -- pr-body-before pr-body-latest; then
|
||||||
|
echo "Pull request description changed before PATCH; retrying." >&2
|
||||||
|
sleep 2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! gh api --method PATCH \
|
||||||
"repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
"repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||||
--input pr-update.json \
|
--input pr-update.json \
|
||||||
--silent; then
|
--silent; then
|
||||||
patch_ok=true
|
if [ "$attempt" -eq 3 ]; then
|
||||||
break
|
echo "Unable to update the pull request description." >&2
|
||||||
fi
|
exit 1
|
||||||
if [ "$attempt" -lt 3 ]; then
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > pr-verified-response.json ||
|
||||||
|
! python3 -c 'import json; json.load(open("pr-verified-response.json", encoding="utf-8"))'; then
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
echo "Unable to verify the pull request description." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
jq -j '.body // ""' pr-verified-response.json > pr-body-verified
|
||||||
|
if ! cmp -- pr-body-expected pr-body-verified; then
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
echo "Pull request description changed before readback verification." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Pull request description changed after PATCH; retrying." >&2
|
||||||
|
sleep 2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
set +e
|
||||||
|
python3 scripts/pr-walkthrough-pr-body \
|
||||||
|
--github-response pr-verified-response.json \
|
||||||
|
--output pr-verify-update.json \
|
||||||
|
--url "$PUBLIC_URL" \
|
||||||
|
--pr-number "$PR_NUMBER" \
|
||||||
|
--head-sha "$HEAD_SHA"
|
||||||
|
verify_status=$?
|
||||||
|
set -e
|
||||||
|
if [ "$verify_status" -eq 3 ]; then
|
||||||
|
echo "Added the walkthrough link to the top of the PR description." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
echo "Pull request description readback did not contain the published walkthrough." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Pull request description readback changed; retrying." >&2
|
||||||
|
sleep 2
|
||||||
done
|
done
|
||||||
test "$patch_ok" = true
|
echo "Pull request description update did not converge." >&2
|
||||||
echo "Added the walkthrough link to the top of the PR description." >> "$GITHUB_STEP_SUMMARY"
|
exit 1
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ jobs:
|
|||||||
github.event_name == 'pull_request' &&
|
github.event_name == 'pull_request' &&
|
||||||
github.event.pull_request.head.repo.full_name == github.repository
|
github.event.pull_request.head.repo.full_name == github.repository
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
preview_url: ${{ steps.deploy.outputs.preview_url }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
@@ -56,14 +57,20 @@ jobs:
|
|||||||
run: pnpm -C apps install --frozen-lockfile
|
run: pnpm -C apps install --frozen-lockfile
|
||||||
|
|
||||||
- name: Deploy preview environment
|
- name: Deploy preview environment
|
||||||
|
id: deploy
|
||||||
working-directory: apps/backend
|
working-directory: apps/backend
|
||||||
run: go run ./cmd/preview deploy --pr "$PR" --sha "$SHA" --repo "$REPO" --skip-web-install
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
deploy_output="$(go run ./cmd/preview deploy --pr "$PR" --sha "$SHA" --repo "$REPO" --skip-web-install --skip-description)"
|
||||||
|
printf '%s\n' "$deploy_output"
|
||||||
|
preview_url="$(printf '%s\n' "$deploy_output" | sed -n 's/^preview URL: //p' | tail -n 1)"
|
||||||
|
test -n "$preview_url"
|
||||||
|
printf 'preview_url=%s\n' "$preview_url" >> "$GITHUB_OUTPUT"
|
||||||
env:
|
env:
|
||||||
PR: ${{ github.event.pull_request.number }}
|
PR: ${{ github.event.pull_request.number }}
|
||||||
SHA: ${{ github.event.pull_request.head.sha }}
|
SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }}
|
SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }}
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Deploy / update preview (fork PRs, gated by allowlist)
|
# Deploy / update preview (fork PRs, gated by allowlist)
|
||||||
@@ -90,18 +97,19 @@ jobs:
|
|||||||
(vars.PREVIEW_ENV_ALLOWLIST != '' && contains(fromJSON(vars.PREVIEW_ENV_ALLOWLIST), github.event.pull_request.user.login)) ||
|
(vars.PREVIEW_ENV_ALLOWLIST != '' && contains(fromJSON(vars.PREVIEW_ENV_ALLOWLIST), github.event.pull_request.user.login)) ||
|
||||||
(vars.CLAUDE_REVIEW_ALLOWLIST != '' && contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login)))
|
(vars.CLAUDE_REVIEW_ALLOWLIST != '' && contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login)))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
preview_url: ${{ steps.deploy.outputs.preview_url }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
with:
|
with:
|
||||||
# pull_request_target checks out base by default; explicitly use PR head.
|
# pull_request_target checks out base by default; explicitly use PR head.
|
||||||
# Safe: the preview CLI only reads files to build artifacts and posts comments.
|
# Safe: the preview CLI only reads files to build artifacts.
|
||||||
# NOTE: The allowlist gate (safe-to-review, PREVIEW_ENV_ALLOWLIST,
|
# NOTE: The allowlist gate (safe-to-review, PREVIEW_ENV_ALLOWLIST,
|
||||||
# CLAUDE_REVIEW_ALLOWLIST)
|
# CLAUDE_REVIEW_ALLOWLIST)
|
||||||
# authorizes running the fork's cmd/preview code with SPRITES_API_TOKEN and GH_TOKEN.
|
# authorizes running the fork's cmd/preview code with SPRITES_API_TOKEN.
|
||||||
# The safe-to-review label remains an explicit maintainer approval for
|
# The safe-to-review label remains an explicit maintainer approval for
|
||||||
# follow-up pushes until the maintainer removes it.
|
# follow-up pushes until the maintainer removes it.
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
@@ -125,14 +133,106 @@ jobs:
|
|||||||
run: pnpm -C apps install --frozen-lockfile
|
run: pnpm -C apps install --frozen-lockfile
|
||||||
|
|
||||||
- name: Deploy preview environment
|
- name: Deploy preview environment
|
||||||
|
id: deploy
|
||||||
working-directory: apps/backend
|
working-directory: apps/backend
|
||||||
run: go run ./cmd/preview deploy --pr "$PR" --sha "$SHA" --repo "$REPO" --skip-web-install
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
deploy_output="$(go run ./cmd/preview deploy --pr "$PR" --sha "$SHA" --repo "$REPO" --skip-web-install --skip-description)"
|
||||||
|
printf '%s\n' "$deploy_output"
|
||||||
|
preview_url="$(printf '%s\n' "$deploy_output" | sed -n 's/^preview URL: //p' | tail -n 1)"
|
||||||
|
test -n "$preview_url"
|
||||||
|
printf 'preview_url=%s\n' "$preview_url" >> "$GITHUB_OUTPUT"
|
||||||
env:
|
env:
|
||||||
PR: ${{ github.event.pull_request.number }}
|
PR: ${{ github.event.pull_request.number }}
|
||||||
SHA: ${{ github.event.pull_request.head.sha }}
|
SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }}
|
SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Update the PR description after deployment.
|
||||||
|
# This short trusted job owns the shared description-write lock. Keeping it
|
||||||
|
# separate prevents dependency installation and sprite health checks from
|
||||||
|
# starving walkthrough or reconciliation writers.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
update-description-same-repo:
|
||||||
|
if: needs.deploy-same-repo.result == 'success'
|
||||||
|
needs: deploy-same-repo
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: pr-description-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.workflow_sha }}
|
||||||
|
fetch-depth: 1
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||||
|
with:
|
||||||
|
go-version-file: apps/backend/go.mod
|
||||||
|
cache-dependency-path: apps/backend/go.sum
|
||||||
|
|
||||||
|
- name: Update preview description
|
||||||
|
working-directory: apps/backend
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PREVIEW_URL"
|
||||||
|
go run ./cmd/preview update-description \
|
||||||
|
--pr "$PR" \
|
||||||
|
--sha "$SHA" \
|
||||||
|
--repo "$REPO" \
|
||||||
|
--url "$PREVIEW_URL"
|
||||||
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
PR: ${{ github.event.pull_request.number }}
|
||||||
|
SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PREVIEW_URL: ${{ needs.deploy-same-repo.outputs.preview_url }}
|
||||||
|
|
||||||
|
update-description-fork:
|
||||||
|
if: needs.deploy-fork.result == 'success'
|
||||||
|
needs: deploy-fork
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: pr-description-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.workflow_sha }}
|
||||||
|
fetch-depth: 1
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||||
|
with:
|
||||||
|
go-version-file: apps/backend/go.mod
|
||||||
|
cache-dependency-path: apps/backend/go.sum
|
||||||
|
|
||||||
|
- name: Update preview description
|
||||||
|
working-directory: apps/backend
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PREVIEW_URL"
|
||||||
|
go run ./cmd/preview update-description \
|
||||||
|
--pr "$PR" \
|
||||||
|
--sha "$SHA" \
|
||||||
|
--repo "$REPO" \
|
||||||
|
--url "$PREVIEW_URL"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
PR: ${{ github.event.pull_request.number }}
|
||||||
|
SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PREVIEW_URL: ${{ needs.deploy-fork.outputs.preview_url }}
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Cleanup (all closed PRs — no allowlist check)
|
# Cleanup (all closed PRs — no allowlist check)
|
||||||
@@ -149,7 +249,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
@@ -163,9 +262,43 @@ jobs:
|
|||||||
|
|
||||||
- name: Destroy preview environment
|
- name: Destroy preview environment
|
||||||
working-directory: apps/backend
|
working-directory: apps/backend
|
||||||
run: go run ./cmd/preview cleanup --pr "$PR" --repo "$REPO"
|
run: go run ./cmd/preview cleanup --pr "$PR" --repo "$REPO" --skip-description
|
||||||
env:
|
env:
|
||||||
PR: ${{ github.event.pull_request.number }}
|
PR: ${{ github.event.pull_request.number }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }}
|
SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }}
|
||||||
|
|
||||||
|
cleanup-preview-description:
|
||||||
|
if: >
|
||||||
|
always() &&
|
||||||
|
github.event.action == 'closed' &&
|
||||||
|
github.event_name == 'pull_request_target' &&
|
||||||
|
needs.cleanup-preview.result != 'cancelled'
|
||||||
|
needs: cleanup-preview
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: pr-description-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.workflow_sha }}
|
||||||
|
fetch-depth: 1
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||||
|
with:
|
||||||
|
go-version-file: apps/backend/go.mod
|
||||||
|
cache-dependency-path: apps/backend/go.sum
|
||||||
|
|
||||||
|
- name: Remove preview description
|
||||||
|
working-directory: apps/backend
|
||||||
|
run: go run ./cmd/preview remove-description --pr "$PR" --repo "$REPO"
|
||||||
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
PR: ${{ github.event.pull_request.number }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ func runCleanup(ctx context.Context, args []string) int {
|
|||||||
fs := flag.NewFlagSet("cleanup", flag.ContinueOnError)
|
fs := flag.NewFlagSet("cleanup", flag.ContinueOnError)
|
||||||
pr := fs.Int("pr", 0, "PR number (required)")
|
pr := fs.Int("pr", 0, "PR number (required)")
|
||||||
repo := fs.String("repo", envOr("GITHUB_REPOSITORY", ""), "owner/repo")
|
repo := fs.String("repo", envOr("GITHUB_REPOSITORY", ""), "owner/repo")
|
||||||
|
skipDescription := fs.Bool("skip-description", false, "skip removing the PR description section")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "preview cleanup: %v\n", err)
|
fmt.Fprintf(os.Stderr, "preview cleanup: %v\n", err)
|
||||||
@@ -31,7 +32,7 @@ func runCleanup(ctx context.Context, args []string) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
ghToken := os.Getenv("GH_TOKEN")
|
ghToken := os.Getenv("GH_TOKEN")
|
||||||
if ghToken == "" {
|
if ghToken == "" && !*skipDescription {
|
||||||
fmt.Fprintln(os.Stderr, "preview cleanup: GH_TOKEN is required")
|
fmt.Fprintln(os.Stderr, "preview cleanup: GH_TOKEN is required")
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -47,6 +48,12 @@ func runCleanup(ctx context.Context, args []string) int {
|
|||||||
fmt.Fprintf(os.Stderr, "preview cleanup: destroy sprite: %v\n", destroyErr)
|
fmt.Fprintf(os.Stderr, "preview cleanup: destroy sprite: %v\n", destroyErr)
|
||||||
// Continue to update the PR description even if destroy failed.
|
// Continue to update the PR description even if destroy failed.
|
||||||
}
|
}
|
||||||
|
if *skipDescription {
|
||||||
|
if destroyErr != nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
if err := removeDescriptionSection(ctx, ghToken, *repo, *pr); err != nil {
|
if err := removeDescriptionSection(ctx, ghToken, *repo, *pr); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "preview cleanup: remove PR description section: %v\n", err)
|
fmt.Fprintf(os.Stderr, "preview cleanup: remove PR description section: %v\n", err)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func runDeploy(ctx context.Context, args []string) int {
|
|||||||
repo := fs.String("repo", envOr("GITHUB_REPOSITORY", ""), "owner/repo")
|
repo := fs.String("repo", envOr("GITHUB_REPOSITORY", ""), "owner/repo")
|
||||||
port := fs.Int("port", ports.Backend, "kandev backend port exposed by the sprite")
|
port := fs.Int("port", ports.Backend, "kandev backend port exposed by the sprite")
|
||||||
skipWebInstall := fs.Bool("skip-web-install", false, "skip pnpm install (CI already ran it)")
|
skipWebInstall := fs.Bool("skip-web-install", false, "skip pnpm install (CI already ran it)")
|
||||||
|
skipDescription := fs.Bool("skip-description", false, "skip the PR description update")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "preview deploy: %v\n", err)
|
fmt.Fprintf(os.Stderr, "preview deploy: %v\n", err)
|
||||||
@@ -38,7 +39,7 @@ func runDeploy(ctx context.Context, args []string) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
ghToken := os.Getenv("GH_TOKEN")
|
ghToken := os.Getenv("GH_TOKEN")
|
||||||
if ghToken == "" {
|
if ghToken == "" && !*skipDescription {
|
||||||
fmt.Fprintln(os.Stderr, "preview deploy: GH_TOKEN is required")
|
fmt.Fprintln(os.Stderr, "preview deploy: GH_TOKEN is required")
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,9 @@ func runDeploy(ctx context.Context, args []string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("preview URL: %s\n", previewURL)
|
fmt.Printf("preview URL: %s\n", previewURL)
|
||||||
|
if *skipDescription {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
section := buildDeploySection(previewURL, *sha)
|
section := buildDeploySection(previewURL, *sha)
|
||||||
if err := upsertDescriptionSection(ctx, ghToken, *repo, *pr, section); err != nil {
|
if err := upsertDescriptionSection(ctx, ghToken, *repo, *pr, section); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runUpdateDescription(ctx context.Context, args []string) int {
|
||||||
|
fs := flag.NewFlagSet("update-description", flag.ContinueOnError)
|
||||||
|
pr := fs.Int("pr", 0, "PR number (required)")
|
||||||
|
sha := fs.String("sha", "", "commit SHA to display in the comment (required)")
|
||||||
|
repo := fs.String("repo", envOr("GITHUB_REPOSITORY", ""), "owner/repo")
|
||||||
|
previewURL := fs.String("url", "", "public preview URL (required)")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "preview update-description: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *pr == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "preview update-description: --pr is required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *sha == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "preview update-description: --sha is required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *repo == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "preview update-description: --repo or GITHUB_REPOSITORY is required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *previewURL == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "preview update-description: --url is required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
ghToken := os.Getenv("GH_TOKEN")
|
||||||
|
if ghToken == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "preview update-description: GH_TOKEN is required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
section := buildDeploySection(*previewURL, *sha)
|
||||||
|
if err := upsertDescriptionSection(ctx, ghToken, *repo, *pr, section); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "preview update-description: update PR description: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRemoveDescription(ctx context.Context, args []string) int {
|
||||||
|
fs := flag.NewFlagSet("remove-description", flag.ContinueOnError)
|
||||||
|
pr := fs.Int("pr", 0, "PR number (required)")
|
||||||
|
repo := fs.String("repo", envOr("GITHUB_REPOSITORY", ""), "owner/repo")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "preview remove-description: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *pr == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "preview remove-description: --pr is required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *repo == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "preview remove-description: --repo or GITHUB_REPOSITORY is required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
ghToken := os.Getenv("GH_TOKEN")
|
||||||
|
if ghToken == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "preview remove-description: GH_TOKEN is required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := removeDescriptionSection(ctx, ghToken, *repo, *pr); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "preview remove-description: remove PR description section: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -11,44 +11,114 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
sectionStart = "<!-- kandev-preview-start -->"
|
sectionStart = "<!-- kandev-preview-start -->"
|
||||||
sectionEnd = "<!-- kandev-preview-end -->"
|
sectionEnd = "<!-- kandev-preview-end -->"
|
||||||
githubAPIBase = "https://api.github.com"
|
githubAPIBase = "https://api.github.com"
|
||||||
|
maxDescriptionWriteAttempts = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
// upsertDescriptionSection appends (or updates) the kandev preview section at
|
// upsertDescriptionSection appends (or updates) the kandev preview section at
|
||||||
// the end of the PR description. Other bots may also append sections; we only
|
// the end of the PR description. Other bots may also append sections; we only
|
||||||
// touch the block delimited by our own markers.
|
// touch the block delimited by our own markers.
|
||||||
func upsertDescriptionSection(ctx context.Context, token, repo string, pr int, section string) error {
|
func upsertDescriptionSection(ctx context.Context, token, repo string, pr int, section string) error {
|
||||||
body, err := getPRBody(ctx, token, repo, pr)
|
marked := markedSection(section)
|
||||||
if err != nil {
|
return mutatePRBody(
|
||||||
return fmt.Errorf("get PR body: %w", err)
|
ctx,
|
||||||
}
|
token,
|
||||||
|
repo,
|
||||||
newBody := replaceOrAppendSection(body, section)
|
pr,
|
||||||
return updatePRBody(ctx, token, repo, pr, newBody)
|
func(body string) string { return replaceOrAppendSection(body, section) },
|
||||||
|
func(body string) bool { return strings.Contains(body, marked) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeDescriptionSection removes the kandev preview section from the PR
|
// removeDescriptionSection removes the kandev preview section from the PR
|
||||||
// description on cleanup. If no section is present this is a no-op.
|
// description on cleanup. If no section is present this is a no-op.
|
||||||
func removeDescriptionSection(ctx context.Context, token, repo string, pr int) error {
|
func removeDescriptionSection(ctx context.Context, token, repo string, pr int) error {
|
||||||
body, err := getPRBody(ctx, token, repo, pr)
|
return mutatePRBody(
|
||||||
if err != nil {
|
ctx,
|
||||||
return fmt.Errorf("get PR body: %w", err)
|
token,
|
||||||
|
repo,
|
||||||
|
pr,
|
||||||
|
func(body string) string {
|
||||||
|
if !strings.Contains(body, sectionStart) {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return stripSection(body)
|
||||||
|
},
|
||||||
|
func(body string) bool {
|
||||||
|
if !strings.Contains(body, sectionStart) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !strings.Contains(body, sectionEnd)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutatePRBody(
|
||||||
|
ctx context.Context,
|
||||||
|
token, repo string,
|
||||||
|
pr int,
|
||||||
|
merge func(string) string,
|
||||||
|
verify func(string) bool,
|
||||||
|
) error {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 1; attempt <= maxDescriptionWriteAttempts; attempt++ {
|
||||||
|
body, err := getPRBody(ctx, token, repo, pr)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("get PR body: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := merge(body)
|
||||||
|
if candidate == body {
|
||||||
|
if verify(body) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastErr = fmt.Errorf("merged PR body failed verification without a write")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
latest, err := getPRBody(ctx, token, repo, pr)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("refresh PR body: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if latest != body {
|
||||||
|
lastErr = fmt.Errorf("PR body changed before update")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := updatePRBody(ctx, token, repo, pr, candidate); err != nil {
|
||||||
|
lastErr = fmt.Errorf("update PR body: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := getPRBody(ctx, token, repo, pr)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("verify PR body: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if updated == candidate && verify(updated) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if updated != candidate {
|
||||||
|
lastErr = fmt.Errorf("updated PR body differs from the merged body")
|
||||||
|
} else {
|
||||||
|
lastErr = fmt.Errorf("updated PR body failed verification")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(body, sectionStart) {
|
if lastErr == nil {
|
||||||
return nil // nothing to remove
|
lastErr = fmt.Errorf("no body update attempt completed")
|
||||||
}
|
}
|
||||||
|
return fmt.Errorf("PR body update did not converge after %d attempts: %w", maxDescriptionWriteAttempts, lastErr)
|
||||||
newBody := stripSection(body)
|
|
||||||
return updatePRBody(ctx, token, repo, pr, newBody)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// replaceOrAppendSection replaces an existing kandev section in body with
|
// replaceOrAppendSection replaces an existing kandev section in body with
|
||||||
// section, or appends it if none exists. Preserves any trailing newline.
|
// section, or appends it if none exists. Preserves any trailing newline.
|
||||||
func replaceOrAppendSection(body, section string) string {
|
func replaceOrAppendSection(body, section string) string {
|
||||||
marked := sectionStart + "\n" + section + "\n" + sectionEnd
|
marked := markedSection(section)
|
||||||
if strings.Contains(body, sectionStart) {
|
if strings.Contains(body, sectionStart) {
|
||||||
return replaceSection(body, marked)
|
return replaceSection(body, marked)
|
||||||
}
|
}
|
||||||
@@ -59,6 +129,10 @@ func replaceOrAppendSection(body, section string) string {
|
|||||||
return body + sep + marked
|
return body + sep + marked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markedSection(section string) string {
|
||||||
|
return sectionStart + "\n" + section + "\n" + sectionEnd
|
||||||
|
}
|
||||||
|
|
||||||
// replaceSection replaces everything between our markers (inclusive) with marked.
|
// replaceSection replaces everything between our markers (inclusive) with marked.
|
||||||
// If sectionEnd is missing (orphaned start marker), the orphan is stripped
|
// If sectionEnd is missing (orphaned start marker), the orphan is stripped
|
||||||
// and marked is appended to avoid duplicating sectionStart.
|
// and marked is appended to avoid duplicating sectionStart.
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func githubJSONResponse(req *http.Request, status int, payload any) *http.Response {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(string(body))),
|
||||||
|
Request: req,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertDescriptionSectionPreservesBodyChangedBeforePatch(t *testing.T) {
|
||||||
|
// @covers AC-UI-PR-WALKTHROUGH-001.10
|
||||||
|
originalBody := "Contributor content"
|
||||||
|
newerBody := originalBody + "\n\nPreview bot content"
|
||||||
|
currentBody := originalBody
|
||||||
|
getCount := 0
|
||||||
|
var patchedBody string
|
||||||
|
|
||||||
|
previousTransport := http.DefaultTransport
|
||||||
|
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
switch req.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
getCount++
|
||||||
|
if getCount == 2 {
|
||||||
|
currentBody = newerBody
|
||||||
|
}
|
||||||
|
return githubJSONResponse(req, http.StatusOK, map[string]string{"body": currentBody}), nil
|
||||||
|
case http.MethodPatch:
|
||||||
|
var payload struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
patchedBody = payload.Body
|
||||||
|
currentBody = patchedBody
|
||||||
|
return githubJSONResponse(req, http.StatusOK, map[string]string{"body": currentBody}), nil
|
||||||
|
default:
|
||||||
|
return githubJSONResponse(req, http.StatusNotFound, map[string]string{}), nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||||
|
|
||||||
|
err := upsertDescriptionSection(
|
||||||
|
context.Background(),
|
||||||
|
"token",
|
||||||
|
"owner/repo",
|
||||||
|
1,
|
||||||
|
"### Preview\nURL: https://example.com",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upsertDescriptionSection() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(patchedBody, "Preview bot content") {
|
||||||
|
t.Fatalf("patched body lost newer content: %q", patchedBody)
|
||||||
|
}
|
||||||
|
if !strings.Contains(patchedBody, sectionStart) {
|
||||||
|
t.Fatalf("patched body lost preview section: %q", patchedBody)
|
||||||
|
}
|
||||||
|
if getCount < 4 {
|
||||||
|
t.Fatalf("expected fresh snapshot and readback, got %d GET requests", getCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertDescriptionSectionRetriesAfterReadbackLosesUpdate(t *testing.T) {
|
||||||
|
// @covers AC-UI-PR-WALKTHROUGH-001.10
|
||||||
|
currentBody := "Contributor content"
|
||||||
|
patchCount := 0
|
||||||
|
getCount := 0
|
||||||
|
|
||||||
|
previousTransport := http.DefaultTransport
|
||||||
|
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
switch req.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
getCount++
|
||||||
|
return githubJSONResponse(req, http.StatusOK, map[string]string{"body": currentBody}), nil
|
||||||
|
case http.MethodPatch:
|
||||||
|
patchCount++
|
||||||
|
var payload struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if patchCount == 1 {
|
||||||
|
currentBody = "External writer won"
|
||||||
|
} else {
|
||||||
|
currentBody = payload.Body
|
||||||
|
}
|
||||||
|
return githubJSONResponse(req, http.StatusOK, map[string]string{"body": currentBody}), nil
|
||||||
|
default:
|
||||||
|
return githubJSONResponse(req, http.StatusNotFound, map[string]string{}), nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||||
|
|
||||||
|
err := upsertDescriptionSection(
|
||||||
|
context.Background(),
|
||||||
|
"token",
|
||||||
|
"owner/repo",
|
||||||
|
1,
|
||||||
|
"### Preview\nURL: https://example.com",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upsertDescriptionSection() error = %v", err)
|
||||||
|
}
|
||||||
|
if patchCount != 2 {
|
||||||
|
t.Fatalf("expected a retry after readback loss, got %d PATCH requests", patchCount)
|
||||||
|
}
|
||||||
|
if !strings.Contains(currentBody, sectionStart) {
|
||||||
|
t.Fatalf("final body lost preview section: %q", currentBody)
|
||||||
|
}
|
||||||
|
if !strings.Contains(currentBody, "External writer won") {
|
||||||
|
t.Fatalf("final body lost external content: %q", currentBody)
|
||||||
|
}
|
||||||
|
if getCount < 6 {
|
||||||
|
t.Fatalf("expected readback and retry reads, got %d GET requests", getCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertDescriptionSectionFailsAfterRepeatedBodyChanges(t *testing.T) {
|
||||||
|
// @covers AC-UI-PR-WALKTHROUGH-001.10
|
||||||
|
getCount := 0
|
||||||
|
patchCount := 0
|
||||||
|
|
||||||
|
previousTransport := http.DefaultTransport
|
||||||
|
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
switch req.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
getCount++
|
||||||
|
return githubJSONResponse(req, http.StatusOK, map[string]string{
|
||||||
|
"body": "Body version " + string(rune('0'+getCount)),
|
||||||
|
}), nil
|
||||||
|
case http.MethodPatch:
|
||||||
|
patchCount++
|
||||||
|
return githubJSONResponse(req, http.StatusOK, map[string]string{"body": "ignored"}), nil
|
||||||
|
default:
|
||||||
|
return githubJSONResponse(req, http.StatusNotFound, map[string]string{}), nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||||
|
|
||||||
|
err := upsertDescriptionSection(
|
||||||
|
context.Background(),
|
||||||
|
"token",
|
||||||
|
"owner/repo",
|
||||||
|
1,
|
||||||
|
"### Preview\nURL: https://example.com",
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("upsertDescriptionSection() error = nil, want bounded convergence failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "did not converge") {
|
||||||
|
t.Fatalf("error = %v, want convergence failure", err)
|
||||||
|
}
|
||||||
|
if patchCount != 0 {
|
||||||
|
t.Fatalf("expected no stale PATCH requests, got %d", patchCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveDescriptionSectionPreservesOtherOwnedContent(t *testing.T) {
|
||||||
|
// @covers AC-UI-PR-WALKTHROUGH-001.10
|
||||||
|
walkthrough := "<!-- kandev-pr-walkthrough-start -->\nwalkthrough\n<!-- kandev-pr-walkthrough-end -->"
|
||||||
|
currentBody := walkthrough + "\n\n" + sectionStart + "\npreview\n" + sectionEnd
|
||||||
|
patchCount := 0
|
||||||
|
|
||||||
|
previousTransport := http.DefaultTransport
|
||||||
|
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
switch req.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
return githubJSONResponse(req, http.StatusOK, map[string]string{"body": currentBody}), nil
|
||||||
|
case http.MethodPatch:
|
||||||
|
patchCount++
|
||||||
|
var payload struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
currentBody = payload.Body
|
||||||
|
return githubJSONResponse(req, http.StatusOK, map[string]string{"body": currentBody}), nil
|
||||||
|
default:
|
||||||
|
return githubJSONResponse(req, http.StatusNotFound, map[string]string{}), nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||||
|
|
||||||
|
if err := removeDescriptionSection(context.Background(), "token", "owner/repo", 1); err != nil {
|
||||||
|
t.Fatalf("removeDescriptionSection() error = %v", err)
|
||||||
|
}
|
||||||
|
if patchCount != 1 {
|
||||||
|
t.Fatalf("expected one PATCH request, got %d", patchCount)
|
||||||
|
}
|
||||||
|
if !strings.Contains(currentBody, walkthrough) {
|
||||||
|
t.Fatalf("removeDescriptionSection() lost walkthrough content: %q", currentBody)
|
||||||
|
}
|
||||||
|
if strings.Contains(currentBody, sectionStart) || strings.Contains(currentBody, sectionEnd) {
|
||||||
|
t.Fatalf("removeDescriptionSection() kept preview markers: %q", currentBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveDescriptionSectionWithoutMarkerDoesNotPatch(t *testing.T) {
|
||||||
|
// @covers AC-UI-PR-WALKTHROUGH-001.10
|
||||||
|
patchCount := 0
|
||||||
|
previousTransport := http.DefaultTransport
|
||||||
|
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.Method == http.MethodPatch {
|
||||||
|
patchCount++
|
||||||
|
}
|
||||||
|
return githubJSONResponse(req, http.StatusOK, map[string]string{"body": "Contributor content"}), nil
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||||
|
|
||||||
|
if err := removeDescriptionSection(context.Background(), "token", "owner/repo", 1); err != nil {
|
||||||
|
t.Fatalf("removeDescriptionSection() error = %v", err)
|
||||||
|
}
|
||||||
|
if patchCount != 0 {
|
||||||
|
t.Fatalf("expected no PATCH request without a marker, got %d", patchCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveDescriptionSectionWithOrphanEndMarkerDoesNotPatch(t *testing.T) {
|
||||||
|
currentBody := "Contributor content\n\n" + sectionEnd
|
||||||
|
patchCount := 0
|
||||||
|
previousTransport := http.DefaultTransport
|
||||||
|
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
switch req.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
return githubJSONResponse(req, http.StatusOK, map[string]string{"body": currentBody}), nil
|
||||||
|
case http.MethodPatch:
|
||||||
|
patchCount++
|
||||||
|
}
|
||||||
|
return githubJSONResponse(req, http.StatusOK, map[string]string{"body": currentBody}), nil
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||||
|
|
||||||
|
if err := removeDescriptionSection(context.Background(), "token", "owner/repo", 1); err != nil {
|
||||||
|
t.Fatalf("removeDescriptionSection() error = %v", err)
|
||||||
|
}
|
||||||
|
if patchCount != 0 {
|
||||||
|
t.Fatalf("expected no PATCH request for an orphan end marker, got %d", patchCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
//
|
//
|
||||||
// preview deploy --pr N --sha S [--repo owner/repo] [--port 38429]
|
// preview deploy --pr N --sha S [--repo owner/repo] [--port 38429] [--skip-description]
|
||||||
// preview cleanup --pr N [--repo owner/repo]
|
// preview update-description --pr N --sha S --url URL [--repo owner/repo]
|
||||||
|
// preview cleanup --pr N [--repo owner/repo] [--skip-description]
|
||||||
|
// preview remove-description --pr N [--repo owner/repo]
|
||||||
//
|
//
|
||||||
// Required environment variables:
|
// Required environment variables:
|
||||||
//
|
//
|
||||||
@@ -34,8 +36,12 @@ func run() int {
|
|||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "deploy":
|
case "deploy":
|
||||||
return runDeploy(ctx, os.Args[2:])
|
return runDeploy(ctx, os.Args[2:])
|
||||||
|
case "update-description":
|
||||||
|
return runUpdateDescription(ctx, os.Args[2:])
|
||||||
case "cleanup":
|
case "cleanup":
|
||||||
return runCleanup(ctx, os.Args[2:])
|
return runCleanup(ctx, os.Args[2:])
|
||||||
|
case "remove-description":
|
||||||
|
return runRemoveDescription(ctx, os.Args[2:])
|
||||||
case "-h", "--help", "help":
|
case "-h", "--help", "help":
|
||||||
usage()
|
usage()
|
||||||
return 0
|
return 0
|
||||||
@@ -50,12 +56,14 @@ func usage() {
|
|||||||
fmt.Fprintln(os.Stderr, `preview — deploy and manage PR preview environments on Sprites.dev
|
fmt.Fprintln(os.Stderr, `preview — deploy and manage PR preview environments on Sprites.dev
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
preview deploy --pr N --sha S [--repo owner/repo] [--port 38429]
|
preview deploy --pr N --sha S [--repo owner/repo] [--port 38429] [--skip-description]
|
||||||
preview cleanup --pr N [--repo owner/repo]
|
preview update-description --pr N --sha S --url URL [--repo owner/repo]
|
||||||
|
preview cleanup --pr N [--repo owner/repo] [--skip-description]
|
||||||
|
preview remove-description --pr N [--repo owner/repo]
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
SPRITES_API_TOKEN Sprites.dev API token (required)
|
SPRITES_API_TOKEN Sprites.dev API token (required)
|
||||||
GH_TOKEN GitHub token for posting PR comments (required)
|
GH_TOKEN GitHub token for PR description updates (required by description commands)
|
||||||
GITHUB_REPOSITORY GitHub repository in owner/repo format (required)
|
GITHUB_REPOSITORY GitHub repository in owner/repo format (required)
|
||||||
|
|
||||||
Local usage:
|
Local usage:
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# ADR-2026-09-05-pr-walkthrough-description-integrity: Keep PR walkthrough description updates canonical and race-safe
|
||||||
|
|
||||||
|
**Status:** accepted
|
||||||
|
**Date:** 2026-09-05
|
||||||
|
**Area:** workflow, infra, GitHub
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
PR walkthrough HTML is published under a 12-character head-SHA URL, but pull
|
||||||
|
request descriptions are shared mutable documents. The walkthrough workflow,
|
||||||
|
preview workflow, users, and external integrations can update the same body.
|
||||||
|
GitHub pull request body updates replace the complete body, so a stale writer
|
||||||
|
can restore an obsolete full-SHA walkthrough URL or remove another automation's
|
||||||
|
marker-owned section. The result is a successful workflow with a reviewer link
|
||||||
|
that returns 404.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use one canonical public URL contract: the first 12 lowercase hexadecimal
|
||||||
|
characters of the published head SHA. The publication job passes its validated
|
||||||
|
URL to the walkthrough link job, and current Kandev writers must not construct
|
||||||
|
full-SHA public URLs.
|
||||||
|
|
||||||
|
All Kandev-owned pull request body writers use a bounded optimistic update
|
||||||
|
protocol: read the live body, merge only the caller's marker-owned section,
|
||||||
|
read again immediately before PATCH, retry when the body changed, and read back
|
||||||
|
the result before reporting success. Walkthrough and preview write jobs share a
|
||||||
|
per-pull-request concurrency group, but that group does not replace the
|
||||||
|
compare-and-readback checks because users and external integrations can write
|
||||||
|
outside GitHub Actions.
|
||||||
|
|
||||||
|
The walkthrough workflow handles pull request `edited` events in a separate,
|
||||||
|
non-generating reconciliation path. It repairs an existing walkthrough marker
|
||||||
|
only after the current canonical object is publicly available. Missing objects,
|
||||||
|
missing callouts, and malformed markers fail closed without a destructive body
|
||||||
|
write.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- New walkthrough links cannot point at the retired full-SHA object format.
|
||||||
|
- A later body edit can self-heal a stale walkthrough callout when its current
|
||||||
|
object is available.
|
||||||
|
- Preview and walkthrough sections preserve each other and contributor content
|
||||||
|
across concurrent Kandev writes.
|
||||||
|
- A write can fail after bounded retries instead of silently overwriting a
|
||||||
|
changing description. The job summary and logs expose that failure.
|
||||||
|
- Existing full-SHA objects are not renamed, and links outside the owned
|
||||||
|
walkthrough block are not changed.
|
||||||
|
- The public host may still add a legacy-path redirect as separate defense in
|
||||||
|
depth, but hosting compatibility is not the source-of-truth repair.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Rely only on the existing workflow concurrency group:** It serializes
|
||||||
|
walkthrough runs but not preview jobs, users, or external integrations.
|
||||||
|
- **Use GitHub conditional PATCH requests alone:** GitHub's REST guidance does
|
||||||
|
not provide a reliable conditional-write contract for this body mutation.
|
||||||
|
The client therefore uses fresh snapshots and post-write verification.
|
||||||
|
- **Publish both full and short URL objects:** This masks stale writers but
|
||||||
|
keeps the obsolete public contract alive and does not protect other body
|
||||||
|
sections from full-body races.
|
||||||
|
- **Rewrite the complete description from a template:** This simplifies one
|
||||||
|
writer but violates the existing ownership boundary and can destroy
|
||||||
|
contributor or other automation content.
|
||||||
@@ -208,6 +208,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
|||||||
| 2026-08-22-pr-walkthrough-description-link | [Own a top-level PR walkthrough callout](2026-08-22-pr-walkthrough-description-link.md) | accepted | workflow, infra, security, GitHub | 2026-08-22 |
|
| 2026-08-22-pr-walkthrough-description-link | [Own a top-level PR walkthrough callout](2026-08-22-pr-walkthrough-description-link.md) | accepted | workflow, infra, security, GitHub | 2026-08-22 |
|
||||||
| 2026-08-23-pr-walkthrough-short-urls | [Use 12-character SHA prefixes for PR walkthrough URLs](2026-08-23-pr-walkthrough-short-urls.md) | accepted | workflow, infra | 2026-08-23 |
|
| 2026-08-23-pr-walkthrough-short-urls | [Use 12-character SHA prefixes for PR walkthrough URLs](2026-08-23-pr-walkthrough-short-urls.md) | accepted | workflow, infra | 2026-08-23 |
|
||||||
| 2026-08-23-pr-walkthrough-workflow-provenance | [Use the workflow SHA for trusted PR walkthrough inputs](2026-08-23-pr-walkthrough-workflow-provenance.md) | accepted | workflow, infra, security | 2026-08-23 |
|
| 2026-08-23-pr-walkthrough-workflow-provenance | [Use the workflow SHA for trusted PR walkthrough inputs](2026-08-23-pr-walkthrough-workflow-provenance.md) | accepted | workflow, infra, security | 2026-08-23 |
|
||||||
|
| 2026-09-05-pr-walkthrough-description-integrity | [Keep PR walkthrough description updates canonical and race-safe](2026-09-05-pr-walkthrough-description-integrity.md) | accepted | workflow, infra, GitHub | 2026-09-05 |
|
||||||
| 2026-08-24-opt-in-ssh-task-directory-reclamation | [Opt-in reclamation of remote SSH task directories](2026-08-24-opt-in-ssh-task-directory-reclamation.md) | accepted | backend, frontend, operations | 2026-08-24 |
|
| 2026-08-24-opt-in-ssh-task-directory-reclamation | [Opt-in reclamation of remote SSH task directories](2026-08-24-opt-in-ssh-task-directory-reclamation.md) | accepted | backend, frontend, operations | 2026-08-24 |
|
||||||
| 2026-08-24-agentctl-local-managed-runtime-cache-repair | [Run cache repair where npm runs](2026-08-24-agentctl-local-managed-runtime-cache-repair.md) | accepted | backend, agentctl, protocol, security | 2026-08-24 |
|
| 2026-08-24-agentctl-local-managed-runtime-cache-repair | [Run cache repair where npm runs](2026-08-24-agentctl-local-managed-runtime-cache-repair.md) | accepted | backend, agentctl, protocol, security | 2026-08-24 |
|
||||||
| 2026-08-24-unified-fork-approval-label | [Use One Maintainer Approval Label for Contributor PR Automation](2026-08-24-unified-fork-approval-label.md) | accepted | infra, workflow, security | 2026-08-24 |
|
| 2026-08-24-unified-fork-approval-label | [Use One Maintainer Approval Label for Contributor PR Automation](2026-08-24-unified-fork-approval-label.md) | accepted | infra, workflow, security | 2026-08-24 |
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
---
|
||||||
|
created: 2026-09-05
|
||||||
|
status: done
|
||||||
|
requirements:
|
||||||
|
- REQ-UI-PR-WALKTHROUGH-001
|
||||||
|
system_design:
|
||||||
|
- ../../specs/ui/system-design/pr-walkthrough.md
|
||||||
|
legacy_specs: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Implementation Plan: PR Walkthrough Description Integrity Fix
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Make the walkthrough and preview description writers converge on one current
|
||||||
|
pull request body, preserve each other's marker-owned sections, and verify
|
||||||
|
their result. Add a non-generating reconciliation path for edited pull
|
||||||
|
requests so an obsolete full-SHA walkthrough URL is repaired when its current
|
||||||
|
short-SHA object is available.
|
||||||
|
|
||||||
|
The work is sequential. First, harden the shared description-write behavior
|
||||||
|
and canonical URL enforcement. Then add the edited-event repair path on top of
|
||||||
|
that protocol.
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
PR #3427 exposed a mismatch between the public object key and the URL in the
|
||||||
|
pull request body. The publisher and successful link job used
|
||||||
|
`pr/3427/990bca847b1c.html`, while the body contained
|
||||||
|
`pr/3427/990bca847b1c0c19c34f243df5a8b0a3b882a155.html`. The short URL returns
|
||||||
|
the walkthrough; the full URL returns 404. The Sprites preview is independent
|
||||||
|
and healthy.
|
||||||
|
|
||||||
|
The current repository has no active walkthrough producer that intentionally
|
||||||
|
emits the full-SHA URL. The body was changed after the successful link job by
|
||||||
|
an unidentified writer or stale full-body update. The exact external writer is
|
||||||
|
not recoverable from public GitHub history, but the current Kandev writers
|
||||||
|
permit the recurrence: they read a body, merge one section, PATCH the complete
|
||||||
|
body, and do not compare a fresh snapshot or verify the result.
|
||||||
|
|
||||||
|
The smallest reproduction is to publish the canonical short object, then
|
||||||
|
replace the owned walkthrough block with the legacy full-SHA URL. The object
|
||||||
|
lookup fails even though publication and the preview environment succeed.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- Enforce the 12-character public URL contract in every current walkthrough
|
||||||
|
description path.
|
||||||
|
- Make walkthrough and preview body writes compare fresh snapshots, retry
|
||||||
|
boundedly, preserve other marker-owned sections, and verify after PATCH.
|
||||||
|
- Serialize Kandev-controlled description writers with one per-PR concurrency
|
||||||
|
group.
|
||||||
|
- Reconcile an existing stale walkthrough block on `pull_request_target`'s
|
||||||
|
`edited` event when the current canonical object is available.
|
||||||
|
- Add focused unit, workflow-contract, and Go HTTP tests for the regression.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Identifying or changing an external GitHub App that may have overwritten the
|
||||||
|
body; that requires organization or application audit access.
|
||||||
|
- Renaming or deleting existing full-SHA R2 objects.
|
||||||
|
- Replacing the PR description with a full template.
|
||||||
|
- Adding a Kandev application UI for walkthrough links.
|
||||||
|
- Adding a host-level legacy URL redirect. That remains optional defense in
|
||||||
|
depth after the writer and reconciliation controls are deployed.
|
||||||
|
|
||||||
|
## Technical approach
|
||||||
|
|
||||||
|
### Description write protocol
|
||||||
|
|
||||||
|
Update `scripts/pr-walkthrough-pr-body` and
|
||||||
|
`apps/backend/cmd/preview/github.go` callers to follow the design's bounded
|
||||||
|
fresh-read, merge, compare, PATCH, and readback sequence. The walkthrough link
|
||||||
|
job continues to consume the publication job's validated URL and rejects any
|
||||||
|
URL that does not match the 12-character canonical form. The walkthrough link,
|
||||||
|
reconciliation, and short preview-description jobs use the shared
|
||||||
|
`pr-description-<number>` concurrency group with cancellation disabled for
|
||||||
|
the write section. Long-running preview lifecycle jobs do not hold that lock;
|
||||||
|
they pass their preview result to a trusted description job, which performs the
|
||||||
|
body write after deployment or cleanup.
|
||||||
|
|
||||||
|
The Go path will use an HTTP transport fake in tests and a bounded retry loop
|
||||||
|
around the body snapshot protocol. The workflow path will keep the
|
||||||
|
trusted helper and minimum GitHub permission boundary, but will stop retrying a
|
||||||
|
stale payload blindly. Both paths will verify their owned marker after the
|
||||||
|
PATCH and retry from the body that actually won.
|
||||||
|
|
||||||
|
### Edited-event reconciliation
|
||||||
|
|
||||||
|
Add a base-controlled, non-generating reconciliation workflow or workflow
|
||||||
|
boundary for `pull_request_target` `edited` events. It will use trusted
|
||||||
|
repository files, no model or R2 credentials, and the existing authorization
|
||||||
|
rules. It will check the current canonical URL with a public HTML request,
|
||||||
|
read the live body, and invoke the walkthrough helper only when an existing
|
||||||
|
walkthrough marker block is present. Missing objects or missing markers are
|
||||||
|
no-ops. Malformed markers fail closed.
|
||||||
|
|
||||||
|
The canonical no-op result prevents a body PATCH from creating a repair loop.
|
||||||
|
The reconciliation path uses the same post-write readback and shared
|
||||||
|
per-PR description concurrency group as the normal link job.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `scripts/pr-walkthrough-pr-body.test.py` will cover required-existing
|
||||||
|
marker mode, legacy full-SHA replacement, canonical no-op behavior, and
|
||||||
|
malformed-marker failure.
|
||||||
|
- `apps/backend/cmd/preview/github_test.go` or a focused sibling test will
|
||||||
|
use a fake GitHub API to change the body between reads and to change it
|
||||||
|
after PATCH. It will prove retry, marker preservation, cleanup behavior,
|
||||||
|
and bounded failure.
|
||||||
|
- `.github/scripts/pr-walkthrough-workflow-contract_test.py` will assert the
|
||||||
|
edited event boundary, non-generating reconciliation, trusted checkout,
|
||||||
|
public-object check, canonical URL rule, shared concurrency group, fresh
|
||||||
|
snapshot comparison, and post-write readback.
|
||||||
|
- Existing workflow and action-pinning tests will continue to enforce trusted
|
||||||
|
inputs and minimal permissions.
|
||||||
|
|
||||||
|
## E2E tests
|
||||||
|
|
||||||
|
There is no Kandev UI flow. The workflow contract and fake GitHub API tests
|
||||||
|
cover the local regression. A same-repository pull request after deployment
|
||||||
|
must provide live evidence that an edited description with a legacy URL is
|
||||||
|
repaired and that a concurrent preview update preserves the walkthrough
|
||||||
|
callout.
|
||||||
|
|
||||||
|
## Work orders
|
||||||
|
|
||||||
|
- [x] [Task 01: Make PR description writes race-safe](task-01-race-safe-description-writes.md)
|
||||||
|
- [x] [Task 02: Reconcile stale walkthrough links](task-02-reconcile-stale-walkthrough-links.md)
|
||||||
|
|
||||||
|
## Verification results
|
||||||
|
|
||||||
|
Task 01 and Task 02 checks passed. The current Kandev writers are
|
||||||
|
race-safe, and edited covered pull requests can repair an existing stale
|
||||||
|
walkthrough callout when the canonical public object is available.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- GitHub body updates replace the complete body, so an external writer can
|
||||||
|
still race after the final local read. Shared concurrency, compare/retry,
|
||||||
|
readback, and edited-event reconciliation reduce this risk but cannot lock
|
||||||
|
writers outside Kandev.
|
||||||
|
- A missing current object prevents repair by design. The next successful
|
||||||
|
publication must create the object before reconciliation can link it.
|
||||||
|
- The preview fork path executes contributor code with deployment credentials;
|
||||||
|
this fix does not widen or redesign that existing trust boundary.
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
id: "01-race-safe-description-writes"
|
||||||
|
title: "Make PR description writes race-safe"
|
||||||
|
status: done
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
plan: "plan.md"
|
||||||
|
requirements:
|
||||||
|
- REQ-UI-PR-WALKTHROUGH-001
|
||||||
|
acceptance_criteria:
|
||||||
|
- AC-UI-PR-WALKTHROUGH-001.9
|
||||||
|
- AC-UI-PR-WALKTHROUGH-001.10
|
||||||
|
system_design:
|
||||||
|
- ../../specs/ui/system-design/pr-walkthrough.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 01: Make PR description writes race-safe
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Harden the walkthrough and preview PR-description writers against stale
|
||||||
|
full-body updates. Each writer will merge its marker-owned section from a
|
||||||
|
fresh body, retry when another writer changes the body, and verify its result
|
||||||
|
after the PATCH.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
- Keep the walkthrough link job bound to the publication job's validated
|
||||||
|
12-character URL.
|
||||||
|
- Add the shared per-PR description concurrency group to the walkthrough link
|
||||||
|
and short trusted preview-description writers.
|
||||||
|
- Add fresh-snapshot comparison, bounded retry, marker preservation, and
|
||||||
|
post-write readback to the Python and Go write paths.
|
||||||
|
- Add canonical URL and concurrent body-update regression tests.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- The edited-event reconciliation job.
|
||||||
|
- Changing the walkthrough generation agent, renderer, or R2 lifecycle.
|
||||||
|
- Renaming existing full-SHA objects or changing external GitHub Apps.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- A current walkthrough writer can only produce the validated URL using the
|
||||||
|
first 12 lowercase SHA characters; a legacy full-SHA URL is replaced inside
|
||||||
|
the owned marker block.
|
||||||
|
- Concurrent walkthrough and preview body updates preserve contributor text
|
||||||
|
and both marker-owned sections, retry from a fresh body, and do not report
|
||||||
|
success until the writer's owned result is readable.
|
||||||
|
- A writer that cannot converge after the bounded retry limit fails without
|
||||||
|
sending a stale merged body.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/pr-walkthrough-pr-body.test.py
|
||||||
|
cd apps/backend && go test ./cmd/preview
|
||||||
|
cd ../.. && python3 .github/scripts/pr-walkthrough-workflow-contract_test.py
|
||||||
|
python3 .github/scripts/lint-action-pinning_test.py
|
||||||
|
python3 .github/scripts/lint-action-pinning.py
|
||||||
|
actionlint .github/workflows/pr-walkthrough.yml .github/workflows/preview-env.yml
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files likely touched
|
||||||
|
|
||||||
|
- `.github/workflows/pr-walkthrough.yml`
|
||||||
|
- `.github/workflows/preview-env.yml`
|
||||||
|
- `.github/scripts/pr-walkthrough-workflow-contract_test.py`
|
||||||
|
- `scripts/pr-walkthrough-pr-body`
|
||||||
|
- `scripts/pr-walkthrough-pr-body.test.py`
|
||||||
|
- `apps/backend/cmd/preview/github.go`
|
||||||
|
- `apps/backend/cmd/preview/github_test.go` or a focused sibling test
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- The GitHub REST API does not provide a relied-upon conditional body write,
|
||||||
|
so a writer can still lose a race with an external integration after its
|
||||||
|
final read. Readback and retry must make this visible and recoverable.
|
||||||
|
- The preview command is built and run from the preview checkout, so its
|
||||||
|
implementation must preserve the existing fork authorization boundary.
|
||||||
|
|
||||||
|
## Parallelism
|
||||||
|
|
||||||
|
`sequential`
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- [PR walkthrough requirements](../../specs/ui/requirements/pr-walkthrough.md)
|
||||||
|
- [PR walkthrough system design](../../specs/ui/system-design/pr-walkthrough.md)
|
||||||
|
- [Description integrity ADR](../../decisions/2026-09-05-pr-walkthrough-description-integrity.md)
|
||||||
|
- Existing `scripts/pr-walkthrough-pr-body` and
|
||||||
|
`apps/backend/cmd/preview/github.go` tests.
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
Implemented the fresh-snapshot, bounded retry, and post-write readback
|
||||||
|
protocol for preview description updates. Walkthrough link updates now use
|
||||||
|
the same per-PR description concurrency group and no longer retry a stale
|
||||||
|
payload. Added Go regression coverage for pre-PATCH body changes, readback
|
||||||
|
loss, bounded non-convergence, marker preservation, cleanup no-ops, and orphan
|
||||||
|
end markers.
|
||||||
|
|
||||||
|
Verification passed:
|
||||||
|
|
||||||
|
- `python3 scripts/pr-walkthrough-pr-body.test.py` (9 tests)
|
||||||
|
- `go test -race ./cmd/preview` (28 tests)
|
||||||
|
- `python3 .github/scripts/pr-walkthrough-workflow-contract_test.py` (27 tests)
|
||||||
|
- `python3 .github/scripts/preview-env-workflow-contract_test.py` (3 tests)
|
||||||
|
- `python3 .github/scripts/lint-action-pinning_test.py` (9 tests)
|
||||||
|
- `python3 .github/scripts/lint-action-pinning.py` (22 workflows)
|
||||||
|
- `mise x actionlint@1.7.12 -- actionlint .github/workflows/pr-walkthrough.yml .github/workflows/preview-env.yml`
|
||||||
|
- `git diff --check`
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
id: "02-reconcile-stale-walkthrough-links"
|
||||||
|
title: "Reconcile stale walkthrough links"
|
||||||
|
status: done
|
||||||
|
wave: 2
|
||||||
|
depends_on:
|
||||||
|
- "01-race-safe-description-writes"
|
||||||
|
plan: "plan.md"
|
||||||
|
requirements:
|
||||||
|
- REQ-UI-PR-WALKTHROUGH-001
|
||||||
|
acceptance_criteria:
|
||||||
|
- AC-UI-PR-WALKTHROUGH-001.9
|
||||||
|
- AC-UI-PR-WALKTHROUGH-001.10
|
||||||
|
- AC-UI-PR-WALKTHROUGH-001.11
|
||||||
|
system_design:
|
||||||
|
- ../../specs/ui/system-design/pr-walkthrough.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 02: Reconcile stale walkthrough links
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a trusted, non-generating reconciliation path for pull request description
|
||||||
|
edits. When an existing walkthrough callout contains a legacy or stale URL,
|
||||||
|
the path will repair only that marker block after confirming that the current
|
||||||
|
canonical short-SHA object is publicly available.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
- Handle `pull_request_target` `edited` events without starting the walkthrough
|
||||||
|
generation agent.
|
||||||
|
- Reuse the trusted walkthrough helper and the race-safe description-write
|
||||||
|
protocol from Task 01.
|
||||||
|
- Require a public HTML response for the current canonical URL before writing.
|
||||||
|
- Treat missing markers as a no-op and malformed or duplicate markers as
|
||||||
|
fail-closed conditions.
|
||||||
|
- Add focused helper and workflow-contract tests for repair and no-op paths.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Re-running walkthrough generation because a description was edited.
|
||||||
|
- Recreating a callout that a user deliberately removed.
|
||||||
|
- Changing contributor authorization, preview deployment, or R2 retention.
|
||||||
|
- Adding a redirect or alias at the walkthrough hosting layer.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- An edited covered pull request with an existing legacy full-SHA walkthrough
|
||||||
|
block and an available current short-SHA object is repaired to the canonical
|
||||||
|
URL while preserving all other body content.
|
||||||
|
- An edit with no walkthrough block or with an unavailable current object does
|
||||||
|
not issue a body PATCH; malformed ownership markers fail closed.
|
||||||
|
- A repair that changes the body is read back and verified, and a canonical
|
||||||
|
body produces a no-op without a repair loop.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/pr-walkthrough-pr-body.test.py
|
||||||
|
python3 .github/scripts/pr-walkthrough-workflow-contract_test.py
|
||||||
|
python3 .github/scripts/lint-action-pinning_test.py
|
||||||
|
python3 .github/scripts/lint-action-pinning.py
|
||||||
|
actionlint .github/workflows/pr-walkthrough.yml .github/workflows/pr-walkthrough-reconcile.yml
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files likely touched
|
||||||
|
|
||||||
|
- `.github/workflows/pr-walkthrough.yml` or a dedicated
|
||||||
|
`.github/workflows/pr-walkthrough-reconcile.yml`
|
||||||
|
- `.github/scripts/pr-walkthrough-workflow-contract_test.py`
|
||||||
|
- `scripts/pr-walkthrough-pr-body`
|
||||||
|
- `scripts/pr-walkthrough-pr-body.test.py`
|
||||||
|
- `.github/workflows/lint-action-pinning.yml` if a new contract test command is
|
||||||
|
required
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Task 01 must establish the description-write protocol and shared concurrency
|
||||||
|
group before reconciliation is enabled.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- GitHub can emit an `edited` event for more than a body-only edit. The
|
||||||
|
reconciliation path must remain a no-op unless it finds an existing owned
|
||||||
|
block that needs repair.
|
||||||
|
- A newly pushed head may not have a published object yet. The path must skip
|
||||||
|
that state instead of linking an unvalidated URL.
|
||||||
|
|
||||||
|
## Parallelism
|
||||||
|
|
||||||
|
`sequential`
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- [PR walkthrough requirements](../../specs/ui/requirements/pr-walkthrough.md)
|
||||||
|
- [PR walkthrough system design](../../specs/ui/system-design/pr-walkthrough.md)
|
||||||
|
- [Description integrity ADR](../../decisions/2026-09-05-pr-walkthrough-description-integrity.md)
|
||||||
|
- Task 01's completed body-write implementation and tests.
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
Implemented the dedicated `pull_request_target` `edited` reconciliation
|
||||||
|
workflow. It checks the current canonical short-SHA URL as a final, non-empty
|
||||||
|
HTML response, verifies that redirects do not change the URL, and confirms the
|
||||||
|
PR head is still the event head before and after a write. It uses the trusted
|
||||||
|
marker helper in required-existing mode and applies the bounded fresh-read,
|
||||||
|
compare, PATCH, and readback protocol. It skips missing objects and missing
|
||||||
|
callouts, and fails closed on malformed markers without a body PATCH.
|
||||||
|
|
||||||
|
Verification passed:
|
||||||
|
|
||||||
|
- `python3 scripts/pr-walkthrough-pr-body.test.py` (9 tests)
|
||||||
|
- `python3 .github/scripts/pr-walkthrough-workflow-contract_test.py` (27 tests)
|
||||||
|
- `python3 .github/scripts/preview-env-workflow-contract_test.py` (3 tests)
|
||||||
|
- `python3 .github/scripts/lint-action-pinning_test.py` (9 tests)
|
||||||
|
- `python3 .github/scripts/lint-action-pinning.py` (22 workflows)
|
||||||
|
- `mise x actionlint@1.7.12 -- actionlint .github/workflows/pr-walkthrough.yml .github/workflows/preview-env.yml .github/workflows/pr-walkthrough-reconcile.yml`
|
||||||
|
- `git diff --check`
|
||||||
@@ -40,6 +40,7 @@ contracts after the shared authorization gate changes.
|
|||||||
| `.github/workflows/opencode-code-review.yml` | Runs the existing OpenCode fork review path with durable `safe-to-review` authorization. |
|
| `.github/workflows/opencode-code-review.yml` | Runs the existing OpenCode fork review path with durable `safe-to-review` authorization. |
|
||||||
| `.github/workflows/preview-env.yml` | Runs the existing privileged fork preview path. |
|
| `.github/workflows/preview-env.yml` | Runs the existing privileged fork preview path. |
|
||||||
| `.github/workflows/pr-walkthrough.yml` | Generates, publishes, and links walkthroughs for authorized fork and same-repository pull requests. |
|
| `.github/workflows/pr-walkthrough.yml` | Generates, publishes, and links walkthroughs for authorized fork and same-repository pull requests. |
|
||||||
|
| `.github/workflows/pr-walkthrough-reconcile.yml` | Repairs an existing stale walkthrough callout after an authorized pull request description edit. |
|
||||||
| `.github/scripts/*workflow_contract_test.py` | Protects job gates, labels, event filters, permissions, and trusted input provenance. |
|
| `.github/scripts/*workflow_contract_test.py` | Protects job gates, labels, event filters, permissions, and trusted input provenance. |
|
||||||
| `scripts/opencode-code-review.test.sh` | Keeps the standalone OpenCode workflow test aligned with the persistent-label contract. |
|
| `scripts/opencode-code-review.test.sh` | Keeps the standalone OpenCode workflow test aligned with the persistent-label contract. |
|
||||||
|
|
||||||
@@ -68,7 +69,9 @@ workflow run.
|
|||||||
### Event contract
|
### Event contract
|
||||||
|
|
||||||
- The walkthrough workflow keeps `pull_request_target` events for opened,
|
- The walkthrough workflow keeps `pull_request_target` events for opened,
|
||||||
ready-for-review, reopened, synchronize, and labeled actions.
|
ready-for-review, reopened, synchronize, and labeled actions. A separate
|
||||||
|
trusted workflow handles `edited` for non-generating reconciliation when the
|
||||||
|
current public object is available.
|
||||||
- A `generate-pr-walkthrough` label still requests a same-repository manual
|
- A `generate-pr-walkthrough` label still requests a same-repository manual
|
||||||
rerun. It does not authorize a contributor pull request.
|
rerun. It does not authorize a contributor pull request.
|
||||||
- The existing Claude workflow keeps its open and labeled fork review policy.
|
- The existing Claude workflow keeps its open and labeled fork review policy.
|
||||||
@@ -86,6 +89,14 @@ The walkthrough keeps the existing provider-neutral contract:
|
|||||||
4. Validate the public response and exact HTML bytes.
|
4. Validate the public response and exact HTML bytes.
|
||||||
5. Add or replace the owned callout in the pull request body in the link job.
|
5. Add or replace the owned callout in the pull request body in the link job.
|
||||||
|
|
||||||
|
The walkthrough link, reconciliation, and short preview-description jobs use
|
||||||
|
one per-pull-request description-write concurrency group. Long-running preview
|
||||||
|
deployment and cleanup jobs do not hold that lock. Each description writer
|
||||||
|
fetches the body again before its PATCH, retries when the body changed, and
|
||||||
|
reads the result back after the write. This protects the walkthrough and
|
||||||
|
preview marker blocks from stale full-body updates while preserving contributor
|
||||||
|
content.
|
||||||
|
|
||||||
The public object key remains
|
The public object key remains
|
||||||
`pr/<number>/<first-12-lowercase-head-sha>.html`.
|
`pr/<number>/<first-12-lowercase-head-sha>.html`.
|
||||||
|
|
||||||
@@ -137,8 +148,13 @@ The public object key remains
|
|||||||
- A missing or mismatched pull request ref fails walkthrough context
|
- A missing or mismatched pull request ref fails walkthrough context
|
||||||
preparation before the agent starts. The exact SHA check prevents a result
|
preparation before the agent starts. The exact SHA check prevents a result
|
||||||
from being associated with a different head.
|
from being associated with a different head.
|
||||||
- Existing walkthrough retry, artifact, R2 validation, and PR-body validation
|
- Existing walkthrough retry, artifact, and R2 validation behavior remains
|
||||||
behavior remains unchanged.
|
unchanged. A stale or legacy walkthrough URL is repaired only when the
|
||||||
|
current canonical public object is available. Missing or malformed marker
|
||||||
|
state fails closed without rewriting the description.
|
||||||
|
- A description-write race causes a fresh merge and bounded retry. A writer
|
||||||
|
reports failure if its post-write readback does not contain its expected
|
||||||
|
marker state.
|
||||||
- Contract tests fail when the old label appears in an active authorization
|
- Contract tests fail when the old label appears in an active authorization
|
||||||
expression or when a privileged job loses its trust or permission boundary.
|
expression or when a privileged job loses its trust or permission boundary.
|
||||||
|
|
||||||
@@ -179,7 +195,9 @@ trigger.
|
|||||||
## Observability
|
## Observability
|
||||||
|
|
||||||
- Existing workflow run status, job summaries, artifact diagnostics, R2 URL
|
- Existing workflow run status, job summaries, artifact diagnostics, R2 URL
|
||||||
validation, and PR-body marker behavior remain the operational signals.
|
validation, and PR-body marker behavior remain the operational signals. The
|
||||||
|
walkthrough reconciliation summary additionally reports no-op, repair,
|
||||||
|
unavailable-object, retry, and readback-failure outcomes.
|
||||||
- Contract tests provide local evidence for every authorization expression,
|
- Contract tests provide local evidence for every authorization expression,
|
||||||
event filter, trusted checkout, exact SHA check, and sensitive permission.
|
event filter, trusted checkout, exact SHA check, and sensitive permission.
|
||||||
- Post-rollout verification must inspect a labeled fork run, an allowlisted
|
- Post-rollout verification must inspect a labeled fork run, an allowlisted
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ UI owns responsive behavior; other systems own behavior/state.
|
|||||||
- [Repository Groups](system-design/sidebar-repository-grouping.md)
|
- [Repository Groups](system-design/sidebar-repository-grouping.md)
|
||||||
- [Sidebar Task Focus](system-design/sidebar-task-focus.md)
|
- [Sidebar Task Focus](system-design/sidebar-task-focus.md)
|
||||||
- [Sidebar task row](system-design/sidebar-task-row-presentation.md)
|
- [Sidebar task row](system-design/sidebar-task-row-presentation.md)
|
||||||
|
- [PR walkthrough](system-design/pr-walkthrough.md)
|
||||||
- [PR Task Status Summary](system-design/pr-task-status-summary.md)
|
- [PR Task Status Summary](system-design/pr-task-status-summary.md)
|
||||||
- [Prompt History Panel](system-design/prompt-history-panel.md)
|
- [Prompt History Panel](system-design/prompt-history-panel.md)
|
||||||
- [Quick Chat and terminal elevation](system-design/quick-chat-elevation.md)
|
- [Quick Chat and terminal elevation](system-design/quick-chat-elevation.md)
|
||||||
|
|||||||
@@ -27,20 +27,14 @@ Reviewers can inspect a diff in GitHub, but a large pull request still takes tim
|
|||||||
- **AC-UI-PR-WALKTHROUGH-001.6:** The configured workflow agent generates and renders the walkthrough for a non-draft same-repository pull request when it is opened, reopened, marked ready for review, or updated. OpenCode is the initial runner, but the skill and artifact contract do not depend on it. A maintainer can explicitly retrigger generation by adding the `generate-pr-walkthrough` label.
|
- **AC-UI-PR-WALKTHROUGH-001.6:** The configured workflow agent generates and renders the walkthrough for a non-draft same-repository pull request when it is opened, reopened, marked ready for review, or updated. OpenCode is the initial runner, but the skill and artifact contract do not depend on it. A maintainer can explicitly retrigger generation by adding the `generate-pr-walkthrough` label.
|
||||||
- **AC-UI-PR-WALKTHROUGH-001.7:** The workflow gives each runner the same fixed prompt, prepared context, draft JSON path, renderer command, and final output paths. A provider change does not change this contract.
|
- **AC-UI-PR-WALKTHROUGH-001.7:** The workflow gives each runner the same fixed prompt, prepared context, draft JSON path, renderer command, and final output paths. A provider change does not change this contract.
|
||||||
- **AC-UI-PR-WALKTHROUGH-001.8:** Kandev CI uses `.pr-walkthrough/draft.json` as the provider-neutral draft path. Its renderer command invokes the script bundled under `.agents/skills/pr-walkthrough/scripts/`.
|
- **AC-UI-PR-WALKTHROUGH-001.8:** Kandev CI uses `.pr-walkthrough/draft.json` as the provider-neutral draft path. Its renderer command invokes the script bundled under `.agents/skills/pr-walkthrough/scripts/`.
|
||||||
|
- **AC-UI-PR-WALKTHROUGH-001.9:** After a walkthrough is publicly validated, its pull request callout shall use the exact validated URL for the object keyed by the first 12 lowercase hexadecimal characters of the published head SHA. A full-SHA URL shall not be emitted by current walkthrough automation.
|
||||||
|
- **AC-UI-PR-WALKTHROUGH-001.10:** When Kandev automation changes a pull request description, it shall merge only its marker-owned section with the latest description, preserve other content and marker-owned sections, and report success only after its owned result is readable from GitHub.
|
||||||
|
- **AC-UI-PR-WALKTHROUGH-001.11:** When a covered pull request description edit leaves an existing walkthrough callout with a stale or legacy URL and the current canonical walkthrough object is publicly available, reconciliation shall replace only that callout with the canonical URL. If the object is unavailable or the markers are malformed, reconciliation shall make no destructive description write and shall report the reason.
|
||||||
|
|
||||||
## Migrated source detail
|
## Migrated source detail
|
||||||
|
|
||||||
## Why
|
This generates the walkthrough HTML in CI and publishes the HTML to
|
||||||
|
the dedicated Cloudflare R2 walkthrough bucket. Lifecycle controls retention.
|
||||||
Reviewers can inspect a diff in GitHub, but a large pull request still takes
|
|
||||||
time to understand. Kandev should generate a visual explanation that gives a
|
|
||||||
reviewer the change context, architecture, important code paths, risk, and
|
|
||||||
review focus before they read the full diff.
|
|
||||||
|
|
||||||
This increment generates the walkthrough HTML in CI and publishes the HTML to
|
|
||||||
the dedicated Cloudflare R2 walkthrough bucket. The hosted file remains
|
|
||||||
available after the pull request merges and expires through the bucket
|
|
||||||
lifecycle policy.
|
|
||||||
|
|
||||||
**Decisions:**
|
**Decisions:**
|
||||||
[ADR-2026-08-22-pr-walkthrough-r2-hosting](../../../decisions/2026-08-22-pr-walkthrough-r2-hosting.md),
|
[ADR-2026-08-22-pr-walkthrough-r2-hosting](../../../decisions/2026-08-22-pr-walkthrough-r2-hosting.md),
|
||||||
@@ -48,11 +42,11 @@ lifecycle policy.
|
|||||||
[ADR-2026-08-22-pr-walkthrough-description-link](../../../decisions/2026-08-22-pr-walkthrough-description-link.md),
|
[ADR-2026-08-22-pr-walkthrough-description-link](../../../decisions/2026-08-22-pr-walkthrough-description-link.md),
|
||||||
[ADR-2026-08-23-pr-walkthrough-short-urls](../../../decisions/2026-08-23-pr-walkthrough-short-urls.md),
|
[ADR-2026-08-23-pr-walkthrough-short-urls](../../../decisions/2026-08-23-pr-walkthrough-short-urls.md),
|
||||||
[ADR-2026-08-23-pr-walkthrough-workflow-provenance](../../../decisions/2026-08-23-pr-walkthrough-workflow-provenance.md),
|
[ADR-2026-08-23-pr-walkthrough-workflow-provenance](../../../decisions/2026-08-23-pr-walkthrough-workflow-provenance.md),
|
||||||
|
[ADR-2026-09-05-pr-walkthrough-description-integrity](../../../decisions/2026-09-05-pr-walkthrough-description-integrity.md),
|
||||||
[ADR-2026-08-24-unified-fork-approval-label](../../../decisions/2026-08-24-unified-fork-approval-label.md)
|
[ADR-2026-08-24-unified-fork-approval-label](../../../decisions/2026-08-24-unified-fork-approval-label.md)
|
||||||
|
|
||||||
**Implementation plans:**
|
**Implementation plans:**
|
||||||
[Portable PR walkthrough runner fix](../../../plans/pr-walkthrough-portable-runner-fix/plan.md),
|
[PR walkthrough description integrity fix](../../../plans/pr-walkthrough-description-integrity-fix/plan.md)
|
||||||
[PR walkthrough runner reliability fix](../../../plans/pr-walkthrough-runner-reliability-fix/plan.md)
|
|
||||||
|
|
||||||
## What
|
## What
|
||||||
|
|
||||||
@@ -95,7 +89,9 @@ lifecycle policy.
|
|||||||
`.agents/skills/pr-walkthrough/scripts/`.
|
`.agents/skills/pr-walkthrough/scripts/`.
|
||||||
- Walkthrough automation lives in `.github/workflows/pr-walkthrough.yml` and
|
- Walkthrough automation lives in `.github/workflows/pr-walkthrough.yml` and
|
||||||
is enabled independently with the `PR_WALKTHROUGH_ENABLED` repository
|
is enabled independently with the `PR_WALKTHROUGH_ENABLED` repository
|
||||||
variable. It does not share the `OPENCODE_REVIEW_ENABLED` code-review gate.
|
variable. The trusted, non-generating repair path for edited descriptions is
|
||||||
|
in `.github/workflows/pr-walkthrough-reconcile.yml`. It does not share the
|
||||||
|
`OPENCODE_REVIEW_ENABLED` code-review gate.
|
||||||
- The initial runner uses `opencode-go/muse-spark-1.2-contributor` and its
|
- The initial runner uses `opencode-go/muse-spark-1.2-contributor` and its
|
||||||
built-in `high` reasoning variant. The workflow passes these values with the
|
built-in `high` reasoning variant. The workflow passes these values with the
|
||||||
OpenCode 1.17.7 `--model` and `--variant` options.
|
OpenCode 1.17.7 `--model` and `--variant` options.
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
---
|
||||||
|
status: current
|
||||||
|
system: ui
|
||||||
|
requirements:
|
||||||
|
- REQ-UI-PR-WALKTHROUGH-001
|
||||||
|
---
|
||||||
|
|
||||||
|
# PR Walkthrough Publication and Description Integrity System Design
|
||||||
|
|
||||||
|
## Purpose and boundaries
|
||||||
|
|
||||||
|
The PR walkthrough contract owns the reviewer-facing relationship between a
|
||||||
|
published walkthrough object and the link in the pull request description.
|
||||||
|
The walkthrough requirement remains the source of truth for the public URL,
|
||||||
|
the marker-owned callout, and its repair behavior.
|
||||||
|
|
||||||
|
The CI automation system owns workflow authorization, event gates, and job
|
||||||
|
permissions. The preview command owns preview deployment. This design covers
|
||||||
|
the shared description-write protocol that those adjacent components must use
|
||||||
|
when they update the same GitHub pull request document.
|
||||||
|
|
||||||
|
## Requirement mapping
|
||||||
|
|
||||||
|
| Requirement | Design section |
|
||||||
|
| --- | --- |
|
||||||
|
| `REQ-UI-PR-WALKTHROUGH-001` | [Public URL contract](#public-url-contract), [Description ownership and writes](#description-ownership-and-writes), [Reconciliation flow](#reconciliation-flow), [Failure and recovery](#failure-and-recovery) |
|
||||||
|
| `AC-UI-PR-WALKTHROUGH-001.9` | [Public URL contract](#public-url-contract), [Publication flow](#publication-flow) |
|
||||||
|
| `AC-UI-PR-WALKTHROUGH-001.10` | [Description ownership and writes](#description-ownership-and-writes), [Failure and recovery](#failure-and-recovery) |
|
||||||
|
| `AC-UI-PR-WALKTHROUGH-001.11` | [Reconciliation flow](#reconciliation-flow), [Failure and recovery](#failure-and-recovery) |
|
||||||
|
|
||||||
|
## Components and responsibilities
|
||||||
|
|
||||||
|
| Component | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `.github/workflows/pr-walkthrough.yml` | Generate the walkthrough, publish the HTML, and link the validated URL. |
|
||||||
|
| `.github/workflows/pr-walkthrough-reconcile.yml` | Reconcile an existing walkthrough callout after a pull request description edit without generating or executing pull request code. |
|
||||||
|
| `scripts/pr-walkthrough-pr-body` | Validate the pull request identity and canonical URL, merge the walkthrough marker block, and reject malformed ownership state. |
|
||||||
|
| `apps/backend/cmd/preview/github.go` | Update or remove the preview marker block using the same fresh-read, merge, compare, patch, and readback protocol. |
|
||||||
|
| GitHub pull request description | External mutable document containing contributor content plus independently marker-owned automation sections. |
|
||||||
|
| Cloudflare R2 and `walkthrough.kandev.ai` | Store and serve immutable head-keyed HTML objects for the published snapshots. |
|
||||||
|
|
||||||
|
## Public URL contract
|
||||||
|
|
||||||
|
The workflow keeps the full lowercase 40-character head SHA for event identity,
|
||||||
|
object validation, and trusted workflow inputs. The public object key and
|
||||||
|
callout URL use only its first 12 lowercase hexadecimal characters:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pr/<pull-request-number>/<head-sha[0:12]>.html
|
||||||
|
```
|
||||||
|
|
||||||
|
The publication job is the source of the URL consumed by the link job. The
|
||||||
|
link job does not reconstruct a URL from a different SHA length. The body
|
||||||
|
helper accepts only the exact custom-domain URL derived from the event head and
|
||||||
|
the 12-character public prefix.
|
||||||
|
|
||||||
|
Existing full-SHA objects are not renamed or rewritten. A stale full-SHA link
|
||||||
|
in an owned callout is corrected when a valid canonical object is available.
|
||||||
|
|
||||||
|
## Description ownership and writes
|
||||||
|
|
||||||
|
The walkthrough owns only the content between
|
||||||
|
`kandev-pr-walkthrough-start` and `kandev-pr-walkthrough-end`. The preview
|
||||||
|
automation owns only its corresponding preview markers. All content outside a
|
||||||
|
writer's markers is preserved.
|
||||||
|
|
||||||
|
Every Kandev-owned body mutation follows this bounded protocol:
|
||||||
|
|
||||||
|
1. Fetch the current pull request body.
|
||||||
|
2. Merge only the caller's marker-owned section.
|
||||||
|
3. Fetch the body again immediately before the write.
|
||||||
|
4. If the body differs from the first snapshot, discard the merged payload,
|
||||||
|
repeat from the latest body, and do not patch the stale document.
|
||||||
|
5. Patch the complete body because the GitHub pull request update API replaces
|
||||||
|
the body representation.
|
||||||
|
6. Fetch the body after the patch. Report success only when the writer's
|
||||||
|
expected marker state is present and the other marker-owned content remains
|
||||||
|
present.
|
||||||
|
|
||||||
|
The protocol is bounded to three attempts. A per-pull-request description
|
||||||
|
concurrency group serializes the walkthrough, reconciliation, and short
|
||||||
|
preview-description jobs that Kandev controls. Long-running preview lifecycle
|
||||||
|
jobs do not hold that lock. Compare-and-readback checks remain required because
|
||||||
|
users and external integrations can edit the document outside GitHub Actions.
|
||||||
|
An update that cannot converge fails without a best-effort broad rewrite.
|
||||||
|
|
||||||
|
## Publication flow
|
||||||
|
|
||||||
|
1. The generation job produces the JSON and HTML artifacts for the event head.
|
||||||
|
2. The publication job uploads only HTML under the 12-character object key.
|
||||||
|
3. It validates content type, non-zero length, public availability, and exact
|
||||||
|
public bytes.
|
||||||
|
4. It exports the validated public URL to the link job.
|
||||||
|
5. The link job uses the description-write protocol to prepend or replace the
|
||||||
|
walkthrough marker block.
|
||||||
|
|
||||||
|
The link job may link the validated published snapshot even if the pull request
|
||||||
|
head advances while generation or publication is running. A later
|
||||||
|
`synchronize` run produces and links the newer head snapshot.
|
||||||
|
|
||||||
|
## Reconciliation flow
|
||||||
|
|
||||||
|
The dedicated reconciliation workflow accepts the `edited` pull request event
|
||||||
|
for a non-generating path. The reconciliation job:
|
||||||
|
|
||||||
|
1. Uses the trusted workflow checkout and the existing authorization boundary.
|
||||||
|
2. Computes the current head's canonical 12-character public URL.
|
||||||
|
3. Checks that the canonical object is publicly available as HTML.
|
||||||
|
4. Reads the live pull request description.
|
||||||
|
5. If an existing walkthrough marker block contains a stale or legacy URL, it
|
||||||
|
replaces only that block using the description-write protocol.
|
||||||
|
6. If no walkthrough marker exists, the object is unavailable, or the markers
|
||||||
|
are malformed, it does not perform a destructive write and records the
|
||||||
|
reason.
|
||||||
|
|
||||||
|
An already-canonical block is a no-op. A body PATCH emitted by Kandev may
|
||||||
|
itself produce an `edited` event; the canonical no-op path prevents a repair
|
||||||
|
loop.
|
||||||
|
|
||||||
|
## Failure and recovery
|
||||||
|
|
||||||
|
- A missing public object prevents reconciliation from changing the body. The
|
||||||
|
next successful generation or publication remains responsible for creating
|
||||||
|
the link.
|
||||||
|
- An unbalanced, duplicate, or non-leading walkthrough marker fails closed.
|
||||||
|
Contributor content is not guessed or reconstructed.
|
||||||
|
- A stale body snapshot causes a fresh merge and bounded retry. The stale body
|
||||||
|
is never sent to GitHub.
|
||||||
|
- A post-write readback that does not contain the expected owned result causes
|
||||||
|
another fresh merge. Exhausting retries fails the job and exposes the
|
||||||
|
condition in the job summary.
|
||||||
|
- A legacy full-SHA URL is treated as replaceable content only inside the
|
||||||
|
walkthrough markers. Full-SHA links outside the owned block are preserved.
|
||||||
|
- Preview cleanup keeps its existing behavior. If no preview marker exists,
|
||||||
|
removal remains a no-op and does not affect the walkthrough block.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
R2 objects remain keyed by pull request number and the first 12 characters of
|
||||||
|
the exact head SHA. Reruns replace bytes at the same key for the same head;
|
||||||
|
different heads receive different keys. The pull request description remains
|
||||||
|
GitHub-owned mutable state and is not copied into Kandev persistence.
|
||||||
|
|
||||||
|
No migration removes existing full-SHA objects. The URL contract is enforced
|
||||||
|
for new publication and for repair of the marker-owned callout.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
The generation and reconciliation jobs use the trusted workflow commit for
|
||||||
|
helpers and workflow assets. Reconciliation reads pull request metadata and a
|
||||||
|
public URL; it does not check out or execute pull request code and does not
|
||||||
|
receive model or R2 credentials.
|
||||||
|
|
||||||
|
The link and preview writers retain only the GitHub pull request permission
|
||||||
|
needed for their respective path. The preview fork path keeps its existing
|
||||||
|
explicit authorization because it executes the preview command against the
|
||||||
|
contributor head.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
The walkthrough workflow summary records whether linking was unchanged,
|
||||||
|
repaired, retried after a body race, or skipped because the canonical object
|
||||||
|
was unavailable. A failed post-write readback includes the final marker
|
||||||
|
validation error in the job log.
|
||||||
|
|
||||||
|
Contract tests cover event routing, trusted checkout, shared concurrency,
|
||||||
|
canonical URL validation, no-op reconciliation, fail-closed malformed markers,
|
||||||
|
and the absence of full-SHA URL construction in current writers. Unit or
|
||||||
|
integration tests cover body races and preservation of the walkthrough and
|
||||||
|
preview marker blocks.
|
||||||
|
|
||||||
|
## Related decisions
|
||||||
|
|
||||||
|
- [Own a top-level PR walkthrough callout](../../../decisions/2026-08-22-pr-walkthrough-description-link.md)
|
||||||
|
- [Use 12-character SHA prefixes for PR walkthrough URLs](../../../decisions/2026-08-23-pr-walkthrough-short-urls.md)
|
||||||
|
- [Use the workflow SHA for trusted PR walkthrough inputs](../../../decisions/2026-08-23-pr-walkthrough-workflow-provenance.md)
|
||||||
|
- [Keep PR walkthrough description updates canonical and race-safe](../../../decisions/2026-09-05-pr-walkthrough-description-integrity.md)
|
||||||
|
- [Unified contributor pull request automation](../../ci/system-design/unified-contributor-pr-automation.md)
|
||||||
@@ -14,6 +14,7 @@ END_MARKER = "<!-- kandev-pr-walkthrough-end -->"
|
|||||||
SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||||||
PUBLIC_SHA_LENGTH = 12
|
PUBLIC_SHA_LENGTH = 12
|
||||||
UNCHANGED = 3
|
UNCHANGED = 3
|
||||||
|
NO_WALKTHROUGH = 4
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
@@ -23,6 +24,11 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--url", required=True)
|
parser.add_argument("--url", required=True)
|
||||||
parser.add_argument("--pr-number", type=int, required=True)
|
parser.add_argument("--pr-number", type=int, required=True)
|
||||||
parser.add_argument("--head-sha", required=True)
|
parser.add_argument("--head-sha", required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--require-existing",
|
||||||
|
action="store_true",
|
||||||
|
help="return a no-op when the PR body has no owned walkthrough block",
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +99,12 @@ def main() -> int:
|
|||||||
try:
|
try:
|
||||||
validate_url(args.url, args.pr_number, args.head_sha)
|
validate_url(args.url, args.pr_number, args.head_sha)
|
||||||
body = load_pr(args.github_response, args.pr_number)
|
body = load_pr(args.github_response, args.pr_number)
|
||||||
|
if (
|
||||||
|
args.require_existing
|
||||||
|
and START_MARKER not in body
|
||||||
|
and END_MARKER not in body
|
||||||
|
):
|
||||||
|
return NO_WALKTHROUGH
|
||||||
updated = update_body(body, args.url)
|
updated = update_body(body, args.url)
|
||||||
if updated == body:
|
if updated == body:
|
||||||
return UNCHANGED
|
return UNCHANGED
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ SHORT_HEAD_SHA = HEAD_SHA[:12]
|
|||||||
URL = f"https://walkthrough.kandev.ai/pr/{PR_NUMBER}/{SHORT_HEAD_SHA}.html"
|
URL = f"https://walkthrough.kandev.ai/pr/{PR_NUMBER}/{SHORT_HEAD_SHA}.html"
|
||||||
START = "<!-- kandev-pr-walkthrough-start -->"
|
START = "<!-- kandev-pr-walkthrough-start -->"
|
||||||
END = "<!-- kandev-pr-walkthrough-end -->"
|
END = "<!-- kandev-pr-walkthrough-end -->"
|
||||||
|
NO_WALKTHROUGH = 4
|
||||||
|
|
||||||
|
|
||||||
class PRWalkthroughPRBodyTest(unittest.TestCase):
|
class PRWalkthroughPRBodyTest(unittest.TestCase):
|
||||||
@@ -26,6 +27,7 @@ class PRWalkthroughPRBodyTest(unittest.TestCase):
|
|||||||
url: str = URL,
|
url: str = URL,
|
||||||
response_number: int = PR_NUMBER,
|
response_number: int = PR_NUMBER,
|
||||||
response_sha: str = HEAD_SHA,
|
response_sha: str = HEAD_SHA,
|
||||||
|
require_existing: bool = False,
|
||||||
) -> tuple[subprocess.CompletedProcess[str], Path, tempfile.TemporaryDirectory[str]]:
|
) -> tuple[subprocess.CompletedProcess[str], Path, tempfile.TemporaryDirectory[str]]:
|
||||||
temp_dir = tempfile.TemporaryDirectory()
|
temp_dir = tempfile.TemporaryDirectory()
|
||||||
root = Path(temp_dir.name)
|
root = Path(temp_dir.name)
|
||||||
@@ -41,21 +43,24 @@ class PRWalkthroughPRBodyTest(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
command = [
|
||||||
|
"python3",
|
||||||
|
str(SCRIPT),
|
||||||
|
"--github-response",
|
||||||
|
str(response),
|
||||||
|
"--output",
|
||||||
|
str(output),
|
||||||
|
"--url",
|
||||||
|
url,
|
||||||
|
"--pr-number",
|
||||||
|
str(PR_NUMBER),
|
||||||
|
"--head-sha",
|
||||||
|
HEAD_SHA,
|
||||||
|
]
|
||||||
|
if require_existing:
|
||||||
|
command.append("--require-existing")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
command,
|
||||||
"python3",
|
|
||||||
str(SCRIPT),
|
|
||||||
"--github-response",
|
|
||||||
str(response),
|
|
||||||
"--output",
|
|
||||||
str(output),
|
|
||||||
"--url",
|
|
||||||
url,
|
|
||||||
"--pr-number",
|
|
||||||
str(PR_NUMBER),
|
|
||||||
"--head-sha",
|
|
||||||
HEAD_SHA,
|
|
||||||
],
|
|
||||||
cwd=REPO_ROOT,
|
cwd=REPO_ROOT,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -88,7 +93,7 @@ class PRWalkthroughPRBodyTest(unittest.TestCase):
|
|||||||
f"> **PR walkthrough:** [Open the visual walkthrough]({old_url})\n"
|
f"> **PR walkthrough:** [Open the visual walkthrough]({old_url})\n"
|
||||||
f"{END}\n\nContributor content"
|
f"{END}\n\nContributor content"
|
||||||
)
|
)
|
||||||
result, output, temp_dir = self.run_updater(original)
|
result, output, temp_dir = self.run_updater(original, require_existing=True)
|
||||||
self.addCleanup(temp_dir.cleanup)
|
self.addCleanup(temp_dir.cleanup)
|
||||||
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
@@ -99,6 +104,15 @@ class PRWalkthroughPRBodyTest(unittest.TestCase):
|
|||||||
self.assertNotIn(old_url, updated)
|
self.assertNotIn(old_url, updated)
|
||||||
self.assertTrue(updated.endswith("\n\nContributor content"))
|
self.assertTrue(updated.endswith("\n\nContributor content"))
|
||||||
|
|
||||||
|
def test_reconciliation_without_an_owned_block_is_a_no_op(self) -> None:
|
||||||
|
result, output, temp_dir = self.run_updater(
|
||||||
|
"Contributor content", require_existing=True
|
||||||
|
)
|
||||||
|
self.addCleanup(temp_dir.cleanup)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, NO_WALKTHROUGH, result.stderr)
|
||||||
|
self.assertFalse(output.exists())
|
||||||
|
|
||||||
def test_reports_an_unchanged_body_without_writing_a_payload(self) -> None:
|
def test_reports_an_unchanged_body_without_writing_a_payload(self) -> None:
|
||||||
original = (
|
original = (
|
||||||
f"{START}\n> [!TIP]\n"
|
f"{START}\n> [!TIP]\n"
|
||||||
@@ -159,7 +173,7 @@ class PRWalkthroughPRBodyTest(unittest.TestCase):
|
|||||||
f"{START}\none\n{END}\n{START}\ntwo\n{END}",
|
f"{START}\none\n{END}\n{START}\ntwo\n{END}",
|
||||||
):
|
):
|
||||||
with self.subTest(body=body):
|
with self.subTest(body=body):
|
||||||
result, output, temp_dir = self.run_updater(body)
|
result, output, temp_dir = self.run_updater(body, require_existing=True)
|
||||||
self.addCleanup(temp_dir.cleanup)
|
self.addCleanup(temp_dir.cleanup)
|
||||||
self.assertNotEqual(result.returncode, 0)
|
self.assertNotEqual(result.returncode, 0)
|
||||||
self.assertFalse(output.exists())
|
self.assertFalse(output.exists())
|
||||||
|
|||||||
Reference in New Issue
Block a user