From 6af3bbe2cd56284db0357a74ca0a552245ad9e0e Mon Sep 17 00:00:00 2001 From: Mira Date: Tue, 22 Sep 2026 19:05:09 -0700 Subject: [PATCH] ci: make Logwell SDK checks gate pull requests --- .github/CODEOWNERS | 4 ++ .github/workflows/ci.yml | 3 ++ .github/workflows/sdk-go.yml | 65 ++++++++++++++++++----- .github/workflows/sdk-python.yml | 76 +++++++++++++++++++++++++-- .github/workflows/sdk-typescript.yml | 77 ++++++++++++++++++++++++++-- scripts/test_sdk_ci_gate.py | 49 ++++++++++++++++++ 6 files changed, 254 insertions(+), 20 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 scripts/test_sdk_ci_gate.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..25e85bda --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# Require the repository owner's review for CI policy changes. +# Enable "Require review from Code Owners" on main after this file lands. +/.github/ @Divkix +/scripts/test_sdk_ci_gate.py @Divkix diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27607bbe..da01957d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: with: persist-credentials: false + - name: Validate SDK CI gates + run: python3 -m unittest discover -s scripts -p test_sdk_ci_gate.py + - name: Install pnpm and Node.js uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 with: diff --git a/.github/workflows/sdk-go.yml b/.github/workflows/sdk-go.yml index b390015e..bd5f90a5 100644 --- a/.github/workflows/sdk-go.yml +++ b/.github/workflows/sdk-go.yml @@ -8,9 +8,6 @@ on: - ".github/workflows/sdk-go.yml" pull_request: branches: [main] - paths: - - "sdks/go/**" - - ".github/workflows/sdk-go.yml" workflow_dispatch: permissions: @@ -29,11 +26,45 @@ env: GOTOOLCHAIN: local jobs: + changes: + name: SDK Go Change Detection + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.detect.outputs.run }} + steps: + - name: Checkout PR merge commit + if: github.event_name == 'pull_request' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Detect SDK changes + id: detect + working-directory: . + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [[ "$EVENT_NAME" != 'pull_request' ]]; then + echo 'run=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + git fetch --no-tags --depth=1 origin "$BASE_SHA" + if git diff --quiet "$BASE_SHA" HEAD -- sdks/go/ .github/workflows/sdk-go.yml; then + echo 'run=false' >> "$GITHUB_OUTPUT" + else + status=$? + if [[ "$status" -ne 1 ]]; then exit "$status"; fi + echo 'run=true' >> "$GITHUB_OUTPUT" + fi + # ============================================================================= # Lint # ============================================================================= lint: name: Lint + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -63,6 +94,8 @@ jobs: # ============================================================================= test: name: Test (Go ${{ matrix.go-version }}) + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 strategy: @@ -90,6 +123,8 @@ jobs: # ============================================================================= coverage: name: Coverage + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -127,21 +162,27 @@ jobs: # Final Status Check (required for branch protection) # ============================================================================= ci-success: - name: CI Success + name: SDK Go CI Success runs-on: ubuntu-latest timeout-minutes: 5 - needs: [lint, test, coverage] + needs: [changes, lint, test, coverage] if: always() + env: + CHANGE_RESULT: ${{ needs.changes.result }} + SHOULD_RUN: ${{ needs.changes.outputs.run }} + LINT: ${{ needs.lint.result }} + TEST: ${{ needs.test.result }} + COVERAGE: ${{ needs.coverage.result }} steps: - name: Check all jobs status run: | - results=("${{ needs.lint.result }}" "${{ needs.test.result }}" "${{ needs.coverage.result }}") - for result in "${results[@]}"; do - if [[ "$result" != "success" && "$result" != "skipped" ]]; then - echo "Job failed with result: $result" - exit 1 - fi + [[ "$CHANGE_RESULT" == 'success' ]] || exit 1 + if [[ "$SHOULD_RUN" == 'true' ]]; then expected=success + elif [[ "$SHOULD_RUN" == 'false' ]]; then expected=skipped + else exit 1 + fi + for result in "$LINT" "$TEST" "$COVERAGE"; do + [[ "$result" == "$expected" ]] || exit 1 done - echo "All jobs passed or were skipped" working-directory: . diff --git a/.github/workflows/sdk-python.yml b/.github/workflows/sdk-python.yml index de9c6495..460b2cd9 100644 --- a/.github/workflows/sdk-python.yml +++ b/.github/workflows/sdk-python.yml @@ -8,14 +8,10 @@ on: - ".github/workflows/sdk-python.yml" pull_request: branches: [main] - paths: - - "sdks/python/**" - - ".github/workflows/sdk-python.yml" workflow_dispatch: permissions: contents: read - id-token: write # Cancel in-progress runs on same branch/PR concurrency: @@ -27,11 +23,45 @@ defaults: working-directory: sdks/python jobs: + changes: + name: SDK Python Change Detection + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.detect.outputs.run }} + steps: + - name: Checkout PR merge commit + if: github.event_name == 'pull_request' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Detect SDK changes + id: detect + working-directory: . + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [[ "$EVENT_NAME" != 'pull_request' ]]; then + echo 'run=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + git fetch --no-tags --depth=1 origin "$BASE_SHA" + if git diff --quiet "$BASE_SHA" HEAD -- sdks/python/ .github/workflows/sdk-python.yml; then + echo 'run=false' >> "$GITHUB_OUTPUT" + else + status=$? + if [[ "$status" -ne 1 ]]; then exit "$status"; fi + echo 'run=true' >> "$GITHUB_OUTPUT" + fi + # ============================================================================= # Lint & Type Check # ============================================================================= lint: name: Lint & Type Check + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -68,6 +98,8 @@ jobs: # ============================================================================= test-unit: name: Unit Tests + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 strategy: @@ -101,6 +133,8 @@ jobs: # ============================================================================= test-integration: name: Integration Tests + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -131,6 +165,8 @@ jobs: # ============================================================================= coverage: name: Coverage Report + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -167,6 +203,8 @@ jobs: # ============================================================================= build: name: Build & Verify + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -211,6 +249,9 @@ jobs: # ============================================================================= publish: name: Publish to PyPI + permissions: + contents: read + id-token: write runs-on: ubuntu-latest timeout-minutes: 10 needs: [lint, test-unit, test-integration, coverage, build] @@ -282,3 +323,30 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "Version \`${{ steps.version-check.outputs.local_version }}\` already exists on PyPI." >> $GITHUB_STEP_SUMMARY echo "Bump the version in \`sdks/python/pyproject.toml\` to trigger a new publish." >> $GITHUB_STEP_SUMMARY + + sdk-ci-success: + name: SDK Python CI Success + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [changes, lint, test-unit, test-integration, coverage, build] + if: always() + env: + CHANGE_RESULT: ${{ needs.changes.result }} + SHOULD_RUN: ${{ needs.changes.outputs.run }} + LINT: ${{ needs.lint.result }} + UNIT: ${{ needs.test-unit.result }} + INTEGRATION: ${{ needs.test-integration.result }} + COVERAGE: ${{ needs.coverage.result }} + BUILD: ${{ needs.build.result }} + steps: + - name: Require all applicable SDK checks + working-directory: . + run: | + [[ "$CHANGE_RESULT" == 'success' ]] || exit 1 + if [[ "$SHOULD_RUN" == 'true' ]]; then expected=success + elif [[ "$SHOULD_RUN" == 'false' ]]; then expected=skipped + else exit 1 + fi + for result in "$LINT" "$UNIT" "$INTEGRATION" "$COVERAGE" "$BUILD"; do + [[ "$result" == "$expected" ]] || exit 1 + done diff --git a/.github/workflows/sdk-typescript.yml b/.github/workflows/sdk-typescript.yml index ac0ea481..6b4d0d44 100644 --- a/.github/workflows/sdk-typescript.yml +++ b/.github/workflows/sdk-typescript.yml @@ -6,16 +6,16 @@ on: paths: - "sdks/typescript/**" - ".github/workflows/sdk-typescript.yml" + - "pnpm-workspace.yaml" + - "tsconfig.json" + - "package.json" + - "pnpm-lock.yaml" pull_request: branches: [main] - paths: - - "sdks/typescript/**" - - ".github/workflows/sdk-typescript.yml" workflow_dispatch: permissions: contents: read - id-token: write # Cancel in-progress runs on same branch/PR concurrency: @@ -27,11 +27,45 @@ defaults: working-directory: sdks/typescript jobs: + changes: + name: SDK TypeScript Change Detection + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.detect.outputs.run }} + steps: + - name: Checkout PR merge commit + if: github.event_name == 'pull_request' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Detect SDK changes + id: detect + working-directory: . + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [[ "$EVENT_NAME" != 'pull_request' ]]; then + echo 'run=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + git fetch --no-tags --depth=1 origin "$BASE_SHA" + if git diff --quiet "$BASE_SHA" HEAD -- sdks/typescript/ .github/workflows/sdk-typescript.yml pnpm-workspace.yaml tsconfig.json package.json pnpm-lock.yaml; then + echo 'run=false' >> "$GITHUB_OUTPUT" + else + status=$? + if [[ "$status" -ne 1 ]]; then exit "$status"; fi + echo 'run=true' >> "$GITHUB_OUTPUT" + fi + # ============================================================================= # Lint & Type Check # ============================================================================= lint: name: Lint & Type Check + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -72,6 +106,8 @@ jobs: # ============================================================================= test-unit: name: Unit Tests + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -100,6 +136,8 @@ jobs: # ============================================================================= test-integration: name: Integration Tests + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -128,6 +166,8 @@ jobs: # ============================================================================= build: name: Build & Verify + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -169,6 +209,9 @@ jobs: # ============================================================================= publish: name: Publish to npm + permissions: + contents: read + id-token: write runs-on: ubuntu-latest timeout-minutes: 10 needs: [lint, test-unit, test-integration, build] @@ -285,3 +328,29 @@ jobs: echo "## ℹ️ JSR Publish Skipped" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Version \`${{ steps.jsr-version-check.outputs.jsr_version }}\` already exists on JSR." >> $GITHUB_STEP_SUMMARY + + sdk-ci-success: + name: SDK TypeScript CI Success + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [changes, lint, test-unit, test-integration, build] + if: always() + env: + CHANGE_RESULT: ${{ needs.changes.result }} + SHOULD_RUN: ${{ needs.changes.outputs.run }} + LINT: ${{ needs.lint.result }} + UNIT: ${{ needs.test-unit.result }} + INTEGRATION: ${{ needs.test-integration.result }} + BUILD: ${{ needs.build.result }} + steps: + - name: Require all applicable SDK checks + working-directory: . + run: | + [[ "$CHANGE_RESULT" == 'success' ]] || exit 1 + if [[ "$SHOULD_RUN" == 'true' ]]; then expected=success + elif [[ "$SHOULD_RUN" == 'false' ]]; then expected=skipped + else exit 1 + fi + for result in "$LINT" "$UNIT" "$INTEGRATION" "$BUILD"; do + [[ "$result" == "$expected" ]] || exit 1 + done diff --git a/scripts/test_sdk_ci_gate.py b/scripts/test_sdk_ci_gate.py new file mode 100644 index 00000000..af654c08 --- /dev/null +++ b/scripts/test_sdk_ci_gate.py @@ -0,0 +1,49 @@ +"""Keep the required SDK CI checks present on every pull request.""" + +from pathlib import Path +import unittest + +WORKFLOWS = Path(__file__).resolve().parents[1] / ".github" / "workflows" +SDK_JOBS = { + "sdk-typescript.yml": ("TypeScript", "sdks/typescript/", 4), + "sdk-python.yml": ("Python", "sdks/python/", 5), + "sdk-go.yml": ("Go", "sdks/go/", 3), +} + + +class SdkCiGateConfigTests(unittest.TestCase): + def test_all_sdk_workflows_report_a_unique_gate_on_every_pr(self): + for filename, (sdk, path, job_count) in SDK_JOBS.items(): + with self.subTest(sdk=sdk): + content = (WORKFLOWS / filename).read_text() + pr_trigger = content.split(" pull_request:\n", 1)[1].split(" workflow_dispatch:", 1)[0] + self.assertNotIn("paths:", pr_trigger, "path-filtered workflow can omit a required check") + self.assertIn(f"name: SDK {sdk} CI Success", content) + self.assertIn(f'git diff --quiet "$BASE_SHA" HEAD -- {path}', content) + if sdk == "TypeScript": + shared = ("pnpm-workspace.yaml", "tsconfig.json", "package.json", "pnpm-lock.yaml") + self.assertIn(" ".join(shared), content) + for filename_on_push in shared: + self.assertIn(f' - "{filename_on_push}"', content.split(" pull_request:", 1)[0]) + self.assertGreaterEqual( + content.count("if: needs.changes.outputs.run == 'true'"), job_count + ) + self.assertIn("if: always()", content) + + def test_oidc_is_not_granted_to_pull_request_validation_jobs(self): + for filename in ("sdk-typescript.yml", "sdk-python.yml"): + with self.subTest(workflow=filename): + content = (WORKFLOWS / filename).read_text() + header = content.split("\njobs:\n", 1)[0] + publish = content.split(" publish:\n", 1)[1].split(" sdk-ci-success:\n", 1)[0] + self.assertNotIn("id-token: write", header) + self.assertIn(" permissions:\n contents: read\n id-token: write", publish) + + def test_gate_definitions_have_a_code_owner(self): + owners = (WORKFLOWS.parent / "CODEOWNERS").read_text() + self.assertIn("/.github/ @Divkix", owners.splitlines()) + self.assertIn("/scripts/test_sdk_ci_gate.py @Divkix", owners.splitlines()) + + +if __name__ == "__main__": + unittest.main()