diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 9db4872..96ce8d7 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -60,19 +60,8 @@ jobs: - uses: actions/upload-pages-artifact@v5 with: path: output/site - - deploy: - if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - needs: build - runs-on: ubuntu-24.04 - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - uses: actions/configure-pages@v6 - - name: Publish the checked dashboard - id: deployment - uses: actions/deploy-pages@v5 + retention-days: 30 + - name: Explain PR preview publishing + if: github.event_name == 'pull_request' + run: | + echo 'The Pages publisher will reconcile this build after checks complete. A **Pages preview** check links the deployed page from the PR.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish-pages.yml b/.github/workflows/publish-pages.yml new file mode 100644 index 0000000..4d9406d --- /dev/null +++ b/.github/workflows/publish-pages.yml @@ -0,0 +1,63 @@ +name: Publish dashboard and PR previews + +on: + workflow_run: + workflows: [Check and publish Quickdash] + types: [completed] + pull_request_target: + types: [closed] + workflow_dispatch: + +permissions: + contents: read + +# Reconcile all open PRs on every run: Actions may coalesce pending runs. +concurrency: + group: quickdash-pages-publish + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + pages: write + id-token: write + checks: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + # This privileged workflow only executes default-branch code. PR artifacts + # are read as data, including when the completed build came from a fork. + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.repository.default_branch }} + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - uses: actions/configure-pages@v6 + id: pages + - name: Assemble production and open PR previews + env: + GH_TOKEN: ${{ github.token }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }} + run: | + python3 scripts/pages_preview.py assemble --site "$RUNNER_TEMP/quickdash-pages" \ + --default-branch "$DEFAULT_BRANCH" --base-url "$PAGES_BASE_URL" + - uses: actions/upload-pages-artifact@v5 + with: + path: ${{ runner.temp }}/quickdash-pages + - name: Publish dashboard and previews + id: deployment + uses: actions/deploy-pages@v5 + - name: Link deployed previews from PR checks + env: + GH_TOKEN: ${{ github.token }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }} + run: | + python3 scripts/pages_preview.py report --site "$RUNNER_TEMP/quickdash-pages" \ + --default-branch "$DEFAULT_BRANCH" --base-url "$PAGES_BASE_URL" diff --git a/README.md b/README.md index 5777281..55e919a 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ Files opened here stay in your browser; they are not uploaded or included in lin - Edit or add a self-contained eval file in [configs/evals/](configs/evals/), such as [polymath.yaml](configs/evals/polymath.yaml). Each file holds its scoring rules and language assignments together; the catalogue combines them automatically. - Add weighting profiles to [configs/weights/](configs/weights/), or optional named eval sets to [configs/sets/](configs/sets/). Each directory has a `default.txt` choosing its startup selection. See [contributing configs](configs/README.md). -Use a pull request or GitHub’s **Add file → Upload files**. Changes on `main` trigger tests and a GitHub Pages rebuild; pull requests are checked without publishing. Invalid inputs stop the update and leave the last successful site online. The repository and dashboard are public, so use browser imports for private comparisons. +Use a pull request or GitHub’s **Add file → Upload files**. Changes on `main` trigger tests and a GitHub Pages rebuild. Successful PR builds get a separate review page: open **Details** on the PR’s **Pages preview** check, or use the [preview index](https://openeurollm.github.io/quickdash/pr-preview/). Previews update after successful builds and are removed when the PR closes. Invalid inputs leave the last successful page online. The repository, dashboard, and PR previews are public, so use browser imports for private comparisons. -The [Pages workflow](.github/workflows/pages.yml) publishes only the generated dashboard, fictional demo, and licenses. Repository maintainers configure **Settings → Pages → Source → GitHub Actions**. Check the repository’s **Actions** tab if an update fails to appear. +The [build workflow](.github/workflows/pages.yml) checks and packages the dashboard, fictional demo, and licenses. The [Pages publisher](.github/workflows/publish-pages.yml) combines the main dashboard with previews at `pr-preview/pr-/`. Repository maintainers configure **Settings → Pages → Source → GitHub Actions**. Check the repository’s **Actions** tab if an update fails to appear; see [publishing and previews](docs/development.md#publish-through-github-pages). ## Build a standalone file diff --git a/docs/development.md b/docs/development.md index 39b608b..bb88181 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,7 +8,8 @@ Run commands from the repository root. Install the Python package with `python - | --- | --- | | `quickdash/` | Native Python interpretation, analysis, diagnostics, and CLI. | | `app/` | Python builder, DOM-independent JavaScript engine (`analysis.js`), browser renderer (`app.js`), HTML, and bundled YAML parser. | -| `tests/` | Public contract tests, browser checks, and optional private-export regressions. | +| `tests/` | Public contract tests, browser checks, publishing tests, and optional private-export regressions. | +| `scripts/` | Assemble and report GitHub Pages production and PR preview deployments. | | `configs/` | Catalogue manifest, self-contained `evals/` files, weighting profiles, optional named sets, and fictional examples. | | `results/` | Public CSV exports contributed to the shared dashboard. | | `examples/` | Public sample export for Pages and parity tests, plus small fictional quickstart data. | @@ -108,8 +109,30 @@ This optional check compares included rows, weights, contributions, scores and d The [workflow](../.github/workflows/pages.yml) runs public tests on pull requests and pushes to `main`. The Python loader and Node filesystem helper both assemble the per-eval files declared by `configs/catalogue.yaml`. Shared tests check assembly, language ownership, file loading, and portable export/import as well as scoring. -After tests pass, it builds the shared dashboard and a separate fictional demo. When `results/` has no CSVs, the shared page embeds `examples/sample-evals.csv`; real shared CSVs take precedence. The sample also runs through both engines in CI for all shipped weighting profiles, eval sets, and aggregation modes, with complete and mismatched coverage. See [contributor requirements](../AGENTS.md). Only `output/site/` is uploaded as the Pages artifact: `index.html`, `demo.html`, and license files. The repository root and private local output are not published as the site. +After tests pass, it builds the shared dashboard and a separate fictional demo. When `results/` has no CSVs, the shared page embeds `examples/sample-evals.csv`; real shared CSVs take precedence. The sample also runs through both engines in CI for all shipped weighting profiles, eval sets, and aggregation modes, with complete and mismatched coverage. See [contributor requirements](../AGENTS.md). Only `output/site/` is uploaded as the build artifact: `index.html`, `demo.html`, and license files. Build artifacts are retained for 30 days. The repository root and private local output are not published as the site. -In repository **Settings → Pages**, select **GitHub Actions** as the source. Publishing uses the generated artifact rather than a checked-in root or `docs/` folder. A successful push to `main` deploys automatically; a failed build leaves the last successful site available. Review build or deployment failures in the repository’s **Actions** tab. +In repository **Settings → Pages**, select **GitHub Actions** as the source. Keep the `github-pages` environment restricted to `main`. The [publisher workflow](../.github/workflows/publish-pages.yml) runs after build completions, when a PR closes, or through **Run workflow**. It executes the publisher script from the default branch and uses successful build artifacts as static data. It never checks out or executes PR code with publishing permissions, including for fork PRs. The build workflow keeps read-only repository access. + +The publisher deploys one combined site: + +- `/quickdash/` serves the latest successful main build. +- `/quickdash/pr-preview/pr-/` 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. + +After deployment succeeds, a **Pages preview** check on each current built PR commit links directly to its preview. No PR comments are posted. Closing or merging a PR removes its directory on the next publisher run. A failed main build preserves production; a failed PR build preserves its previous 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. + +Generated files and their source run/commit identifiers persist on the `pages-content` branch, created automatically by the first publisher run. This is storage for the Actions publisher; **do not change Pages to deploy from this branch**. The stored files keep published previews available after their source artifacts expire. Every publisher run reconciles all open PRs and successful main builds, and publishing is serialized so concurrent builds cannot overwrite each other’s previews. Run **Publish dashboard and PR previews** manually to retry a failed deployment or reconcile missed updates. The workflow must be on `main` before automatic previews can run; existing successful artifacts can be picked up during that first deployment. + +Only the four expected regular files are accepted from each build artifact, with a 100 MiB limit on each archive layer and its total file content. Unexpected paths, duplicate files, links, and malformed archives are rejected without extracting their paths. Invalid PR artifacts do not prevent production or other previews from publishing. Hidden Git/state files are excluded from the final Pages artifact. + +To rehearse assembly with current GitHub artifacts, without pushing a branch, deploying Pages, or writing PR checks: + +```sh +python3 scripts/pages_preview.py inspect --repository OpenEuroLLM/quickdash \ + --site output/preview-rehearsal --base-url https://openeurollm.github.io/quickdash +open output/preview-rehearsal/pr-preview/index.html +``` + +This requires an authenticated GitHub CLI (`gh`). The publisher uses Python’s standard library and `gh`; no package installation is required. `python3 -m tests.check` includes archive validation, snapshot updates, fork build identification, stale-build handling, cleanup, and preview-check regression tests. Review build or deployment failures in the repository’s **Actions** tab. Before pushing, run the affected tests and check the README, config reference, and contribution instructions for changed commands or behavior. Contributions to [results](../results/README.md) and [configs](../configs/README.md) are validated by the same workflow. diff --git a/scripts/pages_preview.py b/scripts/pages_preview.py new file mode 100644 index 0000000..6c8a1af --- /dev/null +++ b/scripts/pages_preview.py @@ -0,0 +1,268 @@ +"""Assemble Pages from checked static artifacts; never execute PR content.""" +import argparse +from datetime import datetime, timezone +import html +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +from urllib.parse import urlencode +import zipfile + +SITE_FILES = ("index.html", "demo.html", "LICENSE", "js-yaml.LICENSE") +STATE_FILE = ".preview-state.json" +STATE_BRANCH = "pages-content" +# The current dashboard is about 14 MiB. Bound both archive layers before reading. +MAX_BYTES = 100 * 1024 * 1024 +BUILD_FIELDS = ("run_id", "run_attempt", "sha", "artifact_id", "run_url") + + +def read_bundle(data): + """Read the upload-pages-artifact ZIP/TAR without extracting archive paths.""" + try: + if len(data) > MAX_BYTES: + raise ValueError("Pages archive exceeds 100 MiB") + with zipfile.ZipFile(io.BytesIO(data)) as zipped: + entries = zipped.infolist() + if len(entries) != 1 or entries[0].filename != "artifact.tar": + raise ValueError("Expected only artifact.tar in Pages ZIP") + if entries[0].file_size > MAX_BYTES: + raise ValueError("Pages TAR exceeds 100 MiB") + payload = zipped.read(entries[0]) + files = {} + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:") as tar: + total = 0 + for member in tar: + if member.name == "." and member.isdir(): + continue + name = member.name[2:] if member.name.startswith("./") else member.name + if name not in SITE_FILES or name in files or not member.isfile(): + raise ValueError("Unexpected, duplicate, or non-regular Pages file: " + member.name) + total += member.size + if member.size < 0 or total > MAX_BYTES: + raise ValueError("Pages files exceed 100 MiB") + files[name] = tar.extractfile(member).read() + if set(files) != set(SITE_FILES): + raise ValueError("Pages artifact is missing required files") + return files + except (zipfile.BadZipFile, tarfile.TarError, EOFError, OSError) as error: + raise ValueError("Invalid Pages archive") from error + + +class GitHub: + def __init__(self, repository, default_branch): + self.repository = repository + self.default_branch = default_branch + + def get(self, path): + endpoint = f"repos/{self.repository}" + ("/" + path if path else "") + return json.loads(subprocess.check_output(["gh", "api", endpoint])) + + def write(self, method, path, data): + subprocess.run(["gh", "api", "--method", method, f"repos/{self.repository}/{path}", + "--input", "-", "--silent"], input=json.dumps(data), text=True, check=True) + + def pulls(self): + results = [] + page = 1 + while True: + batch = self.get(f"pulls?state=open&per_page=100&page={page}") + results.extend(batch) + if len(batch) < 100: + return results + page += 1 + + def latest_build(self, pr=None): + filters = dict(status="success", per_page=100) + if pr is None: + filters["branch"] = self.default_branch + repo_id = self.get("")["id"] + events = ("push", "workflow_dispatch") + else: + if pr["head"]["repo"] is None: + return None + filters["head_sha"] = pr["head"]["sha"] + repo_id = pr["head"]["repo"]["id"] + events = ("pull_request",) + candidates = [] + for event in events: + query = urlencode(dict(filters, event=event)) + runs = self.get("actions/workflows/pages.yml/runs?" + query)["workflow_runs"] + for run in runs: + if (run["event"] != event or run["conclusion"] != "success" + or (run.get("head_repository") or {}).get("id") != repo_id): + continue + if pr is not None: + if run["head_sha"] != pr["head"]["sha"] or run["head_branch"] != pr["head"]["ref"]: + continue + elif run["head_branch"] != self.default_branch: + continue + # workflow_run.pull_requests can be empty for forks and its head SHA + # can change after a later push. The run's own head_sha is immutable. + candidates.append(run) + if not candidates: + return None + run = max(candidates, key=lambda item: (item["id"], item.get("run_attempt", 1))) + artifacts = self.get(f"actions/runs/{run['id']}/artifacts?per_page=100")["artifacts"] + artifacts = [a for a in artifacts if a["name"] == "github-pages" and not a["expired"]] + if not artifacts: + return None # Keep the previously published snapshot when artifacts expire. + if len(artifacts) != 1 or artifacts[0]["size_in_bytes"] > MAX_BYTES: + raise ValueError("Invalid Pages artifact inventory") + return dict(run_id=run["id"], run_attempt=run.get("run_attempt", 1), sha=run["head_sha"], + artifact_id=artifacts[0]["id"], run_url=run["html_url"]) + + def download(self, candidate): + return subprocess.check_output(["gh", "api", + f"repos/{self.repository}/actions/artifacts/{candidate['artifact_id']}/zip"]) + + +def install(site, candidate, source): + files = read_bundle(source.download(candidate)) + site.mkdir(parents=True, exist_ok=True) + for name, data in files.items(): + (site / name).write_bytes(data) + return {key: candidate[key] for key in BUILD_FIELDS} + + +def same_build(previous, candidate): + return previous and all(previous[key] == candidate[key] for key in BUILD_FIELDS) + + +def reconcile(site, source): + """Reconcile every open PR so coalesced Actions events cannot lose updates.""" + site.mkdir(parents=True, exist_ok=True) + state_path = site / STATE_FILE + state = json.loads(state_path.read_text()) if state_path.exists() else {"main": None, "previews": {}} + main = source.latest_build() + previous = state["main"] + if main and (not previous or (main["run_id"], main["run_attempt"]) >= (previous["run_id"], previous["run_attempt"])): + if not same_build(previous, main): + state["main"] = install(site, main, source) + if not state["main"] or not all((site / name).is_file() for name in SITE_FILES): + raise ValueError("A successful main build artifact is required before publishing previews") + + pulls = source.pulls() + open_numbers = {str(pr["number"]) for pr in pulls} + preview_dir = site / "pr-preview" + preview_dir.mkdir(exist_ok=True) + for number in list(state["previews"]): + if number not in open_numbers: + shutil.rmtree(preview_dir / ("pr-" + number)) + del state["previews"][number] + warnings = [] + for pr in pulls: + number = str(pr["number"]) + try: + 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) + except ValueError as error: + # A malformed PR bundle must not prevent production/other PRs publishing. + warnings.append(f"PR #{number}: {error}") + + rows = [] + for pr in sorted(pulls, key=lambda item: item["number"]): + number = str(pr["number"]) + preview = state["previews"].get(number) + title = html.escape(pr["title"]) + label = f'#{number}: {title}' + if preview: + status = "Current" if preview["sha"] == pr["head"]["sha"] else "Previous successful build" + link = f'Open preview' + provenance = f'{preview["sha"][:12]}' + else: + status, link, provenance = "Awaiting successful build", "—", "—" + rows.append(f"{label}{link}{status}{provenance}") + page = ''' +Quickdash PR previews +

