diff --git a/.github/workflows/internal-ci-trigger.yml b/.github/workflows/internal-ci-trigger.yml new file mode 100644 index 000000000..9225a1a80 --- /dev/null +++ b/.github/workflows/internal-ci-trigger.yml @@ -0,0 +1,281 @@ +# MVP: Trigger internal ADO pipeline from GitHub PR comments. +# Production version will add: private repo ref push, CredScan redaction, +# and fork PR support. +name: Internal CI Trigger +run-name: "Internal CI for PR #${{ github.event.issue.number }}" + +on: + issue_comment: + types: [created] + +jobs: + trigger-internal-ci: + # Only run on PR comments (not issue comments) that are /test commands. + if: >- + github.event.issue.pull_request + && ( + github.event.comment.body == '/test' + || startsWith(github.event.comment.body, '/test ') + ) + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: read + statuses: write + steps: + - name: Validate test command + env: + COMMENT_BODY: ${{ github.event.comment.body }} + run: | + if [[ ! "$COMMENT_BODY" =~ ^/test([[:space:]]+force)?[[:space:]]*$ ]]; then + echo "::error::Unsupported internal CI command. Use /test or /test force." + exit 1 + fi + + - name: Check authorization + id: auth + env: + GH_TOKEN: ${{ github.token }} + MICROSOFT_GITHUB_ORG: microsoft + MS_ORG_READ_TOKEN: ${{ secrets.MS_ORG_READ_TOKEN }} + run: | + COMMENTER="${{ github.event.comment.user.login }}" + + # Check if the commenter has write access to the repo + PERMISSION=$(gh api "repos/${{ github.repository }}/collaborators/${COMMENTER}/permission" \ + --jq '.permission') + + if [[ "$PERMISSION" != "admin" && "$PERMISSION" != "write" ]]; then + echo "::error::User ${COMMENTER} does not have write access (permission: ${PERMISSION})" + exit 1 + fi + + if [[ -z "${MS_ORG_READ_TOKEN:-}" ]]; then + echo "::error::MS_ORG_READ_TOKEN secret is required to verify Microsoft org membership" + exit 1 + fi + + if ! GH_TOKEN="$MS_ORG_READ_TOKEN" gh api "orgs/${MICROSOFT_GITHUB_ORG}/members/${COMMENTER}" --silent; then + echo "::error::User ${COMMENTER} is not a member of ${MICROSOFT_GITHUB_ORG}, or membership could not be verified" + exit 1 + fi + + echo "authorized=true" >> "$GITHUB_OUTPUT" + echo "User ${COMMENTER} authorized (permission: ${PERMISSION}, org: ${MICROSOFT_GITHUB_ORG})" + + - name: React to comment + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" \ + -f content='eyes' --silent + + - name: Get PR details and pinned SHA + id: pr + env: + GH_TOKEN: ${{ github.token }} + run: | + PR_NUMBER="${{ github.event.issue.number }}" + + PR_DATA=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}" \ + --jq '{sha: .head.sha, branch: .head.ref, head_repo: .head.repo.full_name, base_repo: .base.repo.full_name}') + + COMMIT_SHA=$(echo "$PR_DATA" | jq -r '.sha') + SOURCE_BRANCH=$(echo "$PR_DATA" | jq -r '.branch') + HEAD_REPO=$(echo "$PR_DATA" | jq -r '.head_repo') + BASE_REPO=$(echo "$PR_DATA" | jq -r '.base_repo') + + # MVP: reject fork PRs — they require additional handling + if [[ "$HEAD_REPO" != "$BASE_REPO" ]]; then + echo "::error::Fork PRs are not supported yet (head: ${HEAD_REPO}, base: ${BASE_REPO})" + exit 1 + fi + + echo "commit_sha=${COMMIT_SHA}" >> "$GITHUB_OUTPUT" + echo "source_branch=${SOURCE_BRANCH}" >> "$GITHUB_OUTPUT" + echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + + echo "PR #${PR_NUMBER} at SHA ${COMMIT_SHA} (branch: ${SOURCE_BRANCH})" + + - name: Path filter — skip if only irrelevant files changed + id: pathfilter + env: + GH_TOKEN: ${{ github.token }} + COMMENT_BODY: ${{ github.event.comment.body }} + run: | + PR_NUMBER="${{ steps.pr.outputs.pr_number }}" + + # Allow override: `/test force` bypasses the path filter + if echo "$COMMENT_BODY" | grep -qiE '^/test[[:space:]]+force\b'; then + echo "Path filter bypassed via '/test force'" + echo "skip=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Files matching any of these patterns are considered irrelevant to internal CI. + # If the PR touches *only* these, we skip the expensive run. + IGNORE_PATTERNS=( + '*.md' + '**/*.md' + 'LICENSE' + 'NOTICE' + 'CODEOWNERS' + 'MAINTAINERS.md' + '.gitignore' + '.gitattributes' + 'docs/**' + '.github/**' + 'rfcs/**' + 'licenses/**' + ) + + # Get list of changed files (paginated, up to 3000) + mapfile -t FILES < <(gh api --paginate \ + "repos/${{ github.repository }}/pulls/${PR_NUMBER}/files" \ + --jq '.[].filename') + + if [[ ${#FILES[@]} -eq 0 ]]; then + echo "::warning::No files reported changed; running CI anyway" + echo "skip=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Changed files (${#FILES[@]}):" + printf ' %s\n' "${FILES[@]}" + + shopt -s globstar extglob nullglob + RELEVANT=0 + for f in "${FILES[@]}"; do + matched=0 + for pat in "${IGNORE_PATTERNS[@]}"; do + # shellcheck disable=SC2053 + if [[ "$f" == $pat ]]; then + matched=1 + break + fi + done + if [[ $matched -eq 0 ]]; then + echo "Relevant file: $f" + RELEVANT=1 + break + fi + done + + if [[ $RELEVANT -eq 0 ]]; then + echo "All changed files match ignore patterns — skipping internal CI" + echo "skip=true" >> "$GITHUB_OUTPUT" + + gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ + -f body="⏭️ Internal CI skipped: PR only touches docs/CI/license files. Use \`/test force\` to override." \ + --silent + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set pending commit status + if: steps.pathfilter.outputs.skip != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/${{ github.repository }}/statuses/${{ steps.pr.outputs.commit_sha }}" \ + -f state='pending' \ + -f description='Internal CI triggered, waiting for results...' \ + -f context='ms-internal-ci/oss-jstests' \ + --silent + + - name: POST webhook to ADO + if: steps.pathfilter.outputs.skip != 'true' + env: + MS_WEBHOOK_SECRET: ${{ secrets.MS_WEBHOOK_SECRET }} + GH_TOKEN: ${{ github.token }} + run: | + PR_NUMBER="${{ steps.pr.outputs.pr_number }}" + COMMIT_SHA="${{ steps.pr.outputs.commit_sha }}" + SOURCE_BRANCH="${{ steps.pr.outputs.source_branch }}" + + if [[ -z "${MS_WEBHOOK_SECRET:-}" ]]; then + echo "::error::MS_WEBHOOK_SECRET secret is required to trigger ADO" + exit 1 + fi + + # Build a comment-shaped payload so each /test comment is a distinct + # webhook event while still carrying the pinned PR SHA for ADO. + PAYLOAD=$(jq -n \ + --arg action "created" \ + --arg pr_number "$PR_NUMBER" \ + --arg sha "$COMMIT_SHA" \ + --arg branch "$SOURCE_BRANCH" \ + --arg repo "${{ github.repository }}" \ + --arg triggerer "${{ github.event.comment.user.login }}" \ + --arg comment_id "${{ github.event.comment.id }}" \ + --arg comment_url "${{ github.event.comment.html_url }}" \ + --arg comment_created_at "${{ github.event.comment.created_at }}" \ + '{ + action: $action, + number: ($pr_number | tonumber), + triggerer: $triggerer, + comment: { + id: ($comment_id | tonumber), + html_url: $comment_url, + created_at: $comment_created_at, + user: { + login: $triggerer + } + }, + issue: { + number: ($pr_number | tonumber), + pull_request: { + url: "https://api.github.com/repos/\($repo)/pulls/\($pr_number)" + } + }, + repository: { + full_name: $repo + }, + pull_request: { + html_url: "https://github.com/\($repo)/pull/\($pr_number)", + head: { + sha: $sha, + ref: $branch, + repo: { + full_name: $repo + } + }, + base: { + ref: "main" + }, + user: { + login: $triggerer + } + } + }') + + # Compute the raw HMAC-SHA1 hex signature expected by the ADO + # documentdb-oss-external-ci-webhook service connection. Unsigned + # and bad-signature requests must fail before queuing ADO pipeline 56298. + SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha1 -hmac "$MS_WEBHOOK_SECRET" -binary | xxd -p -c 256) + + # POST to ADO incoming webhook endpoint + HTTP_STATUS=$(curl -sS -o /dev/null -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-GitHub-ADO-Signature: $SIGNATURE" \ + -d "$PAYLOAD" \ + "https://dev.azure.com/msdata/_apis/public/distributedtask/webhooks/documentdb-oss-external-ci-webhook?api-version=6.0-preview") + + echo "ADO webhook response: HTTP ${HTTP_STATUS}" + + if [[ "$HTTP_STATUS" -lt 200 || "$HTTP_STATUS" -ge 300 ]]; then + echo "::error::ADO webhook POST failed with HTTP ${HTTP_STATUS}" + + # Set error status so the PR doesn't stay pending forever + gh api "repos/${{ github.repository }}/statuses/${COMMIT_SHA}" \ + -f state='error' \ + -f description='Failed to trigger internal CI pipeline' \ + -f context='ms-internal-ci/oss-jstests' \ + --silent + exit 1 + fi + + echo "Webhook triggered successfully"