From c793156022cee3507b18b9e39b16fd75ad6aaca4 Mon Sep 17 00:00:00 2001 From: Ignas Baranauskas Date: Mon, 31 Aug 2026 17:14:14 +0100 Subject: [PATCH 1/5] ci(sdk): add proto drift detection and sync notifications Add automated proto drift detection for Go and TypeScript SDKs with issue-based notifications when SDK builds break due to proto changes. Signed-off-by: Ignas Baranauskas --- .github/workflows/sdk-proto-check.yml | 91 ++++++ .github/workflows/sdk-sync-dashboard.yml | 181 +++++++++++ tasks/go.toml | 71 +++++ tasks/scripts/sdk_build_check.sh | 35 +++ tasks/scripts/sdk_sync.py | 380 +++++++++++++++++++++++ tasks/scripts/sdk_sync_test.py | 157 ++++++++++ tasks/typescript.toml | 36 +++ 7 files changed, 951 insertions(+) create mode 100644 .github/workflows/sdk-proto-check.yml create mode 100644 .github/workflows/sdk-sync-dashboard.yml create mode 100755 tasks/scripts/sdk_build_check.sh create mode 100644 tasks/scripts/sdk_sync.py create mode 100644 tasks/scripts/sdk_sync_test.py diff --git a/.github/workflows/sdk-proto-check.yml b/.github/workflows/sdk-proto-check.yml new file mode 100644 index 0000000000..37d6b5236d --- /dev/null +++ b/.github/workflows/sdk-proto-check.yml @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: SDK Proto Check + +on: + merge_group: + types: [checks_requested] + push: + branches: + - "pull-request/[0-9]+" + workflow_dispatch: + +env: + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +permissions: + contents: read + packages: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pr_metadata: + name: Resolve PR metadata + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + should_run: ${{ steps.gate.outputs.should_run }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - id: gate + uses: ./.github/actions/pr-gate + + sdk_proto_drift: + name: Proto Drift (${{ matrix.sdk.name }}) + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: linux-amd64-cpu8 + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + strategy: + fail-fast: false + matrix: + sdk: + - name: go + drift_task: "go:proto:drift" + - name: typescript + drift_task: "sdk:ts:proto:drift" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install tools + run: mise install --locked + + - name: Check proto drift + id: drift + run: | + REPORT=$(mise run ${{ matrix.sdk.drift_task }} 2>"$RUNNER_TEMP/drift_stderr.log") || true + + if echo "$REPORT" | jq -e '.synced' >/dev/null 2>&1; then + SYNCED=$(echo "$REPORT" | jq -r '.synced') + { + echo "report<> "$GITHUB_OUTPUT" + echo "synced=$SYNCED" >> "$GITHUB_OUTPUT" + else + echo "::warning::Proto drift check failed: unable to parse report" + echo "stderr: $(cat "$RUNNER_TEMP/drift_stderr.log")" + echo "synced=error" >> "$GITHUB_OUTPUT" + fi + + - name: Annotate drift warning + if: steps.drift.outputs.synced == 'false' + env: + DRIFT_REPORT: ${{ steps.drift.outputs.report }} + SDK_NAME: ${{ matrix.sdk.name }} + run: | + SUMMARY=$(echo "$DRIFT_REPORT" | jq -r '.summary') + FILES=$(echo "$DRIFT_REPORT" | jq -r '.files[] | select(.status != "synced") | " - \(.name) (\(.status), \(.diff_lines) lines changed)"' | sed ':a;N;$!ba;s/\n/%0A/g') + echo "::warning::SDK proto drift detected for ${SDK_NAME}: ${SUMMARY}%0A${FILES}" diff --git a/.github/workflows/sdk-sync-dashboard.yml b/.github/workflows/sdk-sync-dashboard.yml new file mode 100644 index 0000000000..73a16b7fbf --- /dev/null +++ b/.github/workflows/sdk-sync-dashboard.yml @@ -0,0 +1,181 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: SDK Proto Sync + +on: + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +env: + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +permissions: + actions: read + contents: read + packages: read + issues: write + +concurrency: + group: sdk-proto-sync + cancel-in-progress: true + +jobs: + sdk_sync_check: + name: Sync Check + runs-on: linux-amd64-cpu8 + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + outputs: + go_drift_report: ${{ steps.go_drift.outputs.report }} + go_has_drift: ${{ steps.go_drift.outputs.has_drift }} + go_build_report: ${{ steps.go_build.outputs.report }} + go_build_failed: ${{ steps.go_build.outputs.build_failed || 'false' }} + ts_drift_report: ${{ steps.ts_drift.outputs.report }} + ts_has_drift: ${{ steps.ts_drift.outputs.has_drift }} + ts_build_report: ${{ steps.ts_build.outputs.report }} + ts_build_failed: ${{ steps.ts_build.outputs.build_failed || 'false' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install tools + run: mise install --locked + + - name: Check Go proto drift + id: go_drift + run: | + REPORT=$(mise run go:proto:drift 2>"$RUNNER_TEMP/go_drift_stderr.log") || true + if echo "$REPORT" | jq -e '.synced' >/dev/null 2>&1; then + SYNCED=$(echo "$REPORT" | jq -r '.synced') + { + echo "report<> "$GITHUB_OUTPUT" + [ "$SYNCED" = "true" ] && echo "has_drift=false" >> "$GITHUB_OUTPUT" || echo "has_drift=true" >> "$GITHUB_OUTPUT" + else + echo "::error::Go proto drift check failed" + echo "stderr: $(cat "$RUNNER_TEMP/go_drift_stderr.log")" + echo "report={}" >> "$GITHUB_OUTPUT" + echo "has_drift=error" >> "$GITHUB_OUTPUT" + fi + + - name: Go build check + id: go_build + if: steps.go_drift.outputs.has_drift == 'true' + run: | + REPORT=$(mise run go:proto:build-check 2>"$RUNNER_TEMP/go_build_stderr.log") && BUILD_OK=true || BUILD_OK=false + if echo "$REPORT" | jq -e '.sdk' >/dev/null 2>&1; then + { echo "report<> "$GITHUB_OUTPUT" + else + echo "stderr: $(cat "$RUNNER_TEMP/go_build_stderr.log")" + echo "report={}" >> "$GITHUB_OUTPUT" + fi + [ "$BUILD_OK" = "true" ] && echo "build_failed=false" >> "$GITHUB_OUTPUT" || echo "build_failed=true" >> "$GITHUB_OUTPUT" + + - name: Check TypeScript proto drift + id: ts_drift + run: | + REPORT=$(mise run sdk:ts:proto:drift 2>"$RUNNER_TEMP/ts_drift_stderr.log") || true + if echo "$REPORT" | jq -e '.synced' >/dev/null 2>&1; then + SYNCED=$(echo "$REPORT" | jq -r '.synced') + { + echo "report<> "$GITHUB_OUTPUT" + [ "$SYNCED" = "true" ] && echo "has_drift=false" >> "$GITHUB_OUTPUT" || echo "has_drift=true" >> "$GITHUB_OUTPUT" + else + echo "::error::TypeScript proto drift check failed" + echo "stderr: $(cat "$RUNNER_TEMP/ts_drift_stderr.log")" + echo "report={}" >> "$GITHUB_OUTPUT" + echo "has_drift=error" >> "$GITHUB_OUTPUT" + fi + + - name: TypeScript build check + id: ts_build + if: steps.ts_drift.outputs.has_drift == 'true' + run: | + REPORT=$(mise run sdk:ts:proto:build-check 2>"$RUNNER_TEMP/ts_build_stderr.log") && BUILD_OK=true || BUILD_OK=false + if echo "$REPORT" | jq -e '.sdk' >/dev/null 2>&1; then + { echo "report<> "$GITHUB_OUTPUT" + else + echo "stderr: $(cat "$RUNNER_TEMP/ts_build_stderr.log")" + echo "report={}" >> "$GITHUB_OUTPUT" + fi + [ "$BUILD_OK" = "true" ] && echo "build_failed=false" >> "$GITHUB_OUTPUT" || echo "build_failed=true" >> "$GITHUB_OUTPUT" + + issue_management: + name: Manage Drift Issue (${{ matrix.sdk.name }}) + needs: sdk_sync_check + if: always() && needs.sdk_sync_check.result == 'success' + runs-on: linux-amd64-cpu8 + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + strategy: + fail-fast: false + matrix: + sdk: + - name: go + label: "sdk:go:sync" + has_drift: ${{ needs.sdk_sync_check.outputs.go_has_drift }} + build_failed: ${{ needs.sdk_sync_check.outputs.go_build_failed }} + drift_report: ${{ needs.sdk_sync_check.outputs.go_drift_report }} + build_report: ${{ needs.sdk_sync_check.outputs.go_build_report }} + - name: typescript + label: "sdk:typescript:sync" + has_drift: ${{ needs.sdk_sync_check.outputs.ts_has_drift }} + build_failed: ${{ needs.sdk_sync_check.outputs.ts_build_failed }} + drift_report: ${{ needs.sdk_sync_check.outputs.ts_drift_report }} + build_report: ${{ needs.sdk_sync_check.outputs.ts_build_report }} + steps: + - name: Warn on drift detection error + if: matrix.sdk.has_drift == 'error' + run: | + echo "::error::Drift detection failed for ${{ matrix.sdk.name }} SDK — check the sdk_sync_check job logs" + exit 1 + + - if: matrix.sdk.has_drift == 'true' && matrix.sdk.build_failed == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install tools + if: matrix.sdk.has_drift == 'true' && matrix.sdk.build_failed == 'true' + run: mise install --locked + + - name: Create or update drift issue + if: matrix.sdk.has_drift == 'true' && matrix.sdk.build_failed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRIFT_REPORT: ${{ matrix.sdk.drift_report }} + BUILD_REPORT: ${{ matrix.sdk.build_report }} + run: | + RESULT=$(uv run python tasks/scripts/sdk_sync.py manage-issue \ + --drift-report "${DRIFT_REPORT:-{}}" \ + --build-report "${BUILD_REPORT:-{}}" \ + --sdk "${{ matrix.sdk.name }}" \ + --repo "$GITHUB_REPOSITORY" \ + --label "${{ matrix.sdk.label }}") + echo "$RESULT" | jq . + ACTION=$(echo "$RESULT" | jq -r '.action // "unknown"') + if [ "$ACTION" = "error" ] || [ "$ACTION" = "unknown" ]; then + echo "::error::Issue management for ${{ matrix.sdk.name }} failed: $ACTION" + exit 1 + fi + + - name: Close resolved drift issue + if: matrix.sdk.has_drift == 'false' || (matrix.sdk.has_drift == 'true' && matrix.sdk.build_failed == 'false') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ISSUE=$(gh issue list --repo "$GITHUB_REPOSITORY" --label "${{ matrix.sdk.label }}" --state open --json number --jq '.[0].number') + if [ -n "$ISSUE" ]; then + gh issue close "$ISSUE" --repo "$GITHUB_REPOSITORY" --comment "SDK builds and tests pass after proto regeneration. Closing automatically." + echo "Closed issue #$ISSUE" + fi diff --git a/tasks/go.toml b/tasks/go.toml index 81091f2912..f78334c164 100644 --- a/tasks/go.toml +++ b/tasks/go.toml @@ -178,3 +178,74 @@ fi echo "Proto check passed: generated files are up to date." """ hide = true + +["go:proto:drift"] +description = "Check Go SDK proto drift and output a JSON report" +dir = "sdk/go" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +SDK_ROOT=$(pwd -P) +REPO_ROOT=$(cd ../.. && pwd -P) + +for tool in buf protoc-gen-go protoc-gen-go-grpc jq; do + if ! command -v "$tool" &>/dev/null; then + echo '{"sdk":"go","synced":false,"error":"'"$tool"' not found"}' + exit 1 + fi +done + +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT + +CHECK_TEMPLATE=$(sed 's|out: sdk/go|out: '"$WORK_DIR"'|' buf.gen.yaml) +if ! (cd "$REPO_ROOT" && buf generate --template "$CHECK_TEMPLATE") >/dev/null 2>&1; then + jq -n -c --arg sdk "go" '{sdk:$sdk, synced:false, files:[], summary:"buf generate failed"}' + exit 1 +fi + +NDJSON_FILE=$(mktemp) + +for f in $(find "$WORK_DIR/proto" -name '*.go' -type f | sort); do + REL=${f#"$WORK_DIR/proto/"} + COMMITTED="$SDK_ROOT/proto/$REL" + + if [ ! -f "$COMMITTED" ]; then + printf '%s\t%s\t%s\n' "$REL" "added" "0" >> "$NDJSON_FILE" + else + DIFF_LINES=$(diff -u "$COMMITTED" "$f" 2>/dev/null | wc -l | tr -d ' ') || true + if [ "$DIFF_LINES" -gt 0 ]; then + printf '%s\t%s\t%s\n' "$REL" "modified" "$DIFF_LINES" >> "$NDJSON_FILE" + fi + fi +done + +for f in $(find "$SDK_ROOT/proto" -name '*.go' -type f | sort); do + REL=${f#"$SDK_ROOT/proto/"} + REGEN="$WORK_DIR/proto/$REL" + if [ ! -f "$REGEN" ]; then + printf '%s\t%s\t%s\n' "$REL" "removed" "0" >> "$NDJSON_FILE" + fi +done + +jq -R -c -s --arg sdk "go" ' + split("\n") | map(select(length > 0) | split("\t") | + {name: .[0], status: .[1], diff_lines: (.[2] | tonumber)}) | + {sdk: $sdk, synced: (length == 0), files: ., + summary: (if length == 0 then "all files synced" + else "\\(length) file(s) drifted" end)} +' "$NDJSON_FILE" + +DRIFTED=$(wc -l < "$NDJSON_FILE" | tr -d ' ') +rm -f "$NDJSON_FILE" + +[ "$DRIFTED" -eq 0 ] && exit 0 || exit 1 +""" +hide = true + +["go:proto:build-check"] +description = "Run full Go SDK proto sync, generate, build, and test pipeline" +dir = "sdk/go" +run = 'bash ../../tasks/scripts/sdk_build_check.sh go gen=go:proto:gen build=go:build test=go:test' +hide = true diff --git a/tasks/scripts/sdk_build_check.sh b/tasks/scripts/sdk_build_check.sh new file mode 100755 index 0000000000..b8cde79919 --- /dev/null +++ b/tasks/scripts/sdk_build_check.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SDK="${1:?Usage: sdk_build_check.sh [step2=task2] ...}" +shift + +PAIRS=("$@") +LOG_FILE=$(mktemp) +trap 'rm -f "$LOG_FILE"' EXIT + +FAILED_STEP="" +for pair in "${PAIRS[@]}"; do + STEP="${pair%%=*}" + TASK="${pair#*=}" + + if ! mise run "$TASK" >> "$LOG_FILE" 2>&1; then + FAILED_STEP="$STEP" + break + fi +done + +LOG_CONTENT=$(tail -500 "$LOG_FILE") + +if [ -z "$FAILED_STEP" ]; then + jq -n -c --arg sdk "$SDK" \ + '{sdk: $sdk, success: true, failed_step: null, log: ""}' +else + jq -n -c --arg sdk "$SDK" --arg step "$FAILED_STEP" --arg log "$LOG_CONTENT" \ + '{sdk: $sdk, success: false, failed_step: $step, log: $log}' + exit 1 +fi diff --git a/tasks/scripts/sdk_sync.py b/tasks/scripts/sdk_sync.py new file mode 100644 index 0000000000..09990d49c8 --- /dev/null +++ b/tasks/scripts/sdk_sync.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""SDK proto sync utilities. + +Drift detection is handled by per-SDK mise tasks (go:proto:drift, +sdk:ts:proto:drift) which output JSON DriftReport objects. This CLI +provides the workflow integration layer: issue management when drift +is detected and auto-closing when it resolves. + +Subcommands: + manage-issue Create or update a GitHub drift issue (deduplicates by label) +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys + +SDK_CONFIGS = { + "go": { + "display_name": "Go", + "source_dirs": [ + "sdk/go/openshell/v1/internal/converter/", + "sdk/go/openshell/v1/types/", + "sdk/go/openshell/v1/", + ], + "proto_task": "go:proto:gen", + "build_task": "go:build", + "test_task": "go:test", + }, + "typescript": { + "display_name": "TypeScript", + "source_dirs": [ + "sdk/typescript/src/", + ], + "proto_task": "sdk:ts:proto", + "build_task": "sdk:ts:build", + "test_task": "sdk:ts:test", + }, +} + + +def _sdk_display_name(sdk: str) -> str: + return SDK_CONFIGS[sdk]["display_name"] + + +def generate_issue_body( + drift_report: dict, + build_report: dict | None, + sdk: str, + max_log_lines: int = 500, +) -> str: + sections: list[str] = [] + paths = SDK_CONFIGS[sdk] + + sections.append("## Proto Drift Report") + sections.append("") + summary = drift_report.get("summary", "unknown") + sections.append(f"**Summary**: {summary}") + sections.append("") + + files = drift_report.get("files", []) + drifted_files = [f for f in files if f.get("status") != "synced"] + + if drifted_files: + sections.extend(_render_file_table(drifted_files)) + sections.append("") + + failed_step = ( + build_report["failed_step"] + if build_report and build_report.get("failed_step") + else "" + ) + + if failed_step: + sections.append("## Build Log") + sections.append("") + sections.append(f"**Failed step**: `{failed_step}`") + sections.append("") + log = build_report.get("log", "no log available") + log_lines = log.splitlines() + if len(log_lines) > max_log_lines: + log = "\n".join(log_lines[-max_log_lines:]) + sections.append("```") + sections.append(log) + sections.append("```") + sections.append("") + + sections.append("## Fix Commands") + sections.append("") + sections.append("```bash") + sections.append(f"mise run {paths['proto_task']} # Regenerate bindings") + sections.append(f"mise run {paths['build_task']} # Verify build") + sections.append(f"mise run {paths['test_task']} # Run tests") + sections.append("```") + sections.append("") + + if drifted_files: + drifted_names = ", ".join(f"`{f['name']}`" for f in drifted_files) + elif sdk == "typescript": + drifted_names = ( + "not individually tracked (run `mise run sdk:ts:proto && " + "mise run sdk:ts:typecheck` to reproduce)" + ) + else: + drifted_names = "unknown" + + sections.append("## Agent Instructions") + sections.append("") + sections.append( + "This section is a ready-to-consume prompt for an AI agent. " + "Copy it into your agent to produce a fix PR." + ) + sections.append("") + sections.append("
") + sections.append("Agent prompt (click to expand)") + sections.append("") + + display_name = _sdk_display_name(sdk) + sections.append(f"Fix proto drift in the {display_name} SDK.") + sections.append("") + sections.append("## Context") + sections.append("") + sections.append( + f"The root `proto/` directory has changed and the {display_name} SDK's" + ) + sections.append( + f"generated bindings are out of sync. The drifted files are: {drifted_names}." + ) + + if failed_step: + sections.append( + f"The SDK build fails at the `{failed_step}` step after regenerating protos." + ) + sections.append( + "The build log above shows the exact error. Your job is to fix the" + ) + sections.append( + f"{display_name} SDK code so it compiles and passes tests with the updated protos." + ) + else: + sections.append( + "The SDK build status is unknown. Check if it compiles after regeneration." + ) + + sections.append("") + sections.append("## Steps") + sections.append("") + sections.append( + f"1. **Regenerate bindings**: Run `mise run {paths['proto_task']}` to regenerate " + "language-specific bindings from the updated protos." + ) + sections.append( + "2. **Fix compilation errors**: Read the build log above. Update the SDK source code " + "to handle new/changed/removed proto fields:" + ) + for source_dir in paths["source_dirs"]: + sections.append(f" - `{source_dir}`") + sections.append( + "3. **Fix test failures**: Update tests that assert on proto types that changed shape." + ) + sections.append( + f"4. **Verify**: Run `mise run {paths['build_task']}` and " + f"`mise run {paths['test_task']}` until both pass." + ) + sections.append( + "5. **Create a PR**: Commit all changes and create a PR referencing this issue." + ) + sections.append("") + sections.append("## Scope") + sections.append("") + sections.append( + f"- Only modify files under `sdk/{sdk}/`. Do not change root `proto/` files." + ) + sections.append( + "- Do not change the proto definitions. Adapt the SDK to match them." + ) + sections.append("- Keep changes minimal: only fix what the proto changes broke.") + + sections.append("") + sections.append("
") + sections.append("") + + return "\n".join(sections) + + +# --- helpers --- + + +def _render_file_table(files: list[dict]) -> list[str]: + lines = [ + "| File | Status | Diff Lines |", + "|------|--------|------------|", + ] + for f in files: + lines.append(f"| `{f['name']}` | {f['status']} | {f['diff_lines']} |") + return lines + + +def _run_cmd( + cmd: list[str], + cwd: str | None = None, + capture: bool = False, + stdin_data: str | None = None, +) -> subprocess.CompletedProcess: + return subprocess.run( + cmd, + cwd=cwd, + capture_output=capture, + text=True, + input=stdin_data, + ) + + +def _ensure_label(repo: str, label: str, description: str) -> None: + check = _run_cmd(["gh", "label", "view", label, "--repo", repo], capture=True) + if check.returncode != 0: + _run_cmd( + [ + "gh", + "label", + "create", + label, + "--repo", + repo, + "--description", + description, + "--color", + "D93F0B", + ], + capture=True, + ) + + +def _find_open_issue(repo: str, label: str) -> dict | None: + result = _run_cmd( + [ + "gh", + "issue", + "list", + "--repo", + repo, + "--label", + label, + "--state", + "open", + "--json", + "url,number", + "--jq", + ".[0]", + ], + capture=True, + ) + if result.returncode == 0 and result.stdout.strip(): + try: + data = json.loads(result.stdout.strip()) + return {"url": data["url"], "number": str(data["number"])} + except (json.JSONDecodeError, KeyError, TypeError): + pass + return None + + +# --- public functions --- + + +def manage_issue( + drift_report: dict, + build_report: dict | None, + sdk: str, + repo: str, + label: str, +) -> dict: + _ensure_label(repo, label, f"Proto drift detected for {sdk} SDK") + + body = generate_issue_body(drift_report, build_report, sdk) + title = f"SDK proto drift: {sdk}" + + existing = _find_open_issue(repo, label) + if existing: + result = _run_cmd( + [ + "gh", + "issue", + "edit", + existing["number"], + "--repo", + repo, + "--body-file", + "-", + ], + capture=True, + stdin_data=body, + ) + if result.returncode == 0: + return {"issue_url": existing["url"], "action": "updated"} + return { + "issue_url": "", + "action": "error", + "reason": "Failed to update issue", + } + + result = _run_cmd( + [ + "gh", + "issue", + "create", + "--repo", + repo, + "--title", + title, + "--body-file", + "-", + "--label", + label, + ], + capture=True, + stdin_data=body, + ) + if result.returncode == 0: + url = result.stdout.strip() + return {"issue_url": url, "action": "created"} + return { + "issue_url": "", + "action": "error", + "reason": "Failed to create issue", + } + + +# --- CLI --- + + +def _load_json_arg(value: str) -> dict | list: + if value == "-": + return json.load(sys.stdin) + return json.loads(value) + + +def cmd_manage_issue(args: argparse.Namespace) -> int: + drift_report = _load_json_arg(args.drift_report) + build_report = None + if args.build_report: + build_report = _load_json_arg(args.build_report) + result = manage_issue(drift_report, build_report, args.sdk, args.repo, args.label) + print(json.dumps(result)) + return 1 if result.get("action") == "error" else 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="SDK proto sync utilities", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = parser.add_subparsers(dest="command", required=True) + + mi = sub.add_parser("manage-issue", help="Create or update a drift issue") + mi.add_argument("--drift-report", required=True, help="Drift report JSON") + mi.add_argument("--build-report", help="Build report JSON") + mi.add_argument( + "--sdk", + required=True, + choices=list(SDK_CONFIGS.keys()), + help="SDK name", + ) + mi.add_argument("--repo", required=True, help="GitHub repo (owner/name)") + mi.add_argument("--label", required=True, help="Issue label for deduplication") + + args = parser.parse_args() + handlers = { + "manage-issue": cmd_manage_issue, + } + return handlers[args.command](args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tasks/scripts/sdk_sync_test.py b/tasks/scripts/sdk_sync_test.py new file mode 100644 index 0000000000..6a38b6f93f --- /dev/null +++ b/tasks/scripts/sdk_sync_test.py @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for tasks/scripts/sdk_sync.py. + +Run via: uv run --no-project --with pytest pytest tasks/scripts/sdk_sync_test.py +""" + +from __future__ import annotations + +import subprocess +from unittest.mock import patch + +from sdk_sync import ( + generate_issue_body, + manage_issue, +) + + +def _mock_run(returncode=0, stdout="", stderr=""): + return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr) + + +class TestGenerateIssueBody: + def test_go_sdk_issue_body(self): + drift = { + "sdk": "go", + "synced": False, + "files": [ + { + "name": "openshellv1/openshell.pb.go", + "status": "modified", + "diff_lines": 5, + } + ], + "summary": "1 file(s) drifted", + } + md = generate_issue_body(drift, None, "go") + assert "## Proto Drift Report" in md + assert "`openshellv1/openshell.pb.go`" in md + assert "## Fix Commands" in md + assert "mise run go:proto:gen" in md + + def test_typescript_sdk_issue_body(self): + drift = { + "sdk": "typescript", + "synced": False, + "files": [], + "summary": "typecheck failed after proto regeneration", + } + md = generate_issue_body(drift, None, "typescript") + assert "mise run sdk:ts:proto" in md + assert "mise run sdk:ts:build" in md + assert "mise run sdk:ts:test" in md + assert "sdk/typescript/src/" in md + assert "not individually tracked" in md + assert "mise run sdk:ts:proto && mise run sdk:ts:typecheck" in md + + def test_with_build_log(self): + drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} + build = { + "sdk": "go", + "success": False, + "failed_step": "build", + "log": "error here", + } + md = generate_issue_body(drift, build, "go") + assert "## Build Log" in md + assert "`build`" in md + assert "error here" in md + + def test_log_truncation(self): + long_log = "\n".join(f"line {i}" for i in range(1000)) + drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} + build = { + "sdk": "go", + "success": False, + "failed_step": "test", + "log": long_log, + } + md = generate_issue_body(drift, build, "go", max_log_lines=500) + log_section = md.split("```")[1] + assert log_section.strip().count("\n") <= 500 + assert "line 999" in md + assert "line 0" not in md + + def test_agent_instructions_present(self): + drift = { + "sdk": "go", + "synced": False, + "files": [ + { + "name": "openshellv1/openshell.pb.go", + "status": "modified", + "diff_lines": 5, + } + ], + "summary": "1 file(s) drifted", + } + build = { + "sdk": "go", + "success": False, + "failed_step": "build", + "log": "error", + } + md = generate_issue_body(drift, build, "go") + assert "## Agent Instructions" in md + assert "Agent prompt" in md + assert "mise run go:proto:gen" in md + assert "sdk/go/openshell/v1/internal/converter/" in md + assert "sdk/go/openshell/v1/types/" in md + assert "sdk/go/openshell/v1/" in md + assert "Create a PR" in md + + def test_agent_instructions_includes_failed_step(self): + drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} + build = { + "sdk": "go", + "success": False, + "failed_step": "test", + "log": "fail", + } + md = generate_issue_body(drift, build, "go") + agent_section = md.split("## Agent Instructions")[1] + assert "`test`" in agent_section + assert "fails at" in agent_section.lower() + + +class TestManageIssue: + @patch("sdk_sync._find_open_issue") + @patch("sdk_sync._ensure_label") + @patch("sdk_sync._run_cmd") + def test_create_new_issue(self, mock_run, _mock_label, mock_find): + mock_find.return_value = None + mock_run.return_value = _mock_run( + 0, stdout="https://github.com/org/repo/issues/42\n" + ) + + drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} + result = manage_issue(drift, None, "go", "org/repo", "sdk:go:sync") + assert result["action"] == "created" + assert "42" in result["issue_url"] + + @patch("sdk_sync._find_open_issue") + @patch("sdk_sync._ensure_label") + @patch("sdk_sync._run_cmd") + def test_update_existing_issue(self, mock_run, _mock_label, mock_find): + mock_find.return_value = { + "url": "https://github.com/org/repo/issues/10", + "number": "10", + } + mock_run.return_value = _mock_run(0) + + drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} + result = manage_issue(drift, None, "go", "org/repo", "sdk:go:sync") + assert result["action"] == "updated" + assert "10" in result["issue_url"] diff --git a/tasks/typescript.toml b/tasks/typescript.toml index 06f6e8c4f1..4ce2115dbe 100644 --- a/tasks/typescript.toml +++ b/tasks/typescript.toml @@ -68,6 +68,42 @@ depends = [ ] hide = true +["sdk:ts:proto:drift"] +description = "Check TypeScript SDK proto drift and output a JSON report" +dir = "sdk/typescript" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +# TS SDK generated files (src/gen/) are gitignored, so there's no committed +# baseline to diff against. Instead, detect drift by regenerating + type +# checking: if the handwritten SDK code no longer compiles against the current +# proto definitions, that's drift. + +if ! command -v jq &>/dev/null; then + echo '{"sdk":"typescript","synced":false,"error":"jq not found"}' + exit 1 +fi + +LOG_FILE=$(mktemp) +trap 'rm -f "$LOG_FILE"' EXIT + +if mise run sdk:ts:proto > "$LOG_FILE" 2>&1 && mise run sdk:ts:typecheck >> "$LOG_FILE" 2>&1; then + jq -n -c '{sdk:"typescript", synced:true, files:[], summary:"all files synced"}' + exit 0 +else + jq -n -c '{sdk:"typescript", synced:false, files:[], summary:"typecheck failed after proto regeneration"}' + exit 1 +fi +""" +hide = true + +["sdk:ts:proto:build-check"] +description = "Run full TypeScript SDK proto generate, typecheck, and test pipeline" +dir = "sdk/typescript" +run = 'bash ../../tasks/scripts/sdk_build_check.sh typescript gen=sdk:ts:proto typecheck=sdk:ts:typecheck test=sdk:ts:test' +hide = true + # Publish to the registry in package.json publishConfig. Set OPENSHELL_NPM_VERSION # to stamp the version from the release tag (release.py get-version --npm); the # package.json placeholder 0.0.0 is restored afterward, mirroring the Cargo From 1820aeab3d8e0f910717054d97fa0a636275d206 Mon Sep 17 00:00:00 2001 From: Ignas Baranauskas Date: Thu, 3 Sep 2026 13:03:55 +0100 Subject: [PATCH 2/5] fix(ci): address sdk proto sync review feedback Signed-off-by: Ignas Baranauskas --- .github/workflows/sdk-proto-check.yml | 20 +- .github/workflows/sdk-sync-dashboard.yml | 166 +++++------- tasks/go.toml | 101 +------ tasks/scripts/go_proto_check.sh | 107 ++++++++ tasks/scripts/sdk_sync.py | 320 ++++++++++++----------- tasks/scripts/sdk_sync_test.py | 41 +++ tasks/sdk-sync-config.json | 32 +++ tasks/typescript.toml | 12 +- 8 files changed, 436 insertions(+), 363 deletions(-) create mode 100755 tasks/scripts/go_proto_check.sh create mode 100644 tasks/sdk-sync-config.json diff --git a/.github/workflows/sdk-proto-check.yml b/.github/workflows/sdk-proto-check.yml index 37d6b5236d..019c8bb7ae 100644 --- a/.github/workflows/sdk-proto-check.yml +++ b/.github/workflows/sdk-proto-check.yml @@ -26,22 +26,29 @@ jobs: pr_metadata: name: Resolve PR metadata runs-on: ubuntu-latest + timeout-minutes: 5 permissions: contents: read pull-requests: read outputs: should_run: ${{ steps.gate.outputs.should_run }} + matrix: ${{ steps.config.outputs.matrix }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: gate uses: ./.github/actions/pr-gate + - id: config + name: Load SDK configuration + run: echo "matrix=$(jq -c '.include' tasks/sdk-sync-config.json)" >> "$GITHUB_OUTPUT" + sdk_proto_drift: name: Proto Drift (${{ matrix.sdk.name }}) needs: pr_metadata if: needs.pr_metadata.outputs.should_run == 'true' runs-on: linux-amd64-cpu8 + timeout-minutes: 15 container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -50,11 +57,7 @@ jobs: strategy: fail-fast: false matrix: - sdk: - - name: go - drift_task: "go:proto:drift" - - name: typescript - drift_task: "sdk:ts:proto:drift" + sdk: ${{ fromJSON(needs.pr_metadata.outputs.matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -66,12 +69,13 @@ jobs: run: | REPORT=$(mise run ${{ matrix.sdk.drift_task }} 2>"$RUNNER_TEMP/drift_stderr.log") || true - if echo "$REPORT" | jq -e '.synced' >/dev/null 2>&1; then + if echo "$REPORT" | jq -e 'has("synced") and (.synced | type == "boolean")' >/dev/null 2>&1; then SYNCED=$(echo "$REPORT" | jq -r '.synced') + DELIMITER="REPORT_EOF_$(openssl rand -hex 16)" { - echo "report<> "$GITHUB_OUTPUT" echo "synced=$SYNCED" >> "$GITHUB_OUTPUT" else diff --git a/.github/workflows/sdk-sync-dashboard.yml b/.github/workflows/sdk-sync-dashboard.yml index 73a16b7fbf..962d882344 100644 --- a/.github/workflows/sdk-sync-dashboard.yml +++ b/.github/workflows/sdk-sync-dashboard.yml @@ -22,98 +22,74 @@ concurrency: cancel-in-progress: true jobs: + load_config: + name: Load SDK configuration + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + matrix: ${{ steps.config.outputs.matrix }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - id: config + run: echo "matrix=$(jq -c '.include' tasks/sdk-sync-config.json)" >> "$GITHUB_OUTPUT" + sdk_sync_check: - name: Sync Check + name: Sync Check (${{ matrix.sdk.name }}) + needs: load_config runs-on: linux-amd64-cpu8 + timeout-minutes: 30 container: image: ghcr.io/nvidia/openshell/ci:latest credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - outputs: - go_drift_report: ${{ steps.go_drift.outputs.report }} - go_has_drift: ${{ steps.go_drift.outputs.has_drift }} - go_build_report: ${{ steps.go_build.outputs.report }} - go_build_failed: ${{ steps.go_build.outputs.build_failed || 'false' }} - ts_drift_report: ${{ steps.ts_drift.outputs.report }} - ts_has_drift: ${{ steps.ts_drift.outputs.has_drift }} - ts_build_report: ${{ steps.ts_build.outputs.report }} - ts_build_failed: ${{ steps.ts_build.outputs.build_failed || 'false' }} + strategy: + fail-fast: false + matrix: + sdk: ${{ fromJSON(needs.load_config.outputs.matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install tools run: mise install --locked - - - name: Check Go proto drift - id: go_drift - run: | - REPORT=$(mise run go:proto:drift 2>"$RUNNER_TEMP/go_drift_stderr.log") || true - if echo "$REPORT" | jq -e '.synced' >/dev/null 2>&1; then - SYNCED=$(echo "$REPORT" | jq -r '.synced') - { - echo "report<> "$GITHUB_OUTPUT" - [ "$SYNCED" = "true" ] && echo "has_drift=false" >> "$GITHUB_OUTPUT" || echo "has_drift=true" >> "$GITHUB_OUTPUT" - else - echo "::error::Go proto drift check failed" - echo "stderr: $(cat "$RUNNER_TEMP/go_drift_stderr.log")" - echo "report={}" >> "$GITHUB_OUTPUT" - echo "has_drift=error" >> "$GITHUB_OUTPUT" - fi - - - name: Go build check - id: go_build - if: steps.go_drift.outputs.has_drift == 'true' + - name: Check proto drift and build + env: + SDK_NAME: ${{ matrix.sdk.name }} + DRIFT_TASK: ${{ matrix.sdk.drift_task }} + BUILD_CHECK_TASK: ${{ matrix.sdk.build_check_task }} run: | - REPORT=$(mise run go:proto:build-check 2>"$RUNNER_TEMP/go_build_stderr.log") && BUILD_OK=true || BUILD_OK=false - if echo "$REPORT" | jq -e '.sdk' >/dev/null 2>&1; then - { echo "report<> "$GITHUB_OUTPUT" - else - echo "stderr: $(cat "$RUNNER_TEMP/go_build_stderr.log")" - echo "report={}" >> "$GITHUB_OUTPUT" + mkdir -p report + mise run "$DRIFT_TASK" > report/drift.json 2> report/drift.stderr || true + if ! jq -e 'has("synced") and (.synced | type == "boolean")' report/drift.json >/dev/null 2>&1; then + jq -n --arg sdk "$SDK_NAME" '{sdk:$sdk, has_drift:"error", build_failed:"false"}' > report/status.json + exit 0 fi - [ "$BUILD_OK" = "true" ] && echo "build_failed=false" >> "$GITHUB_OUTPUT" || echo "build_failed=true" >> "$GITHUB_OUTPUT" - - - name: Check TypeScript proto drift - id: ts_drift - run: | - REPORT=$(mise run sdk:ts:proto:drift 2>"$RUNNER_TEMP/ts_drift_stderr.log") || true - if echo "$REPORT" | jq -e '.synced' >/dev/null 2>&1; then - SYNCED=$(echo "$REPORT" | jq -r '.synced') - { - echo "report<> "$GITHUB_OUTPUT" - [ "$SYNCED" = "true" ] && echo "has_drift=false" >> "$GITHUB_OUTPUT" || echo "has_drift=true" >> "$GITHUB_OUTPUT" - else - echo "::error::TypeScript proto drift check failed" - echo "stderr: $(cat "$RUNNER_TEMP/ts_drift_stderr.log")" - echo "report={}" >> "$GITHUB_OUTPUT" - echo "has_drift=error" >> "$GITHUB_OUTPUT" + SYNCED=$(jq -r '.synced' report/drift.json) + if [ "$SYNCED" = "true" ]; then + jq -n --arg sdk "$SDK_NAME" '{sdk:$sdk, has_drift:"false", build_failed:"false"}' > report/status.json + exit 0 fi - - - name: TypeScript build check - id: ts_build - if: steps.ts_drift.outputs.has_drift == 'true' - run: | - REPORT=$(mise run sdk:ts:proto:build-check 2>"$RUNNER_TEMP/ts_build_stderr.log") && BUILD_OK=true || BUILD_OK=false - if echo "$REPORT" | jq -e '.sdk' >/dev/null 2>&1; then - { echo "report<> "$GITHUB_OUTPUT" + if mise run "$BUILD_CHECK_TASK" > report/build.json 2> report/build.stderr; then + BUILD_FAILED=false else - echo "stderr: $(cat "$RUNNER_TEMP/ts_build_stderr.log")" - echo "report={}" >> "$GITHUB_OUTPUT" + BUILD_FAILED=true fi - [ "$BUILD_OK" = "true" ] && echo "build_failed=false" >> "$GITHUB_OUTPUT" || echo "build_failed=true" >> "$GITHUB_OUTPUT" + jq -n --arg sdk "$SDK_NAME" --arg build_failed "$BUILD_FAILED" \ + '{sdk:$sdk, has_drift:"true", build_failed:$build_failed}' > report/status.json + - name: Upload SDK report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sdk-sync-${{ matrix.sdk.name }} + path: report/ + if-no-files-found: error + retention-days: 1 issue_management: name: Manage Drift Issue (${{ matrix.sdk.name }}) - needs: sdk_sync_check - if: always() && needs.sdk_sync_check.result == 'success' + needs: [load_config, sdk_sync_check] + if: always() && needs.load_config.result == 'success' && needs.sdk_sync_check.result == 'success' runs-on: linux-amd64-cpu8 + timeout-minutes: 5 container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -122,43 +98,36 @@ jobs: strategy: fail-fast: false matrix: - sdk: - - name: go - label: "sdk:go:sync" - has_drift: ${{ needs.sdk_sync_check.outputs.go_has_drift }} - build_failed: ${{ needs.sdk_sync_check.outputs.go_build_failed }} - drift_report: ${{ needs.sdk_sync_check.outputs.go_drift_report }} - build_report: ${{ needs.sdk_sync_check.outputs.go_build_report }} - - name: typescript - label: "sdk:typescript:sync" - has_drift: ${{ needs.sdk_sync_check.outputs.ts_has_drift }} - build_failed: ${{ needs.sdk_sync_check.outputs.ts_build_failed }} - drift_report: ${{ needs.sdk_sync_check.outputs.ts_drift_report }} - build_report: ${{ needs.sdk_sync_check.outputs.ts_build_report }} + sdk: ${{ fromJSON(needs.load_config.outputs.matrix) }} steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sdk-sync-${{ matrix.sdk.name }} + path: report + - name: Read SDK status + id: status + run: | + echo "has_drift=$(jq -r '.has_drift' report/status.json)" >> "$GITHUB_OUTPUT" + echo "build_failed=$(jq -r '.build_failed' report/status.json)" >> "$GITHUB_OUTPUT" - name: Warn on drift detection error - if: matrix.sdk.has_drift == 'error' + if: steps.status.outputs.has_drift == 'error' run: | - echo "::error::Drift detection failed for ${{ matrix.sdk.name }} SDK — check the sdk_sync_check job logs" + echo "::error::Drift detection failed for ${{ matrix.sdk.name }} SDK" + cat report/drift.stderr exit 1 - - - if: matrix.sdk.has_drift == 'true' && matrix.sdk.build_failed == 'true' + - if: steps.status.outputs.has_drift == 'true' && steps.status.outputs.build_failed == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install tools - if: matrix.sdk.has_drift == 'true' && matrix.sdk.build_failed == 'true' + if: steps.status.outputs.has_drift == 'true' && steps.status.outputs.build_failed == 'true' run: mise install --locked - - name: Create or update drift issue - if: matrix.sdk.has_drift == 'true' && matrix.sdk.build_failed == 'true' + if: steps.status.outputs.has_drift == 'true' && steps.status.outputs.build_failed == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DRIFT_REPORT: ${{ matrix.sdk.drift_report }} - BUILD_REPORT: ${{ matrix.sdk.build_report }} run: | RESULT=$(uv run python tasks/scripts/sdk_sync.py manage-issue \ - --drift-report "${DRIFT_REPORT:-{}}" \ - --build-report "${BUILD_REPORT:-{}}" \ + --drift-report "$(cat report/drift.json)" \ + --build-report "$(cat report/build.json)" \ --sdk "${{ matrix.sdk.name }}" \ --repo "$GITHUB_REPOSITORY" \ --label "${{ matrix.sdk.label }}") @@ -168,9 +137,8 @@ jobs: echo "::error::Issue management for ${{ matrix.sdk.name }} failed: $ACTION" exit 1 fi - - name: Close resolved drift issue - if: matrix.sdk.has_drift == 'false' || (matrix.sdk.has_drift == 'true' && matrix.sdk.build_failed == 'false') + if: steps.status.outputs.has_drift == 'false' || (steps.status.outputs.has_drift == 'true' && steps.status.outputs.build_failed == 'false') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/tasks/go.toml b/tasks/go.toml index f78334c164..8ff9dc7af6 100644 --- a/tasks/go.toml +++ b/tasks/go.toml @@ -138,110 +138,13 @@ hide = true ["go:proto:check"] description = "Verify generated Go SDK proto files are up to date" dir = "sdk/go" -run = """ -#!/usr/bin/env bash -set -euo pipefail - -SDK_ROOT=$(pwd -P) -REPO_ROOT=$(cd ../.. && pwd -P) - -for tool in buf protoc-gen-go protoc-gen-go-grpc; do - if ! command -v "$tool" &>/dev/null; then - echo "ERROR: $tool not found. Run 'mise install' to install it." - exit 1 - fi -done - -WORK_DIR=$(mktemp -d) -trap 'rm -rf "$WORK_DIR"' EXIT - -if find proto -maxdepth 1 -name '*.proto' -print -quit | grep -q .; then - echo "ERROR: sdk/go/proto contains copied proto sources." - echo "Proto sources belong in the repository root proto/ directory." - exit 1 -fi - -# Generate to temp directory with adjusted output path -CHECK_TEMPLATE=$(sed 's|out: sdk/go|out: '"$WORK_DIR"'|' buf.gen.yaml) -(cd "$REPO_ROOT" && buf generate --template "$CHECK_TEMPLATE") - -DIFF_OUTPUT=$(diff -r "$WORK_DIR/proto" "$SDK_ROOT/proto" 2>&1) || true - -if [ -n "$DIFF_OUTPUT" ]; then - echo "ERROR: Generated proto files are out of date." - echo "Run 'mise run go:proto:gen' to regenerate." - echo "" - echo "$DIFF_OUTPUT" - exit 1 -fi - -echo "Proto check passed: generated files are up to date." -""" +run = 'bash ../../tasks/scripts/go_proto_check.sh text' hide = true ["go:proto:drift"] description = "Check Go SDK proto drift and output a JSON report" dir = "sdk/go" -run = """ -#!/usr/bin/env bash -set -euo pipefail - -SDK_ROOT=$(pwd -P) -REPO_ROOT=$(cd ../.. && pwd -P) - -for tool in buf protoc-gen-go protoc-gen-go-grpc jq; do - if ! command -v "$tool" &>/dev/null; then - echo '{"sdk":"go","synced":false,"error":"'"$tool"' not found"}' - exit 1 - fi -done - -WORK_DIR=$(mktemp -d) -trap 'rm -rf "$WORK_DIR"' EXIT - -CHECK_TEMPLATE=$(sed 's|out: sdk/go|out: '"$WORK_DIR"'|' buf.gen.yaml) -if ! (cd "$REPO_ROOT" && buf generate --template "$CHECK_TEMPLATE") >/dev/null 2>&1; then - jq -n -c --arg sdk "go" '{sdk:$sdk, synced:false, files:[], summary:"buf generate failed"}' - exit 1 -fi - -NDJSON_FILE=$(mktemp) - -for f in $(find "$WORK_DIR/proto" -name '*.go' -type f | sort); do - REL=${f#"$WORK_DIR/proto/"} - COMMITTED="$SDK_ROOT/proto/$REL" - - if [ ! -f "$COMMITTED" ]; then - printf '%s\t%s\t%s\n' "$REL" "added" "0" >> "$NDJSON_FILE" - else - DIFF_LINES=$(diff -u "$COMMITTED" "$f" 2>/dev/null | wc -l | tr -d ' ') || true - if [ "$DIFF_LINES" -gt 0 ]; then - printf '%s\t%s\t%s\n' "$REL" "modified" "$DIFF_LINES" >> "$NDJSON_FILE" - fi - fi -done - -for f in $(find "$SDK_ROOT/proto" -name '*.go' -type f | sort); do - REL=${f#"$SDK_ROOT/proto/"} - REGEN="$WORK_DIR/proto/$REL" - if [ ! -f "$REGEN" ]; then - printf '%s\t%s\t%s\n' "$REL" "removed" "0" >> "$NDJSON_FILE" - fi -done - -jq -R -c -s --arg sdk "go" ' - split("\n") | map(select(length > 0) | split("\t") | - {name: .[0], status: .[1], diff_lines: (.[2] | tonumber)}) | - {sdk: $sdk, synced: (length == 0), files: ., - summary: (if length == 0 then "all files synced" - else "\\(length) file(s) drifted" end)} -' "$NDJSON_FILE" - -DRIFTED=$(wc -l < "$NDJSON_FILE" | tr -d ' ') -rm -f "$NDJSON_FILE" - -[ "$DRIFTED" -eq 0 ] && exit 0 || exit 1 -""" +run = 'bash ../../tasks/scripts/go_proto_check.sh json' hide = true ["go:proto:build-check"] diff --git a/tasks/scripts/go_proto_check.sh b/tasks/scripts/go_proto_check.sh new file mode 100755 index 0000000000..45a7aa2c15 --- /dev/null +++ b/tasks/scripts/go_proto_check.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +OUTPUT_FORMAT="${1:?Usage: go_proto_check.sh }" +if [ "$OUTPUT_FORMAT" != "text" ] && [ "$OUTPUT_FORMAT" != "json" ]; then + echo "ERROR: output format must be 'text' or 'json'." >&2 + exit 2 +fi + +SDK_ROOT=$(pwd -P) +REPO_ROOT=$(cd ../.. && pwd -P) + +emit_error() { + local message=$1 + if [ "$OUTPUT_FORMAT" = "json" ]; then + jq -n -c --arg message "$message" \ + '{sdk:"go", synced:false, files:[], summary:$message, error:$message}' + else + echo "ERROR: $message" >&2 + fi +} + +TOOLS=(buf protoc-gen-go protoc-gen-go-grpc) +if [ "$OUTPUT_FORMAT" = "json" ]; then + TOOLS+=(jq) +fi +for tool in "${TOOLS[@]}"; do + if ! command -v "$tool" &>/dev/null; then + if [ "$OUTPUT_FORMAT" = "json" ] && [ "$tool" = "jq" ]; then + echo '{"sdk":"go","synced":false,"files":[],"summary":"jq not found","error":"jq not found"}' + else + emit_error "$tool not found. Run 'mise install' to install it." + fi + exit 1 + fi +done + +if find proto -maxdepth 1 -name '*.proto' -print -quit | grep -q .; then + emit_error "sdk/go/proto contains copied proto sources; sources belong in the repository root proto/ directory." + exit 1 +fi + +WORK_DIR=$(mktemp -d) +RESULTS_FILE=$(mktemp) +GENERATION_LOG=$(mktemp) +trap 'rm -rf "$WORK_DIR"; rm -f "$RESULTS_FILE" "$GENERATION_LOG"' EXIT + +CHECK_TEMPLATE=$(sed 's|out: sdk/go|out: '"$WORK_DIR"'|' buf.gen.yaml) +if ! (cd "$REPO_ROOT" && buf generate --template "$CHECK_TEMPLATE") >"$GENERATION_LOG" 2>&1; then + if [ "$OUTPUT_FORMAT" = "text" ]; then + cat "$GENERATION_LOG" >&2 + fi + emit_error "buf generate failed" + exit 1 +fi + +while IFS= read -r generated; do + relative_path=${generated#"$WORK_DIR/proto/"} + committed="$SDK_ROOT/proto/$relative_path" + if [ ! -f "$committed" ]; then + printf '%s\t%s\t%s\n' "$relative_path" "added" "0" >>"$RESULTS_FILE" + continue + fi + + diff_lines=$(diff -u "$committed" "$generated" 2>/dev/null | wc -l | tr -d ' ') || true + if [ "$diff_lines" -gt 0 ]; then + printf '%s\t%s\t%s\n' "$relative_path" "modified" "$diff_lines" >>"$RESULTS_FILE" + fi +done < <(find "$WORK_DIR/proto" -name '*.go' -type f | sort) + +while IFS= read -r committed; do + relative_path=${committed#"$SDK_ROOT/proto/"} + if [ ! -f "$WORK_DIR/proto/$relative_path" ]; then + printf '%s\t%s\t%s\n' "$relative_path" "removed" "0" >>"$RESULTS_FILE" + fi +done < <(find "$SDK_ROOT/proto" -name '*.go' -type f | sort) + +if [ "$OUTPUT_FORMAT" = "text" ]; then + if [ ! -s "$RESULTS_FILE" ]; then + echo "Proto check passed: generated files are up to date." + exit 0 + fi + + echo "ERROR: Generated proto files are out of date." + echo "Run 'mise run go:proto:gen' to regenerate." + echo "" + while IFS=$'\t' read -r name status diff_lines; do + echo "$status: $name ($diff_lines diff lines)" + done <"$RESULTS_FILE" + exit 1 +fi + +REPORT=$(jq -R -c -s --arg sdk "go" ' + split("\n") | map(select(length > 0) | split("\t") | + {name: .[0], status: .[1], diff_lines: (.[2] | tonumber)}) | + {sdk: $sdk, synced: (length == 0), files: ., + summary: (if length == 0 then "all files synced" + else "\(length) file(s) drifted" end)} +' "$RESULTS_FILE") +echo "$REPORT" + +SYNCED=$(echo "$REPORT" | jq -r '.synced') +[ "$SYNCED" = "true" ] && exit 0 || exit 1 diff --git a/tasks/scripts/sdk_sync.py b/tasks/scripts/sdk_sync.py index 09990d49c8..8a0aca3bf5 100644 --- a/tasks/scripts/sdk_sync.py +++ b/tasks/scripts/sdk_sync.py @@ -20,29 +20,77 @@ import json import subprocess import sys +from pathlib import Path -SDK_CONFIGS = { - "go": { - "display_name": "Go", - "source_dirs": [ - "sdk/go/openshell/v1/internal/converter/", - "sdk/go/openshell/v1/types/", - "sdk/go/openshell/v1/", - ], - "proto_task": "go:proto:gen", - "build_task": "go:build", - "test_task": "go:test", - }, - "typescript": { - "display_name": "TypeScript", - "source_dirs": [ - "sdk/typescript/src/", - ], - "proto_task": "sdk:ts:proto", - "build_task": "sdk:ts:build", - "test_task": "sdk:ts:test", - }, -} + +def _load_sdk_configs() -> dict[str, dict]: + config_path = Path(__file__).resolve().parent.parent / "sdk-sync-config.json" + config = json.loads(config_path.read_text()) + return {entry["name"]: entry for entry in config["include"]} + + +SDK_CONFIGS = _load_sdk_configs() + +ISSUE_TEMPLATE = """\ +## Proto Drift Report + +**Summary**: {summary} + +{file_table} +{build_section} +## Fix Commands + +```bash +mise run {proto_task} # Regenerate bindings +mise run {build_task} # Verify build +mise run {test_task} # Run tests +``` + +## Agent Instructions + +This section is a ready-to-consume prompt for an AI agent. Copy it into your agent to produce a fix PR. + +
+Agent prompt (click to expand) + +{agent_section} +
+""" + +BUILD_SECTION_TEMPLATE = """\ +## Build Log + +**Failed step**: `{failed_step}` + +``` +{log} +``` + +""" + +AGENT_SECTION_TEMPLATE = """\ +Fix proto drift in the {display_name} SDK. + +## Context + +The root `proto/` directory has changed and the {display_name} SDK's generated bindings are out of sync. The drifted files are: {drifted_names}. +{build_context} + +## Steps + +1. **Regenerate bindings**: Run `mise run {proto_task}` to regenerate language-specific bindings from the updated protos. +2. **Fix compilation errors**: Read the build log above. Update the SDK source code to handle new/changed/removed proto fields: +{source_dirs} +3. **Fix test failures**: Update tests that assert on proto types that changed shape. +4. **Verify**: Run `mise run {build_task}` and `mise run {test_task}` until both pass. +5. **Create a PR**: Commit all changes and create a PR referencing this issue. + +## Scope + +- Only modify files under `sdk/{sdk}/`. Do not change root `proto/` files. +- Do not change the proto definitions. Adapt the SDK to match them. +- Keep changes minimal: only fix what the proto changes broke. +""" def _sdk_display_name(sdk: str) -> str: @@ -55,51 +103,52 @@ def generate_issue_body( sdk: str, max_log_lines: int = 500, ) -> str: - sections: list[str] = [] paths = SDK_CONFIGS[sdk] - - sections.append("## Proto Drift Report") - sections.append("") - summary = drift_report.get("summary", "unknown") - sections.append(f"**Summary**: {summary}") - sections.append("") - files = drift_report.get("files", []) drifted_files = [f for f in files if f.get("status") != "synced"] + return ISSUE_TEMPLATE.format( + summary=drift_report.get("summary", "unknown"), + file_table=_render_file_table(drifted_files), + build_section=_render_build_section(build_report, max_log_lines), + proto_task=paths["proto_task"], + build_task=paths["build_task"], + test_task=paths["test_task"], + agent_section=_render_agent_section(sdk, drifted_files, build_report), + ) - if drifted_files: - sections.extend(_render_file_table(drifted_files)) - sections.append("") - failed_step = ( - build_report["failed_step"] - if build_report and build_report.get("failed_step") - else "" +# --- helpers --- + + +def _render_file_table(files: list[dict]) -> str: + if not files: + return "" + lines = [ + "| File | Status | Diff Lines |", + "|------|--------|------------|", + ] + for f in files: + lines.append(f"| `{f['name']}` | {f['status']} | {f['diff_lines']} |") + return "\n".join(lines) + "\n\n" + + +def _render_build_section(build_report: dict | None, max_log_lines: int) -> str: + if not build_report or not build_report.get("failed_step"): + return "" + log_lines = build_report.get("log", "no log available").splitlines() + log = "\n".join(log_lines[-max_log_lines:]) + return BUILD_SECTION_TEMPLATE.format( + failed_step=build_report["failed_step"], + log=log, ) - if failed_step: - sections.append("## Build Log") - sections.append("") - sections.append(f"**Failed step**: `{failed_step}`") - sections.append("") - log = build_report.get("log", "no log available") - log_lines = log.splitlines() - if len(log_lines) > max_log_lines: - log = "\n".join(log_lines[-max_log_lines:]) - sections.append("```") - sections.append(log) - sections.append("```") - sections.append("") - - sections.append("## Fix Commands") - sections.append("") - sections.append("```bash") - sections.append(f"mise run {paths['proto_task']} # Regenerate bindings") - sections.append(f"mise run {paths['build_task']} # Verify build") - sections.append(f"mise run {paths['test_task']} # Run tests") - sections.append("```") - sections.append("") +def _render_agent_section( + sdk: str, drifted_files: list[dict], build_report: dict | None +) -> str: + paths = SDK_CONFIGS[sdk] + display_name = _sdk_display_name(sdk) + failed_step = build_report.get("failed_step") if build_report else None if drifted_files: drifted_names = ", ".join(f"`{f['name']}`" for f in drifted_files) elif sdk == "typescript": @@ -110,96 +159,26 @@ def generate_issue_body( else: drifted_names = "unknown" - sections.append("## Agent Instructions") - sections.append("") - sections.append( - "This section is a ready-to-consume prompt for an AI agent. " - "Copy it into your agent to produce a fix PR." - ) - sections.append("") - sections.append("
") - sections.append("Agent prompt (click to expand)") - sections.append("") - - display_name = _sdk_display_name(sdk) - sections.append(f"Fix proto drift in the {display_name} SDK.") - sections.append("") - sections.append("## Context") - sections.append("") - sections.append( - f"The root `proto/` directory has changed and the {display_name} SDK's" - ) - sections.append( - f"generated bindings are out of sync. The drifted files are: {drifted_names}." - ) - if failed_step: - sections.append( - f"The SDK build fails at the `{failed_step}` step after regenerating protos." - ) - sections.append( - "The build log above shows the exact error. Your job is to fix the" - ) - sections.append( + build_context = ( + f"\nThe SDK build fails at the `{failed_step}` step after regenerating protos. " + "The build log above shows the exact error. Your job is to fix the " f"{display_name} SDK code so it compiles and passes tests with the updated protos." ) else: - sections.append( - "The SDK build status is unknown. Check if it compiles after regeneration." - ) - - sections.append("") - sections.append("## Steps") - sections.append("") - sections.append( - f"1. **Regenerate bindings**: Run `mise run {paths['proto_task']}` to regenerate " - "language-specific bindings from the updated protos." - ) - sections.append( - "2. **Fix compilation errors**: Read the build log above. Update the SDK source code " - "to handle new/changed/removed proto fields:" - ) - for source_dir in paths["source_dirs"]: - sections.append(f" - `{source_dir}`") - sections.append( - "3. **Fix test failures**: Update tests that assert on proto types that changed shape." - ) - sections.append( - f"4. **Verify**: Run `mise run {paths['build_task']}` and " - f"`mise run {paths['test_task']}` until both pass." + build_context = "\nThe SDK build status is unknown. Check if it compiles after regeneration." + + source_dirs = "\n".join(f" - `{path}`" for path in paths["source_dirs"]) + return AGENT_SECTION_TEMPLATE.format( + display_name=display_name, + drifted_names=drifted_names, + build_context=build_context, + proto_task=paths["proto_task"], + source_dirs=source_dirs, + build_task=paths["build_task"], + test_task=paths["test_task"], + sdk=sdk, ) - sections.append( - "5. **Create a PR**: Commit all changes and create a PR referencing this issue." - ) - sections.append("") - sections.append("## Scope") - sections.append("") - sections.append( - f"- Only modify files under `sdk/{sdk}/`. Do not change root `proto/` files." - ) - sections.append( - "- Do not change the proto definitions. Adapt the SDK to match them." - ) - sections.append("- Keep changes minimal: only fix what the proto changes broke.") - - sections.append("") - sections.append("
") - sections.append("") - - return "\n".join(sections) - - -# --- helpers --- - - -def _render_file_table(files: list[dict]) -> list[str]: - lines = [ - "| File | Status | Diff Lines |", - "|------|--------|------------|", - ] - for f in files: - lines.append(f"| `{f['name']}` | {f['status']} | {f['diff_lines']} |") - return lines def _run_cmd( @@ -207,20 +186,28 @@ def _run_cmd( cwd: str | None = None, capture: bool = False, stdin_data: str | None = None, + timeout: int = 60, ) -> subprocess.CompletedProcess: - return subprocess.run( - cmd, - cwd=cwd, - capture_output=capture, - text=True, - input=stdin_data, - ) + try: + return subprocess.run( + cmd, + cwd=cwd, + capture_output=capture, + text=True, + input=stdin_data, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + command = " ".join(cmd) + raise RuntimeError( + f"Command timed out after {timeout} seconds: {command}" + ) from None def _ensure_label(repo: str, label: str, description: str) -> None: check = _run_cmd(["gh", "label", "view", label, "--repo", repo], capture=True) if check.returncode != 0: - _run_cmd( + result = _run_cmd( [ "gh", "label", @@ -235,6 +222,9 @@ def _ensure_label(repo: str, label: str, description: str) -> None: ], capture=True, ) + if result.returncode != 0: + details = result.stderr.strip() or "unknown error" + raise RuntimeError(f"Failed to create label '{label}': {details}") def _find_open_issue(repo: str, label: str) -> dict | None: @@ -275,7 +265,31 @@ def manage_issue( repo: str, label: str, ) -> dict: - _ensure_label(repo, label, f"Proto drift detected for {sdk} SDK") + try: + return _manage_issue(drift_report, build_report, sdk, repo, label) + except RuntimeError as error: + return { + "issue_url": "", + "action": "error", + "reason": str(error), + } + + +def _manage_issue( + drift_report: dict, + build_report: dict | None, + sdk: str, + repo: str, + label: str, +) -> dict: + try: + _ensure_label(repo, label, f"Proto drift detected for {sdk} SDK") + except RuntimeError as error: + return { + "issue_url": "", + "action": "error", + "reason": str(error), + } body = generate_issue_body(drift_report, build_report, sdk) title = f"SDK proto drift: {sdk}" diff --git a/tasks/scripts/sdk_sync_test.py b/tasks/scripts/sdk_sync_test.py index 6a38b6f93f..a654d6bd18 100644 --- a/tasks/scripts/sdk_sync_test.py +++ b/tasks/scripts/sdk_sync_test.py @@ -12,6 +12,7 @@ from unittest.mock import patch from sdk_sync import ( + _run_cmd, generate_issue_body, manage_issue, ) @@ -127,6 +128,46 @@ def test_agent_instructions_includes_failed_step(self): class TestManageIssue: + @patch("sdk_sync.subprocess.run") + def test_command_timeout_uses_default_and_becomes_runtime_error(self, mock_run): + mock_run.side_effect = subprocess.TimeoutExpired(["gh", "issue", "list"], 60) + + try: + _run_cmd(["gh", "issue", "list"]) + raise AssertionError("expected timeout error") + except RuntimeError as error: + assert str(error) == ("Command timed out after 60 seconds: gh issue list") + assert mock_run.call_args.kwargs["timeout"] == 60 + + @patch("sdk_sync._ensure_label") + def test_command_timeout_returns_structured_error(self, mock_label): + mock_label.side_effect = RuntimeError( + "Command timed out after 60 seconds: gh label view" + ) + + drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} + result = manage_issue(drift, None, "go", "org/repo", "sdk:go:sync") + + assert result["action"] == "error" + assert "timed out after 60 seconds" in result["reason"] + + @patch("sdk_sync._find_open_issue") + @patch("sdk_sync._ensure_label") + def test_label_creation_failure_stops_issue_management(self, mock_label, mock_find): + mock_label.side_effect = RuntimeError( + "Failed to create label 'sdk:go:sync': permission denied" + ) + + drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} + result = manage_issue(drift, None, "go", "org/repo", "sdk:go:sync") + + assert result == { + "issue_url": "", + "action": "error", + "reason": "Failed to create label 'sdk:go:sync': permission denied", + } + mock_find.assert_not_called() + @patch("sdk_sync._find_open_issue") @patch("sdk_sync._ensure_label") @patch("sdk_sync._run_cmd") diff --git a/tasks/sdk-sync-config.json b/tasks/sdk-sync-config.json new file mode 100644 index 0000000000..6df1597be3 --- /dev/null +++ b/tasks/sdk-sync-config.json @@ -0,0 +1,32 @@ +{ + "include": [ + { + "name": "go", + "display_name": "Go", + "label": "sdk:go:sync", + "drift_task": "go:proto:drift", + "build_check_task": "go:proto:build-check", + "proto_task": "go:proto:gen", + "build_task": "go:build", + "test_task": "go:test", + "source_dirs": [ + "sdk/go/openshell/v1/internal/converter/", + "sdk/go/openshell/v1/types/", + "sdk/go/openshell/v1/" + ] + }, + { + "name": "typescript", + "display_name": "TypeScript", + "label": "sdk:typescript:sync", + "drift_task": "sdk:ts:proto:drift", + "build_check_task": "sdk:ts:proto:build-check", + "proto_task": "sdk:ts:proto", + "build_task": "sdk:ts:build", + "test_task": "sdk:ts:test", + "source_dirs": [ + "sdk/typescript/src/" + ] + } + ] +} diff --git a/tasks/typescript.toml b/tasks/typescript.toml index 4ce2115dbe..5a727cc89b 100644 --- a/tasks/typescript.toml +++ b/tasks/typescript.toml @@ -88,13 +88,17 @@ fi LOG_FILE=$(mktemp) trap 'rm -f "$LOG_FILE"' EXIT -if mise run sdk:ts:proto > "$LOG_FILE" 2>&1 && mise run sdk:ts:typecheck >> "$LOG_FILE" 2>&1; then - jq -n -c '{sdk:"typescript", synced:true, files:[], summary:"all files synced"}' - exit 0 -else +if ! mise run sdk:ts:proto > "$LOG_FILE" 2>&1; then + jq -n -c '{sdk:"typescript", synced:false, files:[], summary:"proto generation failed"}' + exit 1 +fi + +if ! mise run sdk:ts:typecheck >> "$LOG_FILE" 2>&1; then jq -n -c '{sdk:"typescript", synced:false, files:[], summary:"typecheck failed after proto regeneration"}' exit 1 fi + +jq -n -c '{sdk:"typescript", synced:true, files:[], summary:"all files synced"}' """ hide = true From 8170db0cea421fcc42e9b63bea4a2e47efb14a90 Mon Sep 17 00:00:00 2001 From: Ignas Baranauskas Date: Wed, 9 Sep 2026 12:42:36 +0100 Subject: [PATCH 3/5] fix(ci): harden SDK drift issue lifecycle Signed-off-by: Ignas Baranauskas --- .github/workflows/sdk-sync-dashboard.yml | 30 +- tasks/scripts/sdk_sync.py | 153 ++++++-- tasks/scripts/sdk_sync_test.py | 453 ++++++++++++++--------- tasks/test.toml | 7 +- 4 files changed, 416 insertions(+), 227 deletions(-) diff --git a/.github/workflows/sdk-sync-dashboard.yml b/.github/workflows/sdk-sync-dashboard.yml index 962d882344..f073de2bd9 100644 --- a/.github/workflows/sdk-sync-dashboard.yml +++ b/.github/workflows/sdk-sync-dashboard.yml @@ -59,7 +59,7 @@ jobs: run: | mkdir -p report mise run "$DRIFT_TASK" > report/drift.json 2> report/drift.stderr || true - if ! jq -e 'has("synced") and (.synced | type == "boolean")' report/drift.json >/dev/null 2>&1; then + if ! jq -e 'type == "object" and has("synced") and (.synced | type == "boolean") and (has("error") | not)' report/drift.json >/dev/null 2>&1; then jq -n --arg sdk "$SDK_NAME" '{sdk:$sdk, has_drift:"error", build_failed:"false"}' > report/status.json exit 0 fi @@ -100,6 +100,7 @@ jobs: matrix: sdk: ${{ fromJSON(needs.load_config.outputs.matrix) }} steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: sdk-sync-${{ matrix.sdk.name }} @@ -115,35 +116,44 @@ jobs: echo "::error::Drift detection failed for ${{ matrix.sdk.name }} SDK" cat report/drift.stderr exit 1 - - if: steps.status.outputs.has_drift == 'true' && steps.status.outputs.build_failed == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install tools - if: steps.status.outputs.has_drift == 'true' && steps.status.outputs.build_failed == 'true' + if: steps.status.outputs.has_drift == 'true' run: mise install --locked - name: Create or update drift issue - if: steps.status.outputs.has_drift == 'true' && steps.status.outputs.build_failed == 'true' + if: steps.status.outputs.has_drift == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set +e RESULT=$(uv run python tasks/scripts/sdk_sync.py manage-issue \ --drift-report "$(cat report/drift.json)" \ --build-report "$(cat report/build.json)" \ --sdk "${{ matrix.sdk.name }}" \ --repo "$GITHUB_REPOSITORY" \ --label "${{ matrix.sdk.label }}") - echo "$RESULT" | jq . - ACTION=$(echo "$RESULT" | jq -r '.action // "unknown"') - if [ "$ACTION" = "error" ] || [ "$ACTION" = "unknown" ]; then + STATUS=$? + set -e + if ! printf '%s\n' "$RESULT" | jq .; then + printf '%s\n' "$RESULT" + fi + if [ "$STATUS" -ne 0 ]; then + echo "::error::Issue management command failed with exit code $STATUS" + exit "$STATUS" + fi + if ! ACTION=$(printf '%s\n' "$RESULT" | jq -er '.action // "unknown"'); then + ACTION=unknown + fi + if [ "$ACTION" != "created" ] && [ "$ACTION" != "updated" ]; then echo "::error::Issue management for ${{ matrix.sdk.name }} failed: $ACTION" exit 1 fi - name: Close resolved drift issue - if: steps.status.outputs.has_drift == 'false' || (steps.status.outputs.has_drift == 'true' && steps.status.outputs.build_failed == 'false') + if: steps.status.outputs.has_drift == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | ISSUE=$(gh issue list --repo "$GITHUB_REPOSITORY" --label "${{ matrix.sdk.label }}" --state open --json number --jq '.[0].number') if [ -n "$ISSUE" ]; then - gh issue close "$ISSUE" --repo "$GITHUB_REPOSITORY" --comment "SDK builds and tests pass after proto regeneration. Closing automatically." + gh issue close "$ISSUE" --repo "$GITHUB_REPOSITORY" --comment "The SDK proto drift check passed. Closing automatically." echo "Closed issue #$ISSUE" fi diff --git a/tasks/scripts/sdk_sync.py b/tasks/scripts/sdk_sync.py index 8a0aca3bf5..d56893066a 100644 --- a/tasks/scripts/sdk_sync.py +++ b/tasks/scripts/sdk_sync.py @@ -73,15 +73,15 @@ def _load_sdk_configs() -> dict[str, dict]: ## Context -The root `proto/` directory has changed and the {display_name} SDK's generated bindings are out of sync. The drifted files are: {drifted_names}. +The {display_name} SDK's proto drift check detected drift. The drifted files are: {drifted_names}. {build_context} ## Steps 1. **Regenerate bindings**: Run `mise run {proto_task}` to regenerate language-specific bindings from the updated protos. -2. **Fix compilation errors**: Read the build log above. Update the SDK source code to handle new/changed/removed proto fields: +2. **Fix compilation errors, if any**: Consult the build log if available. Update the SDK source code as needed to handle new/changed/removed proto fields: {source_dirs} -3. **Fix test failures**: Update tests that assert on proto types that changed shape. +3. **Fix test failures, if any**: Update tests that assert on proto types that changed shape. 4. **Verify**: Run `mise run {build_task}` and `mise run {test_task}` until both pass. 5. **Create a PR**: Commit all changes and create a PR referencing this issue. @@ -165,6 +165,19 @@ def _render_agent_section( "The build log above shows the exact error. Your job is to fix the " f"{display_name} SDK code so it compiles and passes tests with the updated protos." ) + elif build_report and build_report.get("success") is True: + if sdk == "go": + build_context = ( + "\nRegeneration, build, and tests passed in CI, but the " + "committed bindings still need to be regenerated and committed." + ) + else: + build_context = ( + "\nThe subsequent regeneration, typecheck, and tests passed in CI. " + "Generated TypeScript bindings are gitignored; rerun " + f"`mise run {paths['drift_task']}` to confirm compatibility " + "before changing SDK source." + ) else: build_context = "\nThe SDK build status is unknown. Check if it compiles after regeneration." @@ -205,26 +218,59 @@ def _run_cmd( def _ensure_label(repo: str, label: str, description: str) -> None: - check = _run_cmd(["gh", "label", "view", label, "--repo", repo], capture=True) + check = _run_cmd( + [ + "gh", + "label", + "list", + "--repo", + repo, + "--search", + label, + "--limit", + "20", + "--json", + "name", + ], + capture=True, + ) if check.returncode != 0: - result = _run_cmd( - [ - "gh", - "label", - "create", - label, - "--repo", - repo, - "--description", - description, - "--color", - "D93F0B", - ], - capture=True, + details = check.stderr.strip() or "unknown error" + raise RuntimeError(f"Failed to look up label '{label}' in {repo}: {details}") + try: + labels = json.loads(check.stdout) + except json.JSONDecodeError as error: + raise RuntimeError(f"Invalid label list for {repo}: {error.msg}") from error + if not isinstance(labels, list) or any( + not isinstance(item, dict) or not isinstance(item.get("name"), str) + for item in labels + ): + raise RuntimeError(f"Invalid label list for {repo}: expected label names") + # GitHub's search is fuzzy; similar SDK labels do not establish existence. + if any(item["name"].casefold() == label.casefold() for item in labels): + return + if len(labels) >= 20: + raise RuntimeError( + f"Label search for '{label}' in {repo} was truncated; cannot confirm absence" ) - if result.returncode != 0: - details = result.stderr.strip() or "unknown error" - raise RuntimeError(f"Failed to create label '{label}': {details}") + result = _run_cmd( + [ + "gh", + "label", + "create", + label, + "--repo", + repo, + "--description", + description, + "--color", + "D93F0B", + ], + capture=True, + ) + if result.returncode != 0: + details = result.stderr.strip() or "unknown error" + raise RuntimeError(f"Failed to create label '{label}': {details}") def _find_open_issue(repo: str, label: str) -> dict | None: @@ -239,20 +285,44 @@ def _find_open_issue(repo: str, label: str) -> dict | None: label, "--state", "open", + "--limit", + "1", "--json", "url,number", - "--jq", - ".[0]", ], capture=True, ) - if result.returncode == 0 and result.stdout.strip(): - try: - data = json.loads(result.stdout.strip()) - return {"url": data["url"], "number": str(data["number"])} - except (json.JSONDecodeError, KeyError, TypeError): - pass - return None + context = f"for {repo} with label '{label}'" + if result.returncode != 0: + details = result.stderr.strip() or "no stderr" + raise RuntimeError( + f"Failed to list open issues {context} " + f"(exit code {result.returncode}): {details}" + ) + try: + issues = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise RuntimeError( + f"Invalid issue list {context}: expected JSON array ({error.msg})" + ) from error + if not isinstance(issues, list): + raise RuntimeError(f"Invalid issue list {context}: expected JSON array") + if not issues: + return None + + issue = issues[0] + if not isinstance(issue, dict): + raise RuntimeError(f"Invalid issue list {context}: expected issue object") + url = issue.get("url") + number = issue.get("number") + if ( + not isinstance(url, str) + or not url.strip() + or type(number) is not int + or number <= 0 + ): + raise RuntimeError(f"Invalid issue list {context}: invalid issue URL or number") + return {"url": url, "number": str(number)} # --- public functions --- @@ -348,18 +418,23 @@ def _manage_issue( # --- CLI --- -def _load_json_arg(value: str) -> dict | list: - if value == "-": - return json.load(sys.stdin) - return json.loads(value) +def _load_json_arg(value: str) -> dict: + report = json.load(sys.stdin) if value == "-" else json.loads(value) + if not isinstance(report, dict): + raise ValueError("Expected report to be a JSON object") + return report def cmd_manage_issue(args: argparse.Namespace) -> int: - drift_report = _load_json_arg(args.drift_report) - build_report = None - if args.build_report: - build_report = _load_json_arg(args.build_report) - result = manage_issue(drift_report, build_report, args.sdk, args.repo, args.label) + try: + drift_report = _load_json_arg(args.drift_report) + build_report = _load_json_arg(args.build_report) if args.build_report else None + except ValueError as error: + result = {"issue_url": "", "action": "error", "reason": str(error)} + else: + result = manage_issue( + drift_report, build_report, args.sdk, args.repo, args.label + ) print(json.dumps(result)) return 1 if result.get("action") == "error" else 0 diff --git a/tasks/scripts/sdk_sync_test.py b/tasks/scripts/sdk_sync_test.py index a654d6bd18..0198cda3d4 100644 --- a/tasks/scripts/sdk_sync_test.py +++ b/tasks/scripts/sdk_sync_test.py @@ -1,198 +1,297 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Tests for tasks/scripts/sdk_sync.py. - -Run via: uv run --no-project --with pytest pytest tasks/scripts/sdk_sync_test.py -""" +"""SDK sync regression tests. Run via: mise run test:sdk-sync.""" from __future__ import annotations +import json +import os import subprocess +from argparse import Namespace +from pathlib import Path from unittest.mock import patch -from sdk_sync import ( - _run_cmd, - generate_issue_body, - manage_issue, -) +import pytest +import yaml +from sdk_sync import cmd_manage_issue, generate_issue_body +ISSUE = {"number": 42, "url": "https://github.com/org/repo/issues/42"} +LABELS = [{"name": "SDK:GO:SYNC"}] -def _mock_run(returncode=0, stdout="", stderr=""): - return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr) +def _response(stdout="", code=0, stderr=""): + return subprocess.CompletedProcess([], code, stdout=stdout, stderr=stderr) -class TestGenerateIssueBody: - def test_go_sdk_issue_body(self): - drift = { - "sdk": "go", - "synced": False, - "files": [ - { - "name": "openshellv1/openshell.pb.go", - "status": "modified", - "diff_lines": 5, - } - ], - "summary": "1 file(s) drifted", - } - md = generate_issue_body(drift, None, "go") - assert "## Proto Drift Report" in md - assert "`openshellv1/openshell.pb.go`" in md - assert "## Fix Commands" in md - assert "mise run go:proto:gen" in md - - def test_typescript_sdk_issue_body(self): - drift = { - "sdk": "typescript", - "synced": False, - "files": [], - "summary": "typecheck failed after proto regeneration", - } - md = generate_issue_body(drift, None, "typescript") - assert "mise run sdk:ts:proto" in md - assert "mise run sdk:ts:build" in md - assert "mise run sdk:ts:test" in md - assert "sdk/typescript/src/" in md - assert "not individually tracked" in md - assert "mise run sdk:ts:proto && mise run sdk:ts:typecheck" in md - - def test_with_build_log(self): - drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} - build = { - "sdk": "go", - "success": False, - "failed_step": "build", - "log": "error here", - } - md = generate_issue_body(drift, build, "go") - assert "## Build Log" in md - assert "`build`" in md - assert "error here" in md - - def test_log_truncation(self): - long_log = "\n".join(f"line {i}" for i in range(1000)) - drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} - build = { - "sdk": "go", - "success": False, - "failed_step": "test", - "log": long_log, - } - md = generate_issue_body(drift, build, "go", max_log_lines=500) - log_section = md.split("```")[1] - assert log_section.strip().count("\n") <= 500 - assert "line 999" in md - assert "line 0" not in md - - def test_agent_instructions_present(self): - drift = { - "sdk": "go", - "synced": False, - "files": [ - { - "name": "openshellv1/openshell.pb.go", - "status": "modified", - "diff_lines": 5, - } - ], - "summary": "1 file(s) drifted", - } - build = { - "sdk": "go", - "success": False, - "failed_step": "build", - "log": "error", - } - md = generate_issue_body(drift, build, "go") - assert "## Agent Instructions" in md - assert "Agent prompt" in md - assert "mise run go:proto:gen" in md - assert "sdk/go/openshell/v1/internal/converter/" in md - assert "sdk/go/openshell/v1/types/" in md - assert "sdk/go/openshell/v1/" in md - assert "Create a PR" in md - - def test_agent_instructions_includes_failed_step(self): - drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} - build = { - "sdk": "go", - "success": False, - "failed_step": "test", - "log": "fail", - } - md = generate_issue_body(drift, build, "go") - agent_section = md.split("## Agent Instructions")[1] - assert "`test`" in agent_section - assert "fails at" in agent_section.lower() - - -class TestManageIssue: - @patch("sdk_sync.subprocess.run") - def test_command_timeout_uses_default_and_becomes_runtime_error(self, mock_run): - mock_run.side_effect = subprocess.TimeoutExpired(["gh", "issue", "list"], 60) - - try: - _run_cmd(["gh", "issue", "list"]) - raise AssertionError("expected timeout error") - except RuntimeError as error: - assert str(error) == ("Command timed out after 60 seconds: gh issue list") - assert mock_run.call_args.kwargs["timeout"] == 60 - - @patch("sdk_sync._ensure_label") - def test_command_timeout_returns_structured_error(self, mock_label): - mock_label.side_effect = RuntimeError( - "Command timed out after 60 seconds: gh label view" - ) - drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} - result = manage_issue(drift, None, "go", "org/repo", "sdk:go:sync") +@pytest.fixture +def drift(): + return { + "sdk": "go", + "synced": False, + "summary": "1 file drifted", + "files": [{"name": "openshell.pb.go", "status": "modified", "diff_lines": 5}], + } - assert result["action"] == "error" - assert "timed out after 60 seconds" in result["reason"] - @patch("sdk_sync._find_open_issue") - @patch("sdk_sync._ensure_label") - def test_label_creation_failure_stops_issue_management(self, mock_label, mock_find): - mock_label.side_effect = RuntimeError( - "Failed to create label 'sdk:go:sync': permission denied" - ) +@pytest.fixture +def issue_args(drift): + return Namespace( + sdk="go", + repo="org/repo", + label="sdk:go:sync", + drift_report=json.dumps(drift), + build_report='{"success":true}', + ) - drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} - result = manage_issue(drift, None, "go", "org/repo", "sdk:go:sync") - assert result == { - "issue_url": "", - "action": "error", - "reason": "Failed to create label 'sdk:go:sync': permission denied", +@pytest.mark.parametrize( + ("sdk", "success", "context"), + [ + ("go", None, "build status is unknown"), + ("typescript", None, "not individually tracked"), + ("go", True, "committed bindings still need to be regenerated and committed"), + ("typescript", True, "gitignored"), + ("go", False, "fails at the `build` step"), + ], +) +def test_issue_body(drift, sdk, success, context): + report = {**drift, "sdk": sdk, "files": drift["files"] if sdk == "go" else []} + build = ( + None + if success is None + else { + "success": success, + "failed_step": None if success else "build", + "log": "compiler error", } - mock_find.assert_not_called() - - @patch("sdk_sync._find_open_issue") - @patch("sdk_sync._ensure_label") - @patch("sdk_sync._run_cmd") - def test_create_new_issue(self, mock_run, _mock_label, mock_find): - mock_find.return_value = None - mock_run.return_value = _mock_run( - 0, stdout="https://github.com/org/repo/issues/42\n" - ) + ) + body = generate_issue_body(report, build, sdk) + + assert context in body + assert "## Agent Instructions" in body + assert "Create a PR" in body + assert f"sdk/{sdk}/" in body + assert ("## Build Log" in body) == (success is False) + if sdk == "go": + assert "| `openshell.pb.go` | modified | 5 |" in body + assert "mise run go:proto:gen" in body + else: + assert "mise run sdk:ts:proto" in body + if success is False: + assert "compiler error" in body + + +def test_build_log_keeps_only_the_last_lines(drift): + build = {"failed_step": "test", "log": "discarded\nretained\nlast line"} + body = generate_issue_body(drift, build, "go", max_log_lines=2) + assert "discarded" not in body + assert "retained\nlast line" in body + + +@pytest.mark.parametrize("labels", [LABELS, [{"name": "area:sdk:go"}]]) +def test_create_then_update_preserves_label_and_issue( + issue_args, drift, labels, capsys +): + """Exercise all lifecycle helpers; mock only the GitHub subprocesses.""" + missing_label = labels != LABELS + responses = [_response(json.dumps(labels))] + operations = ["label list"] + if missing_label: + responses.append(_response()) + operations.append("label create") + responses += [ + _response("[]"), + _response(ISSUE["url"]), + _response(json.dumps(LABELS)), + _response(json.dumps([ISSUE])), + _response(), + ] + operations += [ + "issue list", + "issue create", + "label list", + "issue list", + "issue edit", + ] + + with patch("sdk_sync.subprocess.run", side_effect=responses) as github: + for action in ["created", "updated"]: + issue_args.drift_report = json.dumps({**drift, "summary": action}) + assert cmd_manage_issue(issue_args) == 0 + assert json.loads(capsys.readouterr().out) == { + "action": action, + "issue_url": ISSUE["url"], + } + cmd = github.call_args.args[0] + assert cmd[cmd.index("--body-file") + 1] == "-" + assert f"**Summary**: {action}" in github.call_args.kwargs["input"] + + assert [" ".join(call.args[0][1:3]) for call in github.call_args_list] == operations + assert github.call_args.args[0][3] == str(ISSUE["number"]) + + +@pytest.mark.parametrize( + ("before", "failure"), + [ + ([], _response(code=1, stderr="API unavailable")), + ([], _response("invalid JSON")), + ([], _response('[{"name":null}]')), + ([], _response(json.dumps([{"name": f"other-{i}"} for i in range(20)]))), + ([_response("[]")], _response(code=1, stderr="label creation denied")), + ([_response(json.dumps(LABELS))], _response(code=1, stderr="API unavailable")), + ([_response(json.dumps(LABELS))], _response("invalid JSON")), + ([_response(json.dumps(LABELS))], _response("null")), + ([_response(json.dumps(LABELS))], _response('[{"url":"url","number":true}]')), + ], +) +def test_lookup_errors_stop_before_issue_mutation(issue_args, capsys, before, failure): + with patch("sdk_sync.subprocess.run", side_effect=[*before, failure]) as github: + assert cmd_manage_issue(issue_args) == 1 + result = json.loads(capsys.readouterr().out) + assert result["action"] == "error" + assert result["reason"] + assert github.call_count == len(before) + 1 + assert all( + call.args[0][1:3] not in [["issue", "create"], ["issue", "edit"]] + for call in github.call_args_list + ) + + +def test_timeout_returns_an_error_without_retrying(issue_args, capsys): + with patch( + "sdk_sync.subprocess.run", side_effect=subprocess.TimeoutExpired("gh", 60) + ) as github: + assert cmd_manage_issue(issue_args) == 1 + result = json.loads(capsys.readouterr().out) + assert result["action"] == "error" + assert "timed out after 60 seconds" in result["reason"] + github.assert_called_once() + assert github.call_args.kwargs["timeout"] == 60 + + +@pytest.mark.parametrize( + ("field", "value"), [("drift_report", "invalid JSON"), ("build_report", "[]")] +) +def test_invalid_reports_never_call_github(issue_args, capsys, field, value): + setattr(issue_args, field, value) + with patch("sdk_sync.subprocess.run") as github: + assert cmd_manage_issue(issue_args) == 1 + github.assert_not_called() + assert json.loads(capsys.readouterr().out)["action"] == "error" + + +@pytest.fixture +def dashboard(): + path = ( + Path(__file__).resolve().parents[2] / ".github/workflows/sdk-sync-dashboard.yml" + ) + return yaml.safe_load(path.read_text())["jobs"] + + +def _step(dashboard, job, name): + return next(step for step in dashboard[job]["steps"] if step.get("name") == name) - drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} - result = manage_issue(drift, None, "go", "org/repo", "sdk:go:sync") - assert result["action"] == "created" - assert "42" in result["issue_url"] - - @patch("sdk_sync._find_open_issue") - @patch("sdk_sync._ensure_label") - @patch("sdk_sync._run_cmd") - def test_update_existing_issue(self, mock_run, _mock_label, mock_find): - mock_find.return_value = { - "url": "https://github.com/org/repo/issues/10", - "number": "10", - } - mock_run.return_value = _mock_run(0) - drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} - result = manage_issue(drift, None, "go", "org/repo", "sdk:go:sync") - assert result["action"] == "updated" - assert "10" in result["issue_url"] +def _run_shell(tmp_path, script, **env): + script = script.replace("${{ matrix.sdk.name }}", "go").replace( + "${{ matrix.sdk.label }}", "sdk:go:sync" + ) + return subprocess.run( + ["sh", "-e"], + input=script, + cwd=tmp_path, + text=True, + capture_output=True, + env={**os.environ, "GITHUB_REPOSITORY": "org/repo", **env}, + ) + + +@pytest.mark.parametrize( + ("report", "build_exit", "expected"), + [ + ('{"synced":true}', "0", "false"), + ('{"synced":false}', "0", "true"), + ('{"synced":false}', "1", "true"), + ('{"synced":false,"error":"generation failed"}', "0", "error"), + ("invalid JSON", "0", "error"), + ], +) +def test_workflow_classifies_drift(dashboard, tmp_path, report, build_exit, expected): + step = _step(dashboard, "sdk_sync_check", "Check proto drift and build") + stub = """mise() { + if [ "$2" = drift ]; then printf '%s\\n' "$REPORT"; return 1; fi + printf '%s\\n' '{}' + return "$BUILD_EXIT" + } + """ + result = _run_shell( + tmp_path, + stub + step["run"], + SDK_NAME="go", + DRIFT_TASK="drift", + BUILD_CHECK_TASK="build", + REPORT=report, + BUILD_EXIT=build_exit, + ) + assert result.returncode == 0, result.stderr + status = json.loads((tmp_path / "report/status.json").read_text()) + assert status["has_drift"] == expected + assert status["build_failed"] == ( + "true" if expected == "true" and build_exit == "1" else "false" + ) + + +@pytest.mark.parametrize( + ("output", "code", "succeeds"), + [ + ('{"action":"created"}', "0", True), + ('{"action":"updated"}', "0", True), + ('{"action":"error"}', "1", False), + ("invalid JSON", "0", False), + ("", "0", False), + ('{"action":"unexpected"}', "0", False), + ], +) +def test_workflow_requires_successful_issue_result( + dashboard, tmp_path, output, code, succeeds +): + step = _step(dashboard, "issue_management", "Create or update drift issue") + report = tmp_path / "report" + report.mkdir() + for name in ["drift", "build"]: + (report / f"{name}.json").write_text("{}") + stub = 'uv() { printf \'%s\\n\' "$OUTPUT"; return "$CODE"; }\n' + result = _run_shell(tmp_path, stub + step["run"], OUTPUT=output, CODE=code) + assert (result.returncode == 0) == succeeds, result.stdout + result.stderr + + +@pytest.mark.parametrize(("number", "code"), [("42", "0"), ("", "0"), ("", "1")]) +def test_workflow_closes_resolved_issue(dashboard, tmp_path, number, code): + step = _step(dashboard, "issue_management", "Close resolved drift issue") + stub = """gh() { + if [ "$2" = list ]; then printf '%s\\n' "$NUMBER"; return "$CODE"; fi + [ "$2" = close ] && [ "$3" = 42 ] || return 9 + printf 'closed\\n' > closed + } + """ + result = _run_shell(tmp_path, stub + step["run"], NUMBER=number, CODE=code) + assert result.returncode == int(code) + assert (tmp_path / "closed").exists() == bool(number) + + +def test_workflow_gates_issue_lifecycle_on_drift(dashboard): + steps = dashboard["issue_management"]["steps"] + checkout = next( + step for step in steps if step.get("uses", "").startswith("actions/checkout@") + ) + assert "if" not in checkout + for name in ["Install tools", "Create or update drift issue"]: + assert ( + _step(dashboard, "issue_management", name)["if"] + == "steps.status.outputs.has_drift == 'true'" + ) + assert ( + _step(dashboard, "issue_management", "Close resolved drift issue")["if"] + == "steps.status.outputs.has_drift == 'false'" + ) diff --git a/tasks/test.toml b/tasks/test.toml index 8c41e70025..cf34b3fcde 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -91,11 +91,16 @@ hide = true ["test:python"] description = "Run Python tests" -depends = ["python:proto"] +depends = ["python:proto", "test:sdk-sync"] env = { UV_NO_SYNC = "1" } run = "uv run pytest python/" hide = true +["test:sdk-sync"] +description = "Test SDK drift reports and issue lifecycle management" +run = "uv run --no-project --with pytest --with pyyaml pytest tasks/scripts/sdk_sync_test.py" +hide = true + ["e2e:rust"] description = "Run Rust CLI e2e tests against a Docker-backed gateway" depends = ["e2e:conformance:build"] From c8e1b8f071fcfeeeadcf5c18d4a87427c80f21a8 Mon Sep 17 00:00:00 2001 From: Ignas Baranauskas Date: Wed, 9 Sep 2026 19:46:34 +0100 Subject: [PATCH 4/5] fix(ci): address workflow security findings Signed-off-by: Ignas Baranauskas --- .github/workflows/sdk-proto-check.yml | 2 +- .github/workflows/sdk-sync-dashboard.yml | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sdk-proto-check.yml b/.github/workflows/sdk-proto-check.yml index 019c8bb7ae..b4f499f08c 100644 --- a/.github/workflows/sdk-proto-check.yml +++ b/.github/workflows/sdk-proto-check.yml @@ -50,7 +50,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 15 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:37072ee81cd7b294c714bfa5ecc829b6927b3d70 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sdk-sync-dashboard.yml b/.github/workflows/sdk-sync-dashboard.yml index f073de2bd9..2c3a8545f1 100644 --- a/.github/workflows/sdk-sync-dashboard.yml +++ b/.github/workflows/sdk-sync-dashboard.yml @@ -15,7 +15,6 @@ permissions: actions: read contents: read packages: read - issues: write concurrency: group: sdk-proto-sync @@ -39,7 +38,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 30 container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:37072ee81cd7b294c714bfa5ecc829b6927b3d70 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -90,8 +89,12 @@ jobs: if: always() && needs.load_config.result == 'success' && needs.sdk_sync_check.result == 'success' runs-on: linux-amd64-cpu8 timeout-minutes: 5 + permissions: + contents: read + issues: write + packages: read container: - image: ghcr.io/nvidia/openshell/ci:latest + image: ghcr.io/nvidia/openshell/ci:37072ee81cd7b294c714bfa5ecc829b6927b3d70 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} From 6bf6b8ca4e4c64d9099e186b9eca3ca02061341c Mon Sep 17 00:00:00 2001 From: Ignas Baranauskas Date: Mon, 14 Sep 2026 17:15:41 +0100 Subject: [PATCH 5/5] fix(ci): repin SDK workflows to a current CI image The SDK proto check and sync dashboard jobs pinned the CI container to image tag 37072ee8, which predates several mise.lock updates. Tools baked into that image no longer satisfied the lockfile, so every run re-downloaded go and yq from the GitHub release CDN and failed whenever that CDN returned a transient error. Repin to 5b9daab9, the current CI image build, so mise install resolves entirely from the baked toolchain. The pin stays an explicit tag to keep the zizmor unpinned-images rule satisfied. Signed-off-by: Ignas Baranauskas --- .github/workflows/sdk-proto-check.yml | 2 +- .github/workflows/sdk-sync-dashboard.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sdk-proto-check.yml b/.github/workflows/sdk-proto-check.yml index b4f499f08c..2d5660baa3 100644 --- a/.github/workflows/sdk-proto-check.yml +++ b/.github/workflows/sdk-proto-check.yml @@ -50,7 +50,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 15 container: - image: ghcr.io/nvidia/openshell/ci:37072ee81cd7b294c714bfa5ecc829b6927b3d70 + image: ghcr.io/nvidia/openshell/ci:5b9daab9351b1e053f9a5e0ce4c899f5d3f674b0 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sdk-sync-dashboard.yml b/.github/workflows/sdk-sync-dashboard.yml index 2c3a8545f1..09a40bdd1f 100644 --- a/.github/workflows/sdk-sync-dashboard.yml +++ b/.github/workflows/sdk-sync-dashboard.yml @@ -38,7 +38,7 @@ jobs: runs-on: linux-amd64-cpu8 timeout-minutes: 30 container: - image: ghcr.io/nvidia/openshell/ci:37072ee81cd7b294c714bfa5ecc829b6927b3d70 + image: ghcr.io/nvidia/openshell/ci:5b9daab9351b1e053f9a5e0ce4c899f5d3f674b0 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -94,7 +94,7 @@ jobs: issues: write packages: read container: - image: ghcr.io/nvidia/openshell/ci:37072ee81cd7b294c714bfa5ecc829b6927b3d70 + image: ghcr.io/nvidia/openshell/ci:5b9daab9351b1e053f9a5e0ce4c899f5d3f674b0 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}