Quickdash PR previews

Main dashboard

+

Previews update after successful builds. A previous successful build may be shown while a newer commit is pending or failing. +The commit link identifies the build being served. Closed PRs are removed.

+''' + (preview_dir / "index.html").write_text(page + "".join(rows) + "
Pull requestPreviewStatusBuilt commit
\n") + state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n") + for warning in warnings: + print(warning) + return state + + +def report_checks(state, api, base_url): + """Attach the deployed URL to the PR's commit as a check, without PR comments.""" + for pr in api.pulls(): + number = str(pr["number"]) + preview = state["previews"].get(number) + if not preview or preview["sha"] != pr["head"]["sha"]: + continue + url = f"{base_url.rstrip('/')}/pr-preview/pr-{number}/" + external_id = "quickdash-preview-" + number + existing = api.get(f"commits/{preview['sha']}/check-runs?check_name=Pages%20preview&filter=latest&per_page=100")["check_runs"] + existing = next((c for c in existing if c.get("external_id") == external_id), None) + payload = dict(name="Pages preview", external_id=external_id, status="completed", conclusion="success", + completed_at=datetime.now(timezone.utc).isoformat(), + details_url=url, output=dict(title=f"Preview for PR #{number}", + summary=f"[Open preview]({url}) · [Source build]({preview['run_url']})\n\nBuilt commit: `{preview['sha']}`. This URL follows the latest successful build and is removed when the PR closes.")) + if existing: + api.write("PATCH", f"check-runs/{existing['id']}", payload) + else: + api.write("POST", "check-runs", dict(payload, head_sha=preview["sha"])) + + +def git(*args, **kwargs): + return subprocess.run(["git", *args], check=True, **kwargs) + + +def prepare_worktree(site): + branch = subprocess.run(["git", "ls-remote", "--exit-code", "--heads", "origin", STATE_BRANCH], + capture_output=True, text=True) + if branch.returncode == 0: + git("fetch", "origin", f"refs/heads/{STATE_BRANCH}:refs/remotes/origin/{STATE_BRANCH}") + git("worktree", "add", "--detach", str(site), "origin/" + STATE_BRANCH) + elif branch.returncode == 2: + git("worktree", "add", "--detach", str(site), "HEAD") + git("-C", str(site), "switch", "--orphan", STATE_BRANCH) + else: + raise RuntimeError("Could not read Pages state branch: " + branch.stderr) + + +def save_worktree(site): + git("-C", str(site), "add", "--all") + changed = subprocess.check_output(["git", "-C", str(site), "status", "--porcelain"]) + if changed: + git("-C", str(site), "-c", "user.name=github-actions[bot]", "-c", + "user.email=41898282+github-actions[bot]@users.noreply.github.com", "commit", "-m", "Update dashboard and PR previews") + git("-C", str(site), "push", "origin", f"HEAD:refs/heads/{STATE_BRANCH}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("assemble", "report", "inspect")) + parser.add_argument("--site", type=Path, required=True) + parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY"), required=not os.environ.get("GITHUB_REPOSITORY")) + parser.add_argument("--default-branch", default="main") + parser.add_argument("--base-url", required=True) + args = parser.parse_args() + api = GitHub(args.repository, args.default_branch) + if args.command == "report": + report_checks(json.loads((args.site / STATE_FILE).read_text()), api, args.base_url) + else: + if args.command == "assemble": + prepare_worktree(args.site) + state = reconcile(args.site, api) + if args.command == "assemble": + save_worktree(args.site) + print(json.dumps(state, indent=2)) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a") as output: + output.write(f"[PR preview index]({args.base_url.rstrip('/')}/pr-preview/)\n\n") + for number, preview in sorted(state["previews"].items()): + output.write(f"- [PR #{number}]({args.base_url.rstrip('/')}/pr-preview/pr-{number}/) — `{preview['sha'][:12]}`\n") + + +if __name__ == "__main__": + main() diff --git a/tests/check.py b/tests/check.py index a5056f8..466ffc7 100644 --- a/tests/check.py +++ b/tests/check.py @@ -67,7 +67,7 @@ def main(): # Browser tests invoke python3; use this command's Python environment for builds. os.environ['PATH'] = str(Path(sys.executable).parent) + os.pathsep + os.environ.get('PATH', '') subprocess.run([sys.executable, '-m', 'unittest', 'tests.test_check', - 'tests.test_data', 'tests.test_engines'], cwd=ROOT, check=True) + 'tests.test_data', 'tests.test_engines', 'tests.test_pages_preview'], cwd=ROOT, check=True) subprocess.run(['node', '--test'] + ['tests/' + name for name in NODE_TESTS], cwd=ROOT, check=True) run_browser(chrome) print('All public checks passed, including the browser.') diff --git a/tests/test_pages_preview.py b/tests/test_pages_preview.py new file mode 100644 index 0000000..a454c66 --- /dev/null +++ b/tests/test_pages_preview.py @@ -0,0 +1,224 @@ +"""Publishing preserves production and isolates static PR artifacts.""" +import io +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +from scripts import pages_preview as pages +from quickdash.io import parse_yaml + + +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()) + member = tarfile.TarInfo("./" + name) + member.size = len(data) + out.addfile(member, io.BytesIO(data)) + if extra: + out.addfile(extra) + zipped = io.BytesIO() + with zipfile.ZipFile(zipped, "w") as out: + out.writestr("artifact.tar", tar.getvalue()) + return zipped.getvalue() + + +def build(run_id, sha="abc", label="dashboard"): + return dict(run_id=run_id, run_attempt=1, sha=sha, artifact_id=run_id, + run_url="https://github.com/org/repo/actions/runs/" + str(run_id), + bundle=archive(label)) + + +def pull(number, sha="abc", title="Example", repo_id=1): + return dict(number=number, title=title, html_url=f"https://github.com/org/repo/pull/{number}", + head=dict(sha=sha, ref="feature", repo=dict(id=repo_id))) + + +class Source: + def __init__(self): + self.main = build(10, label="production") + self.open = [pull(11), pull(12, repo_id=2)] + self.previews = {11: build(11, label="first"), 12: build(12, label="fork")} + + def pulls(self): + return self.open + + def latest_build(self, pr=None): + return self.main if pr is None else self.previews.get(pr["number"]) + + def download(self, candidate): + return candidate["bundle"] + + +class PagesPreview(unittest.TestCase): + def test_repository_endpoint_has_no_trailing_slash(self): + with patch.object(pages.subprocess, "check_output", return_value=b'{"id":1}') as command: + self.assertEqual(pages.GitHub("org/repo", "main").get("")["id"], 1) + self.assertEqual(command.call_args.args[0], ["gh", "api", "repos/org/repo"]) + + def test_only_expected_regular_files_can_be_published(self): + self.assertEqual(set(pages.read_bundle(archive())), set(pages.SITE_FILES)) + for name in ("../index.html", "/index.html", "pr-preview/pr-1/index.html", ".git/config", "./index.html"): + with self.subTest(name=name), self.assertRaises(ValueError): + pages.read_bundle(archive(extra=tarfile.TarInfo(name))) + link = tarfile.TarInfo("link") + link.type = tarfile.SYMTYPE + link.linkname = "index.html" + with self.assertRaises(ValueError): + pages.read_bundle(archive(extra=link)) + with patch.object(pages, "MAX_BYTES", 10), self.assertRaises(ValueError): + pages.read_bundle(archive()) + with self.assertRaises(ValueError): + pages.read_bundle(b"not a zip") + zipped = io.BytesIO() + with zipfile.ZipFile(zipped, "w") as out: + out.writestr("unexpected", b"data") + with self.assertRaises(ValueError): + pages.read_bundle(zipped.getvalue()) + + def test_reconcile_updates_main_and_previews_preserves_failed_builds_and_prunes_closed(self): + source = Source() + 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(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.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="")] + 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.assertFalse((site / "pr-preview/pr-12").exists()) + self.assertEqual(final["previews"]["11"]["sha"], "next") + self.assertNotIn("')] +source.previews = {11: source.previews[11]} +source.main['bundle'] = archive(files={'index.html': Path(sys.argv[1]).read_bytes()}) +source.previews[11]['bundle'] = archive(files={'index.html': Path(sys.argv[2]).read_bytes()}) +reconcile(Path(sys.argv[3]), source) + `,path.join(shared,'index.html'),path.join(preferred,'index.html'),previewSite],{cwd:root,stdio:'pipe'}); + await send('Page.navigate',{url:pathToFileURL(path.join(previewSite,'pr-preview/index.html')).href}); + for(let i=0;i<50;i++){ + if(await evaluate("document.title==='Quickdash PR previews' && !!document.querySelector('tbody tr')"))break; + await new Promise(r=>setTimeout(r,50)); + } + assert.equal(await evaluate('document.title'),'Quickdash PR previews'); + assert.equal(await evaluate('window.injected===undefined'),true); + 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.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.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'); + await navigate(pathToFileURL(path.join(preferred,'index.html')).href); assert.ok(await evaluate("[...document.querySelector('#modelA').options].some(o=>o.value==='Example A')")); await click('#swap');await click('[data-view=categories]'); assert.equal(await evaluate("document.querySelector('#modelA').value"),'Alternative A');