Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ The publisher deploys one combined site:
- `/quickdash/pr-preview/pr-<number>/` serves that PR’s latest published successful build.
- `/quickdash/pr-preview/` lists open PRs, preview links, and the exact built commits. It labels a retained preview as **Previous successful build** when the current PR head has no successful build yet.

Preview dashboards and demos have a `[DEV PREVIEW] PR #<number>` browser title, a persistent amber banner linking the PR and main dashboard, and a warm-colored header. The trusted publisher adds this presentation, including to retained previews whose artifacts have expired. Production keeps its original appearance. These visual cues identify a review page; they do not isolate its JavaScript from the shared Pages origin.

After deployment succeeds, the publisher creates or updates one bot comment in each PR conversation with a prominent **Open preview** link and the built commit, or the owner-approval instructions when deployment is blocked. It reuses the comment without writing when the text is unchanged, and only edits comments owned by `github-actions[bot]` with the publisher’s hidden marker. A preview from an older successful build is labelled explicitly. Closing or merging a PR removes its directory and changes its existing comment to **Preview closed** after deployment succeeds; pending comment cleanup persists across failed deployments and retries. Reopening the PR lets the next successful deployment reuse the comment.

A **Pages preview** check on each current authorized, built PR commit also includes an **Open preview** link in its summary. A failed main build preserves production; a failed PR build preserves its previous authorized preview. Review both the ordinary `build` check and the built commit before assessing a preview. Previews are public and share the Pages origin with the main dashboard. Authorizing a preview means trusting its browser code: read-only data does not stop JavaScript from misleading users or reading same-origin browser storage and files a user imports into that page. The authorization gate is not a browser sandbox. The trusted publisher has `pull-requests: write` for comments and acknowledgment reactions; PR build jobs retain read-only permissions.
Expand Down
53 changes: 49 additions & 4 deletions scripts/pages_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,45 @@ def download(self, candidate):
f"repos/{self.repository}/actions/artifacts/{candidate['artifact_id']}/zip"])


def install(site, candidate, source):
def preview_html(data, pr):
"""Apply the current preview appearance to new or retained static pages."""
number = int(pr["number"])
page = data.decode("utf-8")
marker = "quickdash-dev-preview"
page = re.sub(f"<!-- {marker} -->.*?<!-- /{marker} -->", "", page, flags=re.S)
page, titles = re.subn(r"(<title\b[^>]*>)(?:\[DEV PREVIEW\] PR #\d+ · )?",
lambda match: f"{match[1]}[DEV PREVIEW] PR #{number} · ",
page, count=1, flags=re.I)
if titles != 1:
raise ValueError("Preview HTML needs a document title")
banner = (f'<aside id="{marker}" aria-label="Development preview">'
f'<strong>DEV PREVIEW</strong><span>Unmerged changes · '
f'<a href="{html.escape(pr["html_url"], quote=True)}">PR #{number}</a></span>'
'<a class="preview-main" href="../../">Main dashboard ↗</a></aside>')
# Escape '<' in the JSON string so embedded markup cannot end the script.
encoded = json.dumps(banner).replace("<", "\\u003c")
decoration = '''<style>
html.quickdash-dev-preview{background:#fff7ed}
html.quickdash-dev-preview body>header{background:#71340f}
#quickdash-dev-preview{position:sticky;top:0;z-index:1000;display:flex;align-items:center;flex-wrap:wrap;gap:10px 20px;padding:13px 4vw;background:#fef3c7;color:#71340f;border-bottom:4px solid #d97706;font:14px system-ui;box-shadow:0 2px 8px #71340f20}
#quickdash-dev-preview strong{background:#71340f;color:#fff;padding:5px 9px;border-radius:4px;letter-spacing:.08em;font-size:13px}
#quickdash-dev-preview a{color:#71340f;font-weight:650;text-decoration:underline;text-underline-offset:3px}
#quickdash-dev-preview .preview-main{margin-left:auto}
@media(max-width:600px){#quickdash-dev-preview{padding:10px 14px;gap:8px 12px}#quickdash-dev-preview .preview-main{margin-left:0}}
</style><script>
document.documentElement.classList.add('quickdash-dev-preview');
document.addEventListener('DOMContentLoaded',()=>document.body.insertAdjacentHTML('afterbegin',BANNER));
</script>'''.replace("BANNER", encoded)
decoration = f"<!-- {marker} -->{decoration}<!-- /{marker} -->"
return re.sub(r"</title\s*>", lambda match: match[0] + decoration,
page, count=1, flags=re.I).encode("utf-8")


