From e115bdda9ad0f62ffab65ee5a7c857b549b0f2fa Mon Sep 17 00:00:00 2001 From: Shane Copenhagen Date: Fri, 4 Sep 2026 08:47:37 -0600 Subject: [PATCH 1/2] feat(deps): report vendored dependency drift from upstream Add bin/vendored_dependency.py status, which asks the GitHub compare API how many commits each pinned upstream branch has moved past the commit recorded in UPSTREAM.yaml, and publish the table in the CI job summary. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yaml | 14 +- README.md | 2 +- bin/tests/test_vendored_dependency.py | 260 ++++++++++++++++++++++++++ bin/vendored_dependency.py | 179 ++++++++++++++++++ 4 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 bin/tests/test_vendored_dependency.py create mode 100755 bin/vendored_dependency.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2d59bba4b..6113d4e6c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -831,6 +831,8 @@ jobs: validate-workspace-dependencies: name: Validate workspace dependencies runs-on: ubuntu-22.04 + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -843,11 +845,21 @@ jobs: - name: Install dependency policy test requirements run: python3 -m pip install pytest==9.0.3 - name: Test dependency policy validator - run: python3 -m pytest bin/tests/test_validate_workspace_dependencies.py -v + run: python3 -m pytest bin/tests -v - name: Validate dependency policy env: GITHUB_TOKEN: ${{ github.token }} run: python3 bin/validate_workspace_dependencies.py --verify-upstream + # Drift is a report, not a gate. A failed lookup exits 1 and shows "?" + # in its row; pipefail lets that reach continue-on-error, which marks + # the step with a warning instead of failing the PR. + - name: Report vendored dependency drift + continue-on-error: true + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -o pipefail + python3 bin/vendored_dependency.py status --markdown | tee -a "$GITHUB_STEP_SUMMARY" validate_objectives: runs-on: ubuntu-22.04 diff --git a/README.md b/README.md index 5ca5d0602..0527a836d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,6 @@ The hardware-only `kinova_gen3_site_config` and `picknik_ur_site_config` configu ## Updating vendored dependencies -Each `UPSTREAM.yaml` file under `src/external_dependencies` records the exact upstream commit and retained paths. Refresh a dependency from that commit, preserve its license files, reapply the documented pruning, and validate every config that consumes the package. +Each `UPSTREAM.yaml` file under `src/external_dependencies` records the exact upstream commit and retained paths. Run `bin/vendored_dependency.py status` to see how many commits each pinned upstream branch has moved past its recorded commit; CI publishes the same table in the job summary of the `Validate workspace dependencies` job. Refresh a dependency from that commit, preserve its license files, reapply the documented pruning, and validate every config that consumes the package. The optional ML model submodules can be advanced independently when their demonstration Objectives need a newer model package. diff --git a/bin/tests/test_vendored_dependency.py b/bin/tests/test_vendored_dependency.py new file mode 100644 index 000000000..828375e59 --- /dev/null +++ b/bin/tests/test_vendored_dependency.py @@ -0,0 +1,260 @@ +"""Tests for the vendored dependency drift report.""" + +import importlib.util +import io +import json +from pathlib import Path +from urllib.error import HTTPError +from urllib.request import Request + +from pytest import CaptureFixture, MonkeyPatch, mark + +MODULE_PATH = Path(__file__).resolve().parents[1] / "vendored_dependency.py" +MODULE_SPEC = importlib.util.spec_from_file_location("vendored_dependency", MODULE_PATH) +assert MODULE_SPEC is not None +assert MODULE_SPEC.loader is not None +tool = importlib.util.module_from_spec(MODULE_SPEC) +MODULE_SPEC.loader.exec_module(tool) + +REPOSITORY = "https://github.com/example/repository.git" +COMMIT = "0123456789abcdef0123456789abcdef01234567" + + +class FakeResponse(io.BytesIO): + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +def mock_compare(monkeypatch: MonkeyPatch, payload: object) -> list[Request]: + """Replace urlopen with a canned compare response and record the requests.""" + requests: list[Request] = [] + + def fake_urlopen(request: Request, **_: object) -> FakeResponse: + requests.append(request) + return FakeResponse(json.dumps(payload).encode()) + + monkeypatch.setattr(tool, "urlopen", fake_urlopen) + return requests + + +def test_fetch_drift_compares_pin_against_branch(monkeypatch: MonkeyPatch) -> None: + """Ask GitHub for the commits on the branch that the pinned commit lacks.""" + monkeypatch.setenv("GITHUB_TOKEN", "token") + requests = mock_compare(monkeypatch, {"ahead_by": 4, "behind_by": 0}) + assert tool.fetch_drift(REPOSITORY, COMMIT, "main") == (4, None) + assert requests[0].full_url == ( + f"https://api.github.com/repos/example/repository/compare/{COMMIT}...main" + ) + assert requests[0].get_header("Authorization") == "Bearer token" + + +def test_fetch_drift_encodes_branch_names(monkeypatch: MonkeyPatch) -> None: + """A slash in a branch name must not read as a path separator.""" + requests = mock_compare(monkeypatch, {"ahead_by": 0, "behind_by": 0}) + assert tool.fetch_drift(REPOSITORY, COMMIT, "release/1.0") == (0, None) + assert requests[0].full_url.endswith(f"/compare/{COMMIT}...release%2F1.0") + + +def test_fetch_drift_reports_pin_off_branch(monkeypatch: MonkeyPatch) -> None: + """A pin with commits the branch lacks is not on that branch.""" + mock_compare(monkeypatch, {"ahead_by": 4, "behind_by": 2}) + behind, error = tool.fetch_drift(REPOSITORY, COMMIT, "main") + assert behind is None + assert error == f"pinned commit {COMMIT} is not on upstream branch main" + + +def test_fetch_drift_reports_http_failure(monkeypatch: MonkeyPatch) -> None: + """Surface the HTTP status so a missing branch or bad token is recognizable.""" + + def fail(request: Request, **_: object) -> FakeResponse: + raise HTTPError(request.full_url, 404, "Not Found", {}, None) # type: ignore[arg-type] + + monkeypatch.setattr(tool, "urlopen", fail) + behind, error = tool.fetch_drift(REPOSITORY, COMMIT, "main") + assert behind is None + assert error is not None + assert "main" in error + assert "404" in error + + +def test_fetch_drift_rejects_oversized_response(monkeypatch: MonkeyPatch) -> None: + """Stop reading past the response cap instead of parsing an unbounded body.""" + monkeypatch.setattr(tool, "COMPARE_MAX_RESPONSE_BYTES", 4) + mock_compare(monkeypatch, {"ahead_by": 4, "behind_by": 0}) + assert tool.fetch_drift(REPOSITORY, COMMIT, "main") == ( + None, + "upstream compare response exceeds the size limit", + ) + + +@mark.parametrize( + "payload", + [ + {"ahead_by": "many"}, + {"ahead_by": True, "behind_by": False}, + {"ahead_by": 4}, + ["not", "a", "dict"], + ], +) +def test_fetch_drift_rejects_malformed_payload( + monkeypatch: MonkeyPatch, payload: object +) -> None: + mock_compare(monkeypatch, payload) + assert tool.fetch_drift(REPOSITORY, COMMIT, "main") == ( + None, + "upstream compare response is malformed", + ) + + +def mock_manifests( + monkeypatch: MonkeyPatch, tmp_path: Path, drift: dict[str, int | str] +) -> None: + """Create one manifest per source and answer drift lookups from a table.""" + manifests: list[Path] = [] + for source in drift: + root = tmp_path / "src" / "external_dependencies" / source + root.mkdir(parents=True) + (root / "UPSTREAM.yaml").write_text( + "upstream:\n" + f" repository: {REPOSITORY}\n" + f" commit: {COMMIT}\n" + f" branch: {source}-branch\n" + "vendored_paths:\n - description\n", + encoding="utf-8", + ) + manifests.append(root / "UPSTREAM.yaml") + monkeypatch.setattr(tool.validator, "REPOSITORY_ROOT", tmp_path) + monkeypatch.setattr( + tool.validator, "discover_vendoring_manifests", lambda: (manifests, []) + ) + monkeypatch.setattr(tool.validator, "validate_vendor_manifest", lambda _: []) + + def fake_fetch(*arguments: str) -> tuple[int | None, str | None]: + result = drift[arguments[2].removesuffix("-branch")] + return (result, None) if isinstance(result, int) else (None, result) + + monkeypatch.setattr(tool, "fetch_drift", fake_fetch) + + +def test_status_lists_most_drifted_first( + tmp_path: Path, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str] +) -> None: + mock_manifests(monkeypatch, tmp_path, {"alpha": 2, "beta": 89, "gamma": 0}) + assert tool.main(["status"]) == 0 + assert capsys.readouterr().out == ( + "Vendored source Pinned commit Upstream branch Commits behind\n" + "beta 012345678 beta-branch 89\n" + "alpha 012345678 alpha-branch 2\n" + "gamma 012345678 gamma-branch 0\n" + ) + + +def test_status_markdown_is_a_step_summary( + tmp_path: Path, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str] +) -> None: + mock_manifests(monkeypatch, tmp_path, {"alpha": 2}) + assert tool.main(["status", "--markdown"]) == 0 + assert capsys.readouterr().out == ( + "## Vendored dependency drift\n\n" + "| Vendored source | Pinned commit | Upstream branch | Commits behind |\n" + "|---|---|---|---:|\n" + "| alpha | 012345678 | alpha-branch | 2 |\n" + ) + + +def test_status_markdown_escapes_table_syntax( + monkeypatch: MonkeyPatch, capsys: CaptureFixture[str] +) -> None: + """A pipe or backslash in a cell must not add columns to the summary table.""" + monkeypatch.setattr( + tool, + "collect_drift", + lambda: [tool.Drift("src/external_dependencies/a|b", COMMIT, "rel\\x|y", 2)], + ) + assert tool.main(["status", "--markdown"]) == 0 + assert capsys.readouterr().out.splitlines()[-1] == ( + "| a\\|b | 012345678 | rel\\\\x\\|y | 2 |" + ) + + +def test_status_writes_github_output_when_requested( + tmp_path: Path, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str] +) -> None: + """A workflow keys on these outputs rather than parsing the rendered table.""" + output = tmp_path / "github_output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + mock_manifests(monkeypatch, tmp_path, {"alpha": 0, "beta": 3, "gamma": "boom"}) + assert tool.main(["status"]) == 1 + capsys.readouterr() + assert output.read_text() == "drifted=true\nresolved=2\nunresolved=1\n" + output.unlink() + mock_manifests(monkeypatch, tmp_path / "second", {"alpha": 0}) + assert tool.main(["status"]) == 0 + assert output.read_text() == "drifted=false\nresolved=1\nunresolved=0\n" + + +def test_status_reports_discovery_and_manifest_errors( + tmp_path: Path, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str] +) -> None: + """Broken manifests take the error column instead of hiding behind the table.""" + mock_manifests(monkeypatch, tmp_path, {"alpha": 1}) + manifest = tmp_path / "src" / "external_dependencies" / "alpha" / "UPSTREAM.yaml" + scalar = tmp_path / "src" / "external_dependencies" / "scalar" / "UPSTREAM.yaml" + scalar.parent.mkdir() + scalar.write_text("vendored_paths:\n - description\n") + real_parse = tool.validator.parse_vendor_manifest + monkeypatch.setattr( + tool.validator, + "parse_vendor_manifest", + lambda path: {"upstream": "nope"} if path == scalar else real_parse(path), + ) + discovery_error = ( + "vendored source has no UPSTREAM.yaml: src/external_dependencies/empty" + ) + monkeypatch.setattr( + tool.validator, + "discover_vendoring_manifests", + lambda: ([manifest, scalar], [discovery_error]), + ) + monkeypatch.setattr( + tool.validator, + "validate_vendor_manifest", + lambda path: ["bad manifest"] if path == manifest else [], + ) + assert tool.main(["status"]) == 1 + captured = capsys.readouterr() + assert captured.err == ( + f"ERROR: src/external_dependencies {discovery_error}\n" + "ERROR: src/external_dependencies/alpha bad manifest\n" + "ERROR: src/external_dependencies/scalar has invalid upstream metadata\n" + ) + assert captured.out.count("?") == 3 + + +def test_status_with_no_vendored_sources( + tmp_path: Path, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str] +) -> None: + """An empty inventory still reports its verdict to a workflow.""" + output = tmp_path / "github_output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + monkeypatch.setattr( + tool.validator, "discover_vendoring_manifests", lambda: ([], []) + ) + assert tool.main(["status"]) == 0 + assert capsys.readouterr().out == "No vendored dependencies found.\n" + assert output.read_text() == "drifted=false\nresolved=0\nunresolved=0\n" + + +def test_status_fails_when_a_lookup_fails( + tmp_path: Path, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str] +) -> None: + """A failed lookup still prints the table but exits non-zero, last in the table.""" + mock_manifests(monkeypatch, tmp_path, {"alpha": 2, "beta": "boom"}) + assert tool.main(["status"]) == 1 + captured = capsys.readouterr() + assert captured.out.splitlines()[-1].startswith("beta") + assert captured.out.splitlines()[-1].endswith("?") + assert captured.err == "ERROR: src/external_dependencies/beta boom\n" diff --git a/bin/vendored_dependency.py b/bin/vendored_dependency.py new file mode 100755 index 000000000..61cafd862 --- /dev/null +++ b/bin/vendored_dependency.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Report how far each vendored dependency has drifted from its upstream branch.""" + +from dataclasses import dataclass +from pathlib import Path +import argparse +import json +import os +import sys +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlparse +from urllib.request import Request, urlopen + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import validate_workspace_dependencies as validator # noqa: E402 + +COMPARE_TIMEOUT_SECONDS = 30 +COMPARE_MAX_RESPONSE_BYTES = 16 * 1024 * 1024 +TABLE_HEADER = ("Vendored source", "Pinned commit", "Upstream branch", "Commits behind") + + +@dataclass(frozen=True) +class Drift: + """Drift of one vendored source relative to its pinned upstream branch.""" + + source: str + commit: str = "" + branch: str = "" + behind: int | None = None + error: str | None = None + + +def fetch_drift( + repository: str, commit: str, branch: str +) -> tuple[int | None, str | None]: + """Return how many commits the upstream branch has beyond the pinned commit.""" + repository_path = urlparse(repository).path.removeprefix("/").removesuffix(".git") + basehead = f"{quote(commit, safe='')}...{quote(branch, safe='')}" + url = f"https://api.github.com/repos/{repository_path}/compare/{basehead}" + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "moveit-pro-workspace-vendored-dependency", + "X-GitHub-Api-Version": "2022-11-28", + } + if token := os.environ.get("GITHUB_TOKEN"): + headers["Authorization"] = f"Bearer {token}" + try: + with urlopen( + Request(url, headers=headers), timeout=COMPARE_TIMEOUT_SECONDS + ) as response: + payload = response.read(COMPARE_MAX_RESPONSE_BYTES + 1) + except (HTTPError, URLError, OSError, TimeoutError, ValueError) as error: + return None, f"could not compare against upstream branch {branch}: {error}" + if len(payload) > COMPARE_MAX_RESPONSE_BYTES: + return None, "upstream compare response exceeds the size limit" + try: + data = json.loads(payload) + except (UnicodeDecodeError, json.JSONDecodeError): + return None, "upstream compare response is malformed" + if not isinstance(data, dict): + return None, "upstream compare response is malformed" + ahead_by = data.get("ahead_by") + behind_by = data.get("behind_by") + # bool is an int subclass, so check the exact type: a count must be a number. + if type(ahead_by) is not int or type(behind_by) is not int: + return None, "upstream compare response is malformed" + if behind_by: + return None, f"pinned commit {commit} is not on upstream branch {branch}" + return ahead_by, None + + +def collect_drift() -> list[Drift]: + """Look up drift for every vendored source that has a valid manifest.""" + manifests, discovery_errors = validator.discover_vendoring_manifests() + rows = [ + Drift(source="src/external_dependencies", error=e) for e in discovery_errors + ] + for manifest_path in manifests: + source = manifest_path.parent.relative_to(validator.REPOSITORY_ROOT).as_posix() + if manifest_errors := validator.validate_vendor_manifest(manifest_path): + rows.append(Drift(source=source, error="; ".join(manifest_errors))) + continue + upstream = validator.parse_vendor_manifest(manifest_path)["upstream"] + if not isinstance(upstream, dict): + rows.append(Drift(source=source, error="has invalid upstream metadata")) + continue + repository = str(upstream["repository"]) + commit = str(upstream["commit"]) + branch = str(upstream["branch"]) + behind, error = fetch_drift(repository, commit, branch) + rows.append(Drift(source, commit, branch, behind, error)) + rows.sort(key=lambda row: (row.behind is None, -(row.behind or 0), row.source)) + return rows + + +def escape_markdown_cell(cell: str) -> str: + """Keep a literal backslash or pipe from being read as table syntax.""" + return cell.replace("\\", "\\\\").replace("|", "\\|") + + +def render_table(rows: list[Drift], *, markdown: bool) -> str: + """Render drift rows as an aligned text table or a GitHub Markdown table.""" + body = [ + ( + row.source.removeprefix("src/external_dependencies/"), + row.commit[:9] or "-", + row.branch or "-", + "?" if row.behind is None else str(row.behind), + ) + for row in rows + ] + if markdown: + lines = [ + "## Vendored dependency drift", + "", + "| " + " | ".join(TABLE_HEADER) + " |", + "|---|---|---|---:|", + ] + lines.extend( + "| " + " | ".join(escape_markdown_cell(cell) for cell in cells) + " |" + for cells in body + ) + return "\n".join(lines) + "\n" + widths = [ + max(len(cells[column]) for cells in (TABLE_HEADER, *body)) + for column in range(len(TABLE_HEADER)) + ] + lines = [] + for cells in (TABLE_HEADER, *body): + *left, right = cells + padded = [cell.ljust(width) for cell, width in zip(left, widths[:-1])] + lines.append(" ".join([*padded, right.rjust(widths[-1])]).rstrip()) + return "\n".join(lines) + "\n" + + +def status(*, markdown: bool) -> int: + rows = collect_drift() + write_github_output(rows) + if not rows: + print("No vendored dependencies found.") + return 0 + print(render_table(rows, markdown=markdown), end="") + failures = [row for row in rows if row.error is not None] + for row in failures: + print(f"ERROR: {row.source} {row.error}", file=sys.stderr) + return 1 if failures else 0 + + +def write_github_output(rows: list[Drift]) -> None: + """Expose the verdict to a GitHub Actions step when GITHUB_OUTPUT is set.""" + output_path = os.environ.get("GITHUB_OUTPUT") + if not output_path: + return + resolved = [row for row in rows if row.behind is not None] + drifted = any(row.behind for row in resolved) + with open(output_path, "a", encoding="utf-8") as output: + output.write(f"drifted={str(drifted).lower()}\n") + output.write(f"resolved={len(resolved)}\n") + output.write(f"unresolved={len(rows) - len(resolved)}\n") + + +def main(argv: list[str] | None = None) -> int: + argument_parser = argparse.ArgumentParser(description=__doc__) + subparsers = argument_parser.add_subparsers(dest="command", required=True) + status_parser = subparsers.add_parser( + "status", + help="report how many upstream commits each vendored source is behind", + ) + status_parser.add_argument( + "--markdown", + action="store_true", + help="emit a GitHub Markdown table, for example for GITHUB_STEP_SUMMARY", + ) + arguments = argument_parser.parse_args(argv) + return status(markdown=arguments.markdown) + + +if __name__ == "__main__": + sys.exit(main()) From 4028ba68c4bbe446331610b1397eacad789752ed Mon Sep 17 00:00:00 2001 From: Shane Copenhagen Date: Fri, 4 Sep 2026 08:53:20 -0600 Subject: [PATCH 2/2] ci: file a weekly issue when vendored dependencies drift upstream On the Sunday schedule, run bin/vendored_dependency.py status and, when any source is behind its pinned upstream branch, open or comment on a single moveit_pro issue carrying the drift table. Never gates a PR. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yaml | 116 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6113d4e6c..e8e7bea38 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -861,6 +861,122 @@ jobs: set -o pipefail python3 bin/vendored_dependency.py status --markdown | tee -a "$GITHUB_STEP_SUMMARY" + # File (or update) a moveit_pro issue once a week when any source under + # src/external_dependencies has fallen behind the upstream branch it pins. + # Runs only on the Sunday schedule so drift lands in triage without gating + # any PR; the table comes from bin/vendored_dependency.py, the same command + # validate-workspace-dependencies publishes in its job summary. Dedupes by + # title like weekly-failure-issue above: an open issue gets a fresh comment. + # Manual dispatch also runs it so the issue-filing path can be exercised + # before a Sunday, the same way integration-test-weekly allows. + vendored-drift-issue: + name: File vendored dependency drift issue + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'schedule' && github.event.schedule == '0 6 * * 0') + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Report vendored dependency drift + id: drift + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + # A failed lookup exits 1 but still prints every row it could + # resolve, so keep going: one flaky compare must not suppress the + # report for the sources that did resolve. The script writes + # drifted= / resolved= / unresolved= to GITHUB_OUTPUT itself. + python3 bin/vendored_dependency.py status --markdown > drift.md || echo "::warning::Some drift lookups failed; see the step log." + cat drift.md >> "$GITHUB_STEP_SUMMARY" + - name: Fail when no lookup succeeded + if: steps.drift.outputs.resolved == '0' + run: | + echo "::error::No vendored dependency lookup succeeded, so the drift report is empty. Check the token and the upstream repositories." + exit 1 + # The token is minted for the running repo's owner and the script + # writes to PickNikRobotics/moveit_pro; the two agree only in the base + # repo, which is the only place the schedule runs. + - name: Generate cross-repo App token + if: steps.drift.outputs.drifted == 'true' + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.SISTER_REPOS_APP_CLIENT_ID }} + private-key: ${{ secrets.SISTER_REPOS_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: moveit_pro + permission-issues: write + - name: Open or update drift issue + if: steps.drift.outputs.drifted == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const fs = require('fs'); + const issueOwner = 'PickNikRobotics'; + const issueRepo = 'moveit_pro'; + const title = 'Vendored example_ws dependencies have drifted from upstream'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const when = new Date().toISOString().slice(0, 10); + // Drop the script's own heading; the issue title carries it. + const table = fs.readFileSync('drift.md', 'utf8').replace(/^## [^\n]*\n\n/, '').trim(); + const body = [ + `As of ${when}, these sources under \`src/external_dependencies\` in moveit_pro_example_ws are behind the upstream branch they pin:`, + '', + table, + '', + 'Refresh one source per PR with `bin/vendored_dependency.py update ` (see "Updating vendored dependencies" in the example_ws README), or close this issue if the current pins are intentional.', + '', + `- [Workflow run](${runUrl})`, + ].join('\n'); + // Unlike weekly-failure-issue, a dedupe failure fails the step + // instead of risking a duplicate: drift recurs every week, so the + // next run retries and nothing is lost. + let existing; + try { + const found = await github.rest.search.issuesAndPullRequests({ + q: `repo:${issueOwner}/${issueRepo} is:issue is:open in:title "${title}"`, + }); + existing = found.data.items.find((i) => i.title === title); + } catch (e) { + core.setFailed(`Issue dedupe search failed (${e.message}); not creating an issue this run.`); + return; + } + if (existing) { + await github.rest.issues.createComment({ + owner: issueOwner, + repo: issueRepo, + issue_number: existing.number, + body, + }); + core.info(`Commented on existing ${issueOwner}/${issueRepo} issue #${existing.number}.`); + return; + } + // The example_ws label is how moveit_pro's tracker filters these, + // so an unlabeled issue is worse than none: fail with the cause. + let created; + try { + created = await github.rest.issues.create({ + owner: issueOwner, + repo: issueRepo, + title, + body, + labels: ['example_ws'], + }); + } catch (e) { + if (e?.status === 422) { + core.setFailed(`Issue create rejected (${e.message}); check that the example_ws label exists in ${issueOwner}/${issueRepo}.`); + return; + } + throw e; + } + core.info(`Opened ${issueOwner}/${issueRepo} issue #${created.data.number}.`); + validate_objectives: runs-on: ubuntu-22.04 steps: