fix: keep PR walkthrough links canonical (#3438)
This commit is contained in:
@@ -8,6 +8,8 @@ import unittest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
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"
|
||||
LINT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.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():
|
||||
raise AssertionError("PR walkthrough must have a dedicated workflow file")
|
||||
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.skill = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
|
||||
cls.generation = workflow_job(cls.workflow, "pr-walkthrough-generate")
|
||||
cls.publication = workflow_job(cls.workflow, "pr-walkthrough-publish")
|
||||
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.fork_review = workflow_job(cls.review_workflow, "opencode-review-fork")
|
||||
|
||||
@@ -76,6 +101,82 @@ class PRWalkthroughWorkflowContractTest(unittest.TestCase):
|
||||
self.assertIn(condition, 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:
|
||||
self.assertNotIn("Kandev", 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:
|
||||
for value in (
|
||||
"pr_response_ok=false",
|
||||
"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\"))'",
|
||||
'test "$pr_response_ok" = true',
|
||||
"patch_ok=false",
|
||||
'test "$patch_ok" = true',
|
||||
"pr-latest-response.json",
|
||||
"pr-verified-response.json",
|
||||
"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)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Contract tests for the fork preview authorization workflow."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
|
||||
@@ -9,6 +10,15 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
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):
|
||||
def test_safe_to_review_and_allowlists_authorize_fork_preview_deployment(self) -> None:
|
||||
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)",
|
||||
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:
|
||||
workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||
@@ -45,6 +55,32 @@ class PreviewEnvironmentWorkflowContractTest(unittest.TestCase):
|
||||
self.assertNotIn(" strip-safe-to-test:", 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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
name: PR Walkthrough Reconcile
|
||||
|
||||
# A description edit must be able to repair a stale callout without starting
|
||||
# the model-backed generation workflow or executing pull request code.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [edited]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: pr-walkthrough-reconcile-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pr-walkthrough-reconcile:
|
||||
if: >
|
||||
vars.PR_WALKTHROUGH_ENABLED == 'true' &&
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'edited' &&
|
||||
github.event.pull_request.draft == false &&
|
||||
(
|
||||
github.event.pull_request.head.repo.full_name == github.repository ||
|
||||
(
|
||||
github.event.pull_request.head.repo.full_name != github.repository &&
|
||||
(
|
||||
contains(github.event.pull_request.labels.*.name, 'safe-to-review') ||
|
||||
(vars.CLAUDE_REVIEW_ALLOWLIST != '' &&
|
||||
contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login))
|
||||
)
|
||||
)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
concurrency:
|
||||
group: pr-description-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout trusted reconciliation helper
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Repair stale walkthrough callout
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TRUSTED_SHA: ${{ github.workflow_sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
WALKTHROUGH_BASE_URL: https://walkthrough.kandev.ai
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$TRUSTED_SHA"
|
||||
test -f scripts/pr-walkthrough-pr-body
|
||||
test "$PR_NUMBER" -gt 0
|
||||
[[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
|
||||
SHORT_HEAD_SHA="${HEAD_SHA:0:12}"
|
||||
PUBLIC_URL="${WALKTHROUGH_BASE_URL%/}/pr/${PR_NUMBER}/${SHORT_HEAD_SHA}.html"
|
||||
RESPONSE_META_PATH=pr-walkthrough-public.response
|
||||
PUBLIC_HTML_PATH=pr-walkthrough-public.html
|
||||
|
||||
if ! curl --fail --silent --show-error --location --max-time 30 \
|
||||
--retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 30 \
|
||||
--output "$PUBLIC_HTML_PATH" \
|
||||
--write-out '%{http_code}\t%{content_type}\t%{url_effective}' \
|
||||
> "$RESPONSE_META_PATH" \
|
||||
"$PUBLIC_URL"; then
|
||||
echo "Canonical walkthrough is not publicly available; skipping repair." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
IFS=$'\t' read -r response_status response_content_type response_effective_url < "$RESPONSE_META_PATH"
|
||||
if [ "$response_status" != "200" ] ||
|
||||
[ "$response_effective_url" != "$PUBLIC_URL" ] ||
|
||||
! printf '%s\n' "$response_content_type" | grep -Eiq '^text/html(;|[[:space:]]|$)' ||
|
||||
! test -s "$PUBLIC_HTML_PATH"; then
|
||||
echo "Canonical walkthrough did not return a non-empty HTML response; skipping repair." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
rm -f \
|
||||
pr-response.json \
|
||||
pr-latest-response.json \
|
||||
pr-verified-response.json \
|
||||
pr-update.json \
|
||||
pr-verify-update.json \
|
||||
pr-body-before \
|
||||
pr-body-latest \
|
||||
pr-body-expected \
|
||||
pr-body-verified
|
||||
|
||||
if ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > pr-response.json ||
|
||||
! python3 -c 'import json; json.load(open("pr-response.json", encoding="utf-8"))'; then
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "Unable to fetch the pull request description." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
set +e
|
||||
python3 scripts/pr-walkthrough-pr-body \
|
||||
--github-response pr-response.json \
|
||||
--output pr-update.json \
|
||||
--url "$PUBLIC_URL" \
|
||||
--pr-number "$PR_NUMBER" \
|
||||
--head-sha "$HEAD_SHA" \
|
||||
--require-existing
|
||||
update_status=$?
|
||||
set -e
|
||||
|
||||
if [ "$update_status" -eq 4 ]; then
|
||||
echo "No existing walkthrough callout found; skipping repair." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$update_status" -eq 3 ]; then
|
||||
echo "PR description already links the canonical walkthrough." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$update_status" -ne 0 ]; then
|
||||
echo "Existing walkthrough markers are malformed; refusing to rewrite the PR description." >&2
|
||||
exit 1
|
||||
fi
|
||||
jq -j '.body // ""' pr-update.json > pr-body-expected
|
||||
|
||||
jq -j '.body // ""' pr-response.json > pr-body-before
|
||||
if ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > pr-latest-response.json ||
|
||||
! python3 -c 'import json; json.load(open("pr-latest-response.json", encoding="utf-8"))'; then
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "Unable to refresh the pull request description." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
current_head_sha="$(jq -r '.head.sha // ""' pr-latest-response.json)"
|
||||
if [ "$current_head_sha" != "$HEAD_SHA" ]; then
|
||||
echo "PR head advanced to ${current_head_sha}; skipping stale repair." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
jq -j '.body // ""' pr-latest-response.json > pr-body-latest
|
||||
if ! cmp -- pr-body-before pr-body-latest; then
|
||||
echo "Pull request description changed before repair PATCH; retrying." >&2
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! gh api --method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
--input pr-update.json \
|
||||
--silent; then
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "Unable to repair the pull request description." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > pr-verified-response.json ||
|
||||
! python3 -c 'import json; json.load(open("pr-verified-response.json", encoding="utf-8"))'; then
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "Unable to verify the repaired pull request description." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
verified_head_sha="$(jq -r '.head.sha // ""' pr-verified-response.json)"
|
||||
if [ "$verified_head_sha" != "$HEAD_SHA" ]; then
|
||||
echo "PR head advanced to ${verified_head_sha}; skipping stale repair." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
jq -j '.body // ""' pr-verified-response.json > pr-body-verified
|
||||
if ! cmp -- pr-body-expected pr-body-verified; then
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "Pull request description changed before repair readback verification." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Pull request description changed after repair PATCH; retrying." >&2
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
set +e
|
||||
python3 scripts/pr-walkthrough-pr-body \
|
||||
--github-response pr-verified-response.json \
|
||||
--output pr-verify-update.json \
|
||||
--url "$PUBLIC_URL" \
|
||||
--pr-number "$PR_NUMBER" \
|
||||
--head-sha "$HEAD_SHA" \
|
||||
--require-existing
|
||||
verify_status=$?
|
||||
set -e
|
||||
if [ "$verify_status" -eq 3 ]; then
|
||||
echo "Repaired the stale walkthrough link in the PR description." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "Pull request description readback did not contain the canonical walkthrough." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Pull request description readback changed; retrying repair." >&2
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Pull request description repair did not converge." >&2
|
||||
exit 1
|
||||
@@ -367,6 +367,9 @@ jobs:
|
||||
needs: pr-walkthrough-publish
|
||||
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
|
||||
@@ -392,47 +395,113 @@ jobs:
|
||||
test -f scripts/pr-walkthrough-pr-body
|
||||
test -n "$PUBLIC_URL"
|
||||
|
||||
pr_response_ok=false
|
||||
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"))'; then
|
||||
pr_response_ok=true
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -lt 3 ]; then
|
||||
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
|
||||
done
|
||||
test "$pr_response_ok" = true
|
||||
|
||||
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"
|
||||
update_status=$?
|
||||
set -e
|
||||
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"
|
||||
update_status=$?
|
||||
set -e
|
||||
|
||||
if [ "$update_status" -eq 3 ]; then
|
||||
echo "PR description already links the published walkthrough." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
test "$update_status" -eq 0
|
||||
if [ "$update_status" -eq 3 ]; then
|
||||
echo "PR description already links the published walkthrough." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
test "$update_status" -eq 0
|
||||
jq -j '.body // ""' pr-update.json > pr-body-expected
|
||||
|
||||
patch_ok=false
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method PATCH \
|
||||
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
|
||||
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}" \
|
||||
--input pr-update.json \
|
||||
--silent; then
|
||||
patch_ok=true
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -lt 3 ]; then
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "Unable to update 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 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
|
||||
test "$patch_ok" = true
|
||||
echo "Added the walkthrough link to the top of the PR description." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Pull request description update did not converge." >&2
|
||||
exit 1
|
||||
|
||||
@@ -29,9 +29,10 @@ jobs:
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
preview_url: ${{ steps.deploy.outputs.preview_url }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
@@ -56,14 +57,20 @@ jobs:
|
||||
run: pnpm -C apps install --frozen-lockfile
|
||||
|
||||
- name: Deploy preview environment
|
||||
id: deploy
|
||||
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:
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
SHA: ${{ github.event.pull_request.head.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
SPRITES_API_TOKEN: ${{ secrets.SPRITES_API_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 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.CLAUDE_REVIEW_ALLOWLIST != '' && contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login)))
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
preview_url: ${{ steps.deploy.outputs.preview_url }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
# 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,
|
||||
# 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
|
||||
# follow-up pushes until the maintainer removes it.
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -125,14 +133,106 @@ jobs:
|
||||
run: pnpm -C apps install --frozen-lockfile
|
||||
|
||||
- name: Deploy preview environment
|
||||
id: deploy
|
||||
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:
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
SHA: ${{ github.event.pull_request.head.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
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 }}
|
||||
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)
|
||||
@@ -149,7 +249,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
@@ -163,9 +262,43 @@ jobs:
|
||||
|
||||
- name: Destroy preview environment
|
||||
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:
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
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 }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
|
||||
Reference in New Issue
Block a user