def install(site, candidate, source, pr=None):
files = read_bundle(source.download(candidate))
if pr:
files = {name: preview_html(data, pr) if name.endswith(".html") else data
for name, data in files.items()}
site.mkdir(parents=True, exist_ok=True)
for name, data in files.items():
(site / name).write_bytes(data)
Expand Down Expand Up @@ -213,12 +250,20 @@ def reconcile(site, source):
if previous and not source.authorization(pr, previous["sha"])["allowed"]:
shutil.rmtree(preview_dir / ("pr-" + number))
del state["previews"][number]
if not access["allowed"]:
continue
try:
if number in state["previews"]:
# Refresh retained previews too, without depending on their
# build artifacts still being available or a new PR commit.
paths = [preview_dir / ("pr-" + number) / name for name in ("index.html", "demo.html")]
decorated = [preview_html(path.read_bytes(), pr) for path in paths]
for path, data in zip(paths, decorated):
if path.read_bytes() != data:
path.write_bytes(data)
if not access["allowed"]:
continue
candidate = source.latest_build(pr)
if candidate and not same_build(state["previews"].get(number), candidate):
state["previews"][number] = install(preview_dir / ("pr-" + number), candidate, source)
state["previews"][number] = install(preview_dir / ("pr-" + number), candidate, source, pr)
except ValueError as error:
# A malformed PR bundle must not prevent production/other PRs publishing.
warnings.append(f"PR #{number}: {error}")
Expand Down
49 changes: 38 additions & 11 deletions tests/test_pages_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@
from quickdash.io import parse_yaml


def page(label, name="index.html"):
return f"<!doctype html><title>{label}</title><header>{label}:{name}</header>".encode()


def archive(label="dashboard", extra=None, files=None):
tar = io.BytesIO()
with tarfile.open(fileobj=tar, mode="w") as out:
root = tarfile.TarInfo(".")
root.type = tarfile.DIRTYPE
out.addfile(root)
for name in pages.SITE_FILES:
data = (files or {}).get(name, (label + ":" + name).encode())
data = (files or {}).get(name, page(label, name) if name.endswith('.html') else (label + ":" + name).encode())
member = tarfile.TarInfo("./" + name)
member.size = len(data)
out.addfile(member, io.BytesIO(data))
Expand Down Expand Up @@ -64,6 +68,29 @@ def authorization(self, pr, sha=None):


class PagesPreview(unittest.TestCase):
def test_preview_decoration_is_visible_idempotent_and_preserves_content(self):
original = page('Dashboard &amp; demo')
rendered = pages.preview_html(original, pull(11)).decode()
self.assertIn('<title>[DEV PREVIEW] PR #11 · Dashboard &amp; demo</title>', rendered)
self.assertIn('DEV PREVIEW', rendered)
self.assertIn('https://github.com/org/repo/pull/11', rendered)
self.assertIn('Main dashboard', rendered)
self.assertIn('<header>Dashboard &amp; demo:index.html</header>', rendered)
self.assertEqual(pages.preview_html(rendered.encode(), pull(11)), rendered.encode())

def test_existing_previews_get_decoration_without_downloading_expired_artifacts(self):
source = Source()
with tempfile.TemporaryDirectory() as tmp:
site = Path(tmp)
pages.reconcile(site, source)
(site / 'pr-preview/pr-11/index.html').write_bytes(page('Stored preview'))
source.previews = {}
with patch.object(source, 'download') as download:
pages.reconcile(site, source)
download.assert_not_called()
self.assertIn('[DEV PREVIEW]', (site / 'pr-preview/pr-11/index.html').read_text())
self.assertEqual((site / 'index.html').read_bytes(), page('production'))

