diff --git a/.github/scripts/build_diff_payload.py b/.github/scripts/build_diff_payload.py index b050e4a0..2c8189ff 100644 --- a/.github/scripts/build_diff_payload.py +++ b/.github/scripts/build_diff_payload.py @@ -1,10 +1,24 @@ """Build the JSON payload sent to GraphRAG-UI's /api/admin/update-graph. Invoked from .github/workflows/update-graph.yml after a push to main: -reads BASE_SHA + HEAD_SHA from env, computes the .mdx diff, reads file -content for added+modified entries, and writes payload.json. Sets the +reads BASE_SHA + HEAD_SHA from env, computes the ingestable diff, reads +file content for added+modified entries, and writes payload.json. Sets the ``skip`` step output to ``true`` when nothing ingestable changed so the workflow can short-circuit before the network call. + +Two extension sets, not one. ``INGEST_SUFFIXES`` is what the graph ingests +today; ``TRACKED_SUFFIXES`` is everything that might already exist in the +graph as a document. The Mintlify migration (#544) moved 151 files from +``.md`` to ``.mdx`` while this script tested a single suffix on both sides +of a rename, so every delete-half silently vanished: the payload read +``+190 ~0 -0`` and the 202 pre-migration documents are still in the live +graph with no way for any future push to evict them. A rename *out of* the +tracked set is a deletion even when the destination is not ingestable. + +Nothing here silently drops a file any more. An unreadable blob, an +unreachable base commit, or an oversized payload fails the step with an +actionable message, because a partial payload publishes as a success and +the loss is only visible much later, in retrieval. """ from __future__ import annotations @@ -19,6 +33,49 @@ # carries an all-zero ``before`` (i.e., first push to a brand-new branch). EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" +# Suffixes whose *content* the graph ingests. Added/modified files outside +# this set are not sent at all. +INGEST_SUFFIXES = (".mdx",) + +# Suffixes that may exist as a :Document in the graph, so a deletion or a +# rename-away has to be reported. Superset of INGEST_SUFFIXES: `.md` files +# were ingested for the three months before the Mintlify migration. +TRACKED_SUFFIXES = (".md", ".mdx") + +# The whole payload is one request body with every changed file's content +# inline. 190 files came to 1.1 MB; this cap exists so a pathological diff +# fails here, with a number, instead of as an opaque proxy error after the +# upload has already run for minutes. +MAX_PAYLOAD_BYTES = 25 * 1024 * 1024 + + +def _log(kind: str, message: str) -> None: + """Emit a GitHub Actions annotation on stderr.""" + print(f"::{kind}::{message}", file=sys.stderr) + + +def _is_ingestable(path: str) -> bool: + return path.endswith(INGEST_SUFFIXES) + + +def _is_tracked(path: str) -> bool: + return path.endswith(TRACKED_SUFFIXES) + + +def _commit_exists(sha: str) -> bool: + """True when ``sha`` names a commit object present in this clone. + + ``github.event.before`` points at whatever main used to be, and after a + force-push or a history rewrite that commit may not exist any more. + Without this check ``git diff`` exits non-zero and the step dies on a + raw CalledProcessError traceback. + """ + proc = subprocess.run( + ["git", "cat-file", "-e", f"{sha}^{{commit}}"], + capture_output=True, text=True, check=False, + ) + return proc.returncode == 0 + def _git_diff_name_status(base: str, head: str) -> str: """Return the raw ``git diff --name-status`` output between two SHAs.""" @@ -28,6 +85,21 @@ def _git_diff_name_status(base: str, head: str) -> str: ).stdout +def _ingestable_paths_at(head: str) -> list[str]: + """Every ingestable path in the tree at ``head``. + + Sent alongside the diff as a manifest so the server can reconcile the + graph's document set against what the repo actually contains. A + diff-only protocol cannot repair a deletion it never heard about — and + it has already had to. + """ + out = subprocess.run( + ["git", "ls-tree", "-r", "--name-only", head], + capture_output=True, text=True, check=True, + ).stdout + return sorted(p for p in out.splitlines() if _is_ingestable(p)) + + def _read_at(head: str, path: str) -> str | None: """Read a file's content at a specific commit, regardless of what's currently checked out in the working tree. @@ -36,8 +108,8 @@ def _read_at(head: str, path: str) -> str | None: object store. Reading from disk via ``pathlib`` would only work if the runner had already checked out ``head``; this is more robust and lets the script be exercised locally against historical - commits without checking them out first. Returns None if the path - doesn't exist at ``head`` (e.g. rare rename edge cases). + commits without checking them out first. Returns None when the blob + is missing, which the caller treats as fatal. """ proc = subprocess.run( ["git", "show", f"{head}:{path}"], @@ -48,51 +120,66 @@ def _read_at(head: str, path: str) -> str | None: return proc.stdout -def _collect_md_changes( +def _collect_changes( diff_output: str, head: str, -) -> tuple[dict[str, str], dict[str, str], list[str]]: - """Parse ``git diff --name-status`` and bucket .mdx changes. +) -> tuple[dict[str, str], dict[str, str], list[str], list[str]]: + """Parse ``git diff --name-status`` and bucket the changes. - Renames (``R``) are split into delete-old + add-new so the SDK - re-extracts the content under the new path. Non-.mdx files are - skipped. File content for added/modified entries is read from - the git object store at ``head``, not from disk. + Returns ``(added, modified, deleted, unreadable)``. Renames and copies + carry two paths; every other status carries one. ``unreadable`` names + the paths whose blob could not be read — the caller fails on those + rather than shipping a payload that is quietly missing a document. """ added: dict[str, str] = {} modified: dict[str, str] = {} deleted: list[str] = [] + unreadable: list[str] = [] + + def _take(path: str, bucket: dict[str, str]) -> None: + content = _read_at(head, path) + if content is None: + unreadable.append(path) + return + bucket[path] = content for line in diff_output.splitlines(): parts = line.split("\t") - if not parts: + if len(parts) < 2: continue - status = parts[0][0] # strip rename similarity score, e.g. R100 → R + status = parts[0][0] # strip rename/copy similarity score, e.g. R100 → R - if status == "R" and len(parts) >= 3: + # R (rename) and C (copy) name a source and a destination. A rename + # moves the document: the old path is gone from the graph and the + # new one has to be extracted fresh. A copy leaves the source in + # place, so only the destination is new. + if status in ("R", "C") and len(parts) >= 3: old, new = parts[1], parts[2] - if old.endswith(".mdx"): + if status == "R" and _is_tracked(old): deleted.append(old) - if new.endswith(".mdx"): - content = _read_at(head, new) - if content is not None: - added[new] = content + if _is_ingestable(new): + _take(new, added) continue - if len(parts) < 2 or not parts[1].endswith(".mdx"): - continue path = parts[1] - if status == "A": - content = _read_at(head, path) - if content is not None: - added[path] = content - elif status == "M": - content = _read_at(head, path) - if content is not None: - modified[path] = content - elif status == "D": - deleted.append(path) + if status == "D": + # Deletions use the tracked set, not the ingestable one: a + # pre-migration .md document still needs evicting. + if _is_tracked(path): + deleted.append(path) + elif status == "A": + if _is_ingestable(path): + _take(path, added) + elif status in ("M", "T"): + # T is a typechange (symlink ↔ regular file). The content at + # head is what matters either way. + if _is_ingestable(path): + _take(path, modified) + elif status == "U": + # Unmerged paths mean the runner is looking at a conflicted + # tree, which should never reach a push to main. + _log("warning", f"Ignoring unmerged path: {path}") - return added, modified, deleted + return added, modified, deleted, unreadable def _set_output(name: str, value: str) -> None: @@ -107,24 +194,72 @@ def _set_output(name: str, value: str) -> None: def main() -> int: base = os.environ["BASE_SHA"] head = os.environ["HEAD_SHA"] + if set(base) == {"0"}: + # First push to a brand-new branch: everything in the tree is new. base = EMPTY_TREE_SHA + elif not _commit_exists(base): + _log( + "error", + f"Base commit {base} is not in this clone — main was probably " + "force-pushed. Refusing to guess a diff: re-run the workflow " + "with a base that exists, or reconcile the graph with a full " + "rebuild.", + ) + return 1 diff = _git_diff_name_status(base, head) - added, modified, deleted = _collect_md_changes(diff, head) + added, modified, deleted, unreadable = _collect_changes(diff, head) + + if unreadable: + shown = ", ".join(unreadable[:10]) + _log( + "error", + f"Could not read {len(unreadable)} file(s) at {head}: {shown}. " + "Refusing to send a payload that is missing documents — the run " + "would report success and the graph would silently lack them.", + ) + return 1 if not (added or modified or deleted): - print("::notice::No .mdx changes — skipping graph update.", file=sys.stderr) + _log("notice", "No ingestable changes — skipping graph update.") _set_output("skip", "true") return 0 + manifest = _ingestable_paths_at(head) payload = { "graph_id": os.environ.get("GRAPH_ID", "docs_benchmark"), + "head_sha": head, "files": {"added": added, "modified": modified, "deleted": deleted}, + # Full ingestable inventory at head. Servers that predate manifest + # support ignore the field; ones that support it reconcile against + # it, which is what makes a missed deletion recoverable. + "manifest": manifest, } - pathlib.Path("payload.json").write_text(json.dumps(payload), encoding="utf-8") - print(f"::notice::Diff: +{len(added)} ~{len(modified)} -{len(deleted)} files") + + body = json.dumps(payload) + size = len(body.encode("utf-8")) + if size > MAX_PAYLOAD_BYTES: + _log( + "error", + f"Payload is {size} bytes, over the {MAX_PAYLOAD_BYTES} byte cap. " + "Split the change across pushes or move to a content-by-reference " + "payload.", + ) + return 1 + + pathlib.Path("payload.json").write_text(body, encoding="utf-8") + _log( + "notice", + f"Diff: +{len(added)} ~{len(modified)} -{len(deleted)} files; " + f"manifest {len(manifest)} files; payload {size} bytes", + ) _set_output("skip", "false") + _set_output("added", str(len(added))) + _set_output("modified", str(len(modified))) + _set_output("deleted", str(len(deleted))) + _set_output("manifest", str(len(manifest))) + _set_output("bytes", str(size)) return 0 diff --git a/.github/scripts/test_build_diff_payload.py b/.github/scripts/test_build_diff_payload.py new file mode 100644 index 00000000..87395ec3 --- /dev/null +++ b/.github/scripts/test_build_diff_payload.py @@ -0,0 +1,254 @@ +"""Tests for build_diff_payload.py. + +The bug that motivated these: a rename from ``.md`` to ``.mdx`` reported the +addition and dropped the deletion, so 202 pre-migration documents are still +in the live docs graph. ``test_a_rename_out_of_md_reports_both_halves`` is +that exact case and fails against the pre-fix script. + +Status parsing is tested against synthetic ``git diff --name-status`` text — +statuses like C (copy) and T (typechange) are impractical to provoke +reliably from a real repo. Everything that involves git itself (base +resolution, the manifest, end-to-end payload shape) runs against a real +temporary repository. + +Run: pytest .github/scripts/test_build_diff_payload.py +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent)) + +import build_diff_payload as bdp # noqa: E402 + + +# ── helpers ───────────────────────────────────────────────────────────────── + +def _git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=repo, capture_output=True, text=True, check=True, + ).stdout.strip() + + +def _commit(repo: Path, message: str) -> str: + _git(repo, "add", "-A") + _git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", message) + return _git(repo, "rev-parse", "HEAD") + + +@pytest.fixture() +def repo(tmp_path: Path) -> Path: + """A git repo with one committed .md page and one .mdx page.""" + r = tmp_path / "docs" + r.mkdir() + _git(r, "init", "-q", "-b", "main") + (r / "guides").mkdir() + (r / "guides" / "intro.md").write_text("intro body\n", encoding="utf-8") + (r / "guides" / "already.mdx").write_text("already mdx\n", encoding="utf-8") + (r / "logo.png").write_bytes(b"\x89PNG\r\n") + _commit(r, "initial") + return r + + +@pytest.fixture() +def run_main(monkeypatch, tmp_path): + """Call ``main()`` inside ``repo`` and return (exit_code, payload, outputs).""" + def _run(repo: Path, base: str, head: str) -> tuple[int, dict | None, dict]: + out_file = tmp_path / "gh_output" + out_file.write_text("", encoding="utf-8") + monkeypatch.chdir(repo) + monkeypatch.setenv("BASE_SHA", base) + monkeypatch.setenv("HEAD_SHA", head) + monkeypatch.setenv("GITHUB_OUTPUT", str(out_file)) + monkeypatch.setenv("GRAPH_ID", "docs_benchmark") + + code = bdp.main() + + outputs = dict( + line.split("=", 1) + for line in out_file.read_text(encoding="utf-8").splitlines() + if "=" in line + ) + payload_path = repo / "payload.json" + payload = ( + json.loads(payload_path.read_text(encoding="utf-8")) + if payload_path.exists() + else None + ) + return code, payload, outputs + + return _run + + +@pytest.fixture() +def parse(monkeypatch): + """Parse synthetic name-status text with blob reads stubbed out.""" + monkeypatch.setattr(bdp, "_read_at", lambda head, path: f"body of {path}") + + def _parse(*lines: str): + return bdp._collect_changes("\n".join(lines), "HEAD") + + return _parse + + +# ── the regression that cost 202 documents ────────────────────────────────── + +def test_a_rename_out_of_md_reports_both_halves(parse): + added, modified, deleted, unreadable = parse("R091\tguides/intro.md\tguides/intro.mdx") + + assert list(added) == ["guides/intro.mdx"], "new path must be ingested" + assert deleted == ["guides/intro.md"], ( + "the old .md document must be evicted — dropping this half is what " + "left 202 stale documents in the live graph" + ) + assert not modified and not unreadable + + +def test_a_rename_out_of_the_tracked_set_still_deletes(parse): + """Renamed to something the graph does not ingest: pure eviction.""" + added, _, deleted, _ = parse("R100\tguides/intro.mdx\tsnippets/intro.txt") + + assert deleted == ["guides/intro.mdx"] + assert added == {} + + +def test_a_plain_md_deletion_is_reported(parse): + _, _, deleted, _ = parse("D\tReferences/license.md") + + assert deleted == ["References/license.md"] + + +def test_an_md_addition_is_not_ingested(parse): + """`.md` is tracked for deletion but is no longer ingested content.""" + added, modified, deleted, _ = parse("A\tAGENTS.md", "M\tREADME.md") + + assert added == {} and modified == {} and deleted == [] + + +# ── status coverage ───────────────────────────────────────────────────────── + +def test_a_copy_adds_the_destination_and_keeps_the_source(parse): + added, _, deleted, _ = parse("C075\tguides/intro.mdx\tguides/intro-copy.mdx") + + assert list(added) == ["guides/intro-copy.mdx"] + assert deleted == [], "a copy leaves the source in place" + + +def test_a_typechange_counts_as_modified(parse): + _, modified, _, _ = parse("T\tguides/intro.mdx") + + assert list(modified) == ["guides/intro.mdx"] + + +def test_non_document_files_are_ignored(parse): + added, modified, deleted, _ = parse( + "A\tlogo.png", "M\tdocs.json", "D\t_includes/head.html", + ) + + assert added == {} and modified == {} and deleted == [] + + +def test_an_unreadable_blob_is_collected_not_skipped(monkeypatch): + monkeypatch.setattr(bdp, "_read_at", lambda head, path: None) + + added, _, _, unreadable = bdp._collect_changes("A\tguides/new.mdx", "HEAD") + + assert added == {}, "a file that cannot be read must not look ingested" + assert unreadable == ["guides/new.mdx"] + + +# ── main(): base resolution and failure surfaces ──────────────────────────── + +def test_the_migration_shape_end_to_end(repo, run_main): + """Rename .md → .mdx plus a fresh page: the #544 diff in miniature.""" + base = _git(repo, "rev-parse", "HEAD") + _git(repo, "mv", "guides/intro.md", "guides/intro.mdx") + (repo / "guides" / "new.mdx").write_text("brand new\n", encoding="utf-8") + head = _commit(repo, "migrate to mdx") + + code, payload, outputs = run_main(repo, base, head) + + assert code == 0 + assert sorted(payload["files"]["added"]) == ["guides/intro.mdx", "guides/new.mdx"] + assert payload["files"]["deleted"] == ["guides/intro.md"] + assert payload["files"]["added"]["guides/new.mdx"] == "brand new\n" + assert outputs["skip"] == "false" + assert outputs["deleted"] == "1" + + +def test_the_payload_carries_a_full_manifest(repo, run_main): + base = _git(repo, "rev-parse", "HEAD") + (repo / "guides" / "new.mdx").write_text("brand new\n", encoding="utf-8") + head = _commit(repo, "add a page") + + _, payload, outputs = run_main(repo, base, head) + + assert payload["manifest"] == [ + "guides/already.mdx", "guides/new.mdx", + ], "every ingestable path at head, not just the changed ones" + assert payload["head_sha"] == head + assert outputs["manifest"] == "2" + + +def test_an_unreachable_base_fails_instead_of_crashing(repo, run_main): + """Force-push case: the old main tip is no longer in the clone.""" + head = _git(repo, "rev-parse", "HEAD") + missing = "0" * 39 + "1" # well-formed, not an object + + code, payload, outputs = run_main(repo, missing, head) + + assert code == 1 + assert payload is None, "no payload may be written from a guessed diff" + assert outputs == {} + + +def test_an_all_zero_base_diffs_against_the_empty_tree(repo, run_main): + head = _git(repo, "rev-parse", "HEAD") + + code, payload, _ = run_main(repo, "0" * 40, head) + + assert code == 0 + assert sorted(payload["files"]["added"]) == ["guides/already.mdx"] + + +def test_nothing_ingestable_sets_skip(repo, run_main): + base = _git(repo, "rev-parse", "HEAD") + (repo / "logo.png").write_bytes(b"\x89PNG\r\n\x00") + head = _commit(repo, "touch an image") + + code, payload, outputs = run_main(repo, base, head) + + assert code == 0 + assert payload is None + assert outputs["skip"] == "true" + + +def test_an_unreadable_file_fails_the_step(repo, run_main, monkeypatch): + base = _git(repo, "rev-parse", "HEAD") + (repo / "guides" / "new.mdx").write_text("brand new\n", encoding="utf-8") + head = _commit(repo, "add a page") + monkeypatch.setattr(bdp, "_read_at", lambda h, p: None) + + code, payload, outputs = run_main(repo, base, head) + + assert code == 1 + assert payload is None + assert outputs == {} + + +def test_an_oversized_payload_fails_the_step(repo, run_main, monkeypatch): + base = _git(repo, "rev-parse", "HEAD") + (repo / "guides" / "big.mdx").write_text("x" * 4096, encoding="utf-8") + head = _commit(repo, "add a big page") + monkeypatch.setattr(bdp, "MAX_PAYLOAD_BYTES", 512) + + code, payload, _ = run_main(repo, base, head) + + assert code == 1 + assert payload is None, "an oversized payload must not be written" diff --git a/.github/workflows/update-graph-check.yml b/.github/workflows/update-graph-check.yml new file mode 100644 index 00000000..260f4baf --- /dev/null +++ b/.github/workflows/update-graph-check.yml @@ -0,0 +1,69 @@ +# Validates the graph-update payload on the pull request, before merge. +# +# update-graph.yml only runs after a push to main, so until now the first +# time anyone saw what would be sent was after it had already been sent. +# The Mintlify migration is the case in point: its payload was +# "+190 ~0 -0" — 190 additions and not one of the 151 renamed or 112 +# deleted pages — and that number existed nowhere a reviewer could see it. +# This job builds the same payload and prints those counts on the PR. +# +# It never calls the server. No secrets, no side effects. + +name: Update graph (payload check) + +on: + pull_request: + paths: + - "**/*.mdx" + - "**/*.md" + - ".github/scripts/build_diff_payload.py" + - ".github/workflows/update-graph.yml" + - ".github/workflows/update-graph-check.yml" + +jobs: + check-payload: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout docs + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # base and head both need to be present + + - name: Test the payload builder + run: | + set -euo pipefail + python3 -m pip install --quiet --user pytest + python3 -m pytest .github/scripts/test_build_diff_payload.py -q + + - name: Build the payload this PR would send + id: payload + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python3 .github/scripts/build_diff_payload.py + + - name: Summarise + if: always() + run: | + set -uo pipefail + { + echo "### Payload this PR would send to the docs graph" + echo + if [ "${{ steps.payload.outputs.skip }}" = "true" ]; then + echo "No ingestable changes — the update would be skipped." + else + echo "| | |" + echo "|---|---|" + echo "| Added | ${{ steps.payload.outputs.added }} |" + echo "| Modified | ${{ steps.payload.outputs.modified }} |" + echo "| Deleted | ${{ steps.payload.outputs.deleted }} |" + echo "| Manifest | ${{ steps.payload.outputs.manifest }} files at head |" + echo "| Payload size | ${{ steps.payload.outputs.bytes }} bytes |" + echo + echo "Renamed or removed pages should appear under **Deleted**." + echo "A large **Added** count with **Deleted** at 0 means the old" + echo "documents will stay in the graph." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/update-graph.yml b/.github/workflows/update-graph.yml index 58df4d0a..42ed4742 100644 --- a/.github/workflows/update-graph.yml +++ b/.github/workflows/update-graph.yml @@ -1,5 +1,5 @@ -# Incrementally updates the FalkorDB docs knowledge graph whenever .mdx -# files change on main. Computes the diff against the previous HEAD, +# Incrementally updates the FalkorDB docs knowledge graph whenever a +# document changes on main. Computes the diff against the previous HEAD, # POSTs it to GraphRAG-UI's /api/admin/update-graph endpoint, which does # the SDK ingestion + smoke test + atomic alias flip server-side. @@ -10,9 +10,18 @@ on: branches: - main paths: + # .mdx is what gets ingested; .md is here so that *deletions* of + # pre-migration pages still trigger a run. Without it a commit that + # only removes .md files never reaches the graph and those documents + # stay in it permanently. - "**/*.mdx" + - "**/*.md" + - ".github/scripts/build_diff_payload.py" -# `github.ref_name` is "main"; all pushes to main share one queue. +# `github.ref_name` is "main"; all pushes to main share one queue. Note +# this only serialises the *CI jobs*: the server keeps working after this +# job exits, so two runs can still overlap server-side. The real guard has +# to live in the endpoint. concurrency: group: update-graph-${{ github.ref_name }} cancel-in-progress: false @@ -40,12 +49,76 @@ jobs: run: python3 .github/scripts/build_diff_payload.py - name: Call admin update-graph endpoint + id: call if: steps.payload.outputs.skip != 'true' + env: + # Via env rather than inline `${{ secrets... }}`: interpolating a + # secret into the script text puts it in the process table and in + # anything that echoes the command. + UPDATE_GRAPH_TOKEN: ${{ secrets.UPDATE_GRAPH_TOKEN }} run: | - curl -X POST "$GRAPHRAG_UI_URL/api/admin/update-graph" \ - -H "Authorization: Bearer ${{ secrets.UPDATE_GRAPH_TOKEN }}" \ + set -euo pipefail + + if [ -z "${GRAPHRAG_UI_URL:-}" ]; then + echo "::error::GRAPHRAG_UI_URL repository variable is not set" >&2 + exit 1 + fi + if [ -z "${UPDATE_GRAPH_TOKEN:-}" ]; then + echo "::error::UPDATE_GRAPH_TOKEN secret is not set" >&2 + exit 1 + fi + + # --max-time is not the real ceiling: the platform closes this + # connection at ~300s while the server keeps ingesting, so a + # timeout here means "outcome unknown", not "did not run". A 190 + # file diff took 2h23m server-side and published long after this + # job had gone red. Do not add --retry: without an idempotency + # key on the endpoint, a retry starts a second full ingest. + code=$(curl -X POST "$GRAPHRAG_UI_URL/api/admin/update-graph" \ + -H "Authorization: Bearer $UPDATE_GRAPH_TOKEN" \ -H "Content-Type: application/json" \ --data-binary @payload.json \ - --fail-with-body \ + --no-progress-meter \ --show-error \ - --max-time 1800 + --max-time 1800 \ + -o response.json -w '%{http_code}') || code=000 + + echo "http_code=$code" >> "$GITHUB_OUTPUT" + echo "$code" > http_code.txt + if [ "$code" != "200" ]; then + echo "::error::update-graph returned HTTP $code" >&2 + exit 1 + fi + + - name: Report outcome + if: always() && steps.payload.outputs.skip != 'true' + run: | + set -uo pipefail + code=$(cat http_code.txt 2>/dev/null || echo "000") + { + echo "### Docs graph update" + echo + echo "| | |" + echo "|---|---|" + echo "| Commit | \`${GITHUB_SHA}\` |" + echo "| Diff | +${{ steps.payload.outputs.added }} ~${{ steps.payload.outputs.modified }} -${{ steps.payload.outputs.deleted }} |" + echo "| Manifest | ${{ steps.payload.outputs.manifest }} files |" + echo "| Payload | ${{ steps.payload.outputs.bytes }} bytes |" + echo "| HTTP | \`${code}\` |" + echo + if [ "$code" = "200" ]; then + echo "Published:" + elif [ "$code" = "000" ]; then + echo "**No response.** The connection closed before the server answered." + echo "The run may still be in progress or may have published — check the" + echo "server log for \`Alias flipped\` before re-running, because a re-run" + echo "starts a second full ingest." + else + echo "**Failed.** Server said:" + fi + echo + echo '```json' + head -c 2000 response.json 2>/dev/null || echo '(no response body)' + echo + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 9a711bba..3b14f710 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ node_modules/ .mintlify/ .mint/ package-lock.json + +# Python bytecode from .github/scripts +__pycache__/ +*.pyc