fix: keep PR walkthrough links canonical (#3438)

This commit is contained in:
Carlos Florêncio
2026-09-06 15:14:28 +01:00
committed by GitHub
parent 7cb5d8c0b3
commit 039142db80
22 changed files with 1793 additions and 106 deletions
@@ -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
+101 -32
View File
@@ -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
+142 -9
View File
@@ -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 }}
+8 -1
View File
@@ -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)
+5 -1
View File
@@ -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 {
+81
View File
@@ -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
}
+93 -19
View File
@@ -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)
}
}
+13 -5
View File
@@ -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.
+1
View File
@@ -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.
@@ -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`
@@ -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
+1
View File
@@ -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)
+10 -14
View File
@@ -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)
+12
View File
@@ -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
+30 -16
View File
@@ -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())