def test_owners_are_explicit_nonempty_logins(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "OWNERS"
Expand Down Expand Up @@ -121,7 +148,7 @@ def test_new_push_keeps_only_previously_approved_snapshot_until_new_approval(sel
source.previews[11] = build(12, later, "new")
state = pages.reconcile(site, source)
self.assertEqual(state["previews"]["11"]["sha"], sha)
self.assertEqual((site / "pr-preview/pr-11/index.html").read_text(), "approved:index.html")
self.assertIn("<header>approved:index.html</header>", (site / "pr-preview/pr-11/index.html").read_text())
self.assertFalse(state["preview_access"]["11"]["allowed"])
approvals.append({"id": 21, "body": "/deploy " + later, "user": {"login": "trusted"}})
state = pages.reconcile(site, source)
Expand Down Expand Up @@ -156,26 +183,26 @@ def test_reconcile_updates_main_and_previews_preserves_failed_builds_and_prunes_
with tempfile.TemporaryDirectory() as tmp:
site = Path(tmp)
first = pages.reconcile(site, source)
self.assertEqual((site / "index.html").read_text(), "production:index.html")
self.assertEqual((site / "pr-preview/pr-12/index.html").read_text(), "fork:index.html")
self.assertEqual((site / "index.html").read_text(), page("production").decode())
self.assertIn("<header>fork:index.html</header>", (site / "pr-preview/pr-12/index.html").read_text())
self.assertEqual(set(first["previews"]), {"11", "12"})

# A main deploy preserves previews whose latest build is pending/failed/expired.
source.main = build(20, label="new production")
source.previews = {}
source.open[0]["head"]["sha"] = "pending"
pages.reconcile(site, source)
self.assertEqual((site / "index.html").read_text(), "new production:index.html")
self.assertEqual((site / "pr-preview/pr-11/index.html").read_text(), "first:index.html")
self.assertEqual((site / "index.html").read_text(), page("new production").decode())
self.assertIn("<header>first:index.html</header>", (site / "pr-preview/pr-11/index.html").read_text())
self.assertIn("Previous successful build", (site / "pr-preview/index.html").read_text())

# Every event reconciles all open PRs, regardless of which run triggered it.
source.main = None
source.open = [pull(11, "next", title="<script>alert(1)</script>")]
source.previews = {11: build(30, "next", "updated")}
final = pages.reconcile(site, source)
self.assertEqual((site / "index.html").read_text(), "new production:index.html")
self.assertEqual((site / "pr-preview/pr-11/index.html").read_text(), "updated:index.html")
self.assertEqual((site / "index.html").read_text(), page("new production").decode())
self.assertIn("<header>updated:index.html</header>", (site / "pr-preview/pr-11/index.html").read_text())
self.assertFalse((site / "pr-preview/pr-12").exists())
self.assertEqual(final["previews"]["11"]["sha"], "next")
self.assertNotIn("<script>", (site / "pr-preview/index.html").read_text())
Expand All @@ -188,7 +215,7 @@ def test_old_main_runs_cannot_roll_back_production(self):
pages.reconcile(site, source)
source.main = build(9, label="old")
pages.reconcile(site, source)
self.assertEqual((site / "index.html").read_text(), "production:index.html")
self.assertEqual((site / "index.html").read_text(), page("production").decode())

def test_bad_preview_cannot_replace_main_or_block_other_previews(self):
source = Source()
Expand All @@ -198,7 +225,7 @@ def test_bad_preview_cannot_replace_main_or_block_other_previews(self):
state = pages.reconcile(site, source)
self.assertNotIn("11", state["previews"])
self.assertIn("12", state["previews"])
self.assertEqual((site / "index.html").read_text(), "production:index.html")
self.assertEqual((site / "index.html").read_text(), page("production").decode())
self.assertIn("Awaiting successful build", (site / "pr-preview/index.html").read_text())

def test_first_publish_requires_a_successful_main_artifact(self):
Expand Down Expand Up @@ -361,7 +388,7 @@ def git(*args, cwd=None):
self.assertEqual((checkout / "source.txt").read_text(), "source must remain")
git("worktree", "remove", str(site), cwd=checkout)
pages.prepare_worktree(site)
self.assertEqual((site / "index.html").read_text(), "production:index.html")
self.assertEqual((site / "index.html").read_text(), page("production").decode())
pages.save_worktree(site) # An unchanged snapshot needs no new commit.
finally:
os.chdir(previous)
Expand Down
15 changes: 15 additions & 0 deletions tests/test_public_browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,25 @@ reconcile(Path(sys.argv[3]), source)
assert.match(await evaluate('document.body.textContent'),/Current/);
const previewLinks=await evaluate("({main:document.querySelector('a[href=\"../index.html\"]').href,pr:document.querySelector('a[href=\"pr-11/index.html\"]').href})");
await navigate(previewLinks.pr);
assert.match(await evaluate('document.title'),/^\[DEV PREVIEW\] PR #11 · /);
assert.equal(await evaluate("document.querySelectorAll('#quickdash-dev-preview').length"),1);
assert.match(await evaluate("document.querySelector('#quickdash-dev-preview').textContent"),/DEV PREVIEW.*Unmerged changes.*PR #11.*Main dashboard/);
assert.equal(await evaluate("document.querySelector('#quickdash-dev-preview a').href"),'https://github.com/org/repo/pull/11');
assert.equal(await evaluate("new URL(document.querySelector('#quickdash-dev-preview .preview-main').href).pathname"),new URL(previewLinks.main).pathname.replace(/index.html$/,''));
const previewHeader=await evaluate("getComputedStyle(document.querySelector('header')).backgroundColor");
await evaluate('window.scrollTo(0,500)');
assert.equal(await evaluate("document.querySelector('#quickdash-dev-preview').getBoundingClientRect().top"),0);
await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:false});
assert.equal(await evaluate("document.querySelector('#quickdash-dev-preview').scrollWidth<=innerWidth"),true);
await send('Emulation.clearDeviceMetricsOverride');
await evaluate('window.scrollTo(0,0)');
assert.equal(await evaluate("document.querySelector('#modelA').value"),'Example B');
assert.equal(await evaluate("document.querySelector('#modelB').value"),'Alternative A');
assert.equal(await evaluate("document.querySelector('#cards .score-card:last-child strong').textContent"),'2.50');
await navigate(previewLinks.main);
assert.doesNotMatch(await evaluate('document.title'),/DEV PREVIEW/);
assert.equal(await evaluate("document.querySelector('#quickdash-dev-preview')"),null);
assert.notEqual(await evaluate("getComputedStyle(document.querySelector('header')).backgroundColor"),previewHeader);
assert.equal(await evaluate("document.querySelector('#modelA').value"),'Example A');
assert.equal(await evaluate("document.querySelector('#modelB').value"),'Example B');
assert.equal(await evaluate("document.querySelector('#cards .score-card:last-child strong').textContent"),'-2.50');
Expand Down