diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 45b7b461a3..8c7d272bb9 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -287,6 +287,151 @@ jobs: package-name: cuopt_sh_client package-type: python publish-wheel-search-key: cuopt_wheel_python_cuopt-sh-client + release-github: + # Attaches the built wheels to a GitHub Release on the version tag, so Kitmaker + # can pull them from a public Release URL (Kitmaker itself never reaches inside NVIDIA). + if: startsWith(github.ref, 'refs/tags/v') + needs: + - wheel-publish-libcuopt + - wheel-publish-cuopt + - wheel-publish-cuopt-server + - wheel-publish-cuopt-sh-client + permissions: + contents: write + runs-on: linux-amd64-cpu4 + outputs: + release-tag: ${{ steps.release.outputs.tag }} + steps: + - name: Download built wheels + uses: actions/download-artifact@v4 + with: + pattern: "wheel_*" + path: dist + merge-multiple: true + - name: Create GitHub Release and upload wheels + id: release + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${GITHUB_REF#refs/tags/}" + mapfile -t wheels < <(find dist -name '*.whl') + gh release create "$tag" \ + --repo "${{ github.repository }}" \ + --title "$tag" \ + --generate-notes \ + "${wheels[@]}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + # Each job below calls the Kitmaker Portal release API (via Charon Ferry) to publish + # one package's wheels from the GitHub Release above to pypi.org / pypi.nvidia.com. + # Kitmaker projects are 1:1 with wheel/component name x CUDA major version (except + # cuopt-sh-client, a pure package with no CUDA suffix), mirroring wheel-publish-* above. + kitmaker-portal-release-libcuopt-cu12: + if: startsWith(github.ref, 'refs/tags/v') + needs: release-github + permissions: + id-token: write + contents: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: ./.github/workflows/kitmaker_portal.yaml + with: + mode: release + release_tag: ${{ needs.release-github.outputs.release-tag }} + project_name: libcuopt-cu12 + project_id: ${{ vars.KITMAKER_PROJECT_ID_LIBCUOPT_CU12 }} + wheel_asset_pattern: "^libcuopt_cu12-" + upload: false # TODO: flip to true once POC is validated + kitmaker-portal-release-libcuopt-cu13: + if: startsWith(github.ref, 'refs/tags/v') + needs: release-github + permissions: + id-token: write + contents: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: ./.github/workflows/kitmaker_portal.yaml + with: + mode: release + release_tag: ${{ needs.release-github.outputs.release-tag }} + project_name: libcuopt-cu13 + project_id: ${{ vars.KITMAKER_PROJECT_ID_LIBCUOPT_CU13 }} + wheel_asset_pattern: "^libcuopt_cu13-" + upload: false # TODO: flip to true once POC is validated + kitmaker-portal-release-cuopt-cu12: + if: startsWith(github.ref, 'refs/tags/v') + needs: release-github + permissions: + id-token: write + contents: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: ./.github/workflows/kitmaker_portal.yaml + with: + mode: release + release_tag: ${{ needs.release-github.outputs.release-tag }} + project_name: cuopt-cu12 + project_id: ${{ vars.KITMAKER_PROJECT_ID_CUOPT_CU12 }} + wheel_asset_pattern: "^cuopt_cu12-" + upload: false # TODO: flip to true once POC is validated + kitmaker-portal-release-cuopt-cu13: + if: startsWith(github.ref, 'refs/tags/v') + needs: release-github + permissions: + id-token: write + contents: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: ./.github/workflows/kitmaker_portal.yaml + with: + mode: release + release_tag: ${{ needs.release-github.outputs.release-tag }} + project_name: cuopt-cu13 + project_id: ${{ vars.KITMAKER_PROJECT_ID_CUOPT_CU13 }} + wheel_asset_pattern: "^cuopt_cu13-" + upload: false # TODO: flip to true once POC is validated + kitmaker-portal-release-cuopt-server-cu12: + if: startsWith(github.ref, 'refs/tags/v') + needs: release-github + permissions: + id-token: write + contents: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: ./.github/workflows/kitmaker_portal.yaml + with: + mode: release + release_tag: ${{ needs.release-github.outputs.release-tag }} + project_name: cuopt-server-cu12 + project_id: ${{ vars.KITMAKER_PROJECT_ID_CUOPT_SERVER_CU12 }} + wheel_asset_pattern: "^cuopt_server_cu12-" + upload: false # TODO: flip to true once POC is validated + kitmaker-portal-release-cuopt-server-cu13: + if: startsWith(github.ref, 'refs/tags/v') + needs: release-github + permissions: + id-token: write + contents: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: ./.github/workflows/kitmaker_portal.yaml + with: + mode: release + release_tag: ${{ needs.release-github.outputs.release-tag }} + project_name: cuopt-server-cu13 + project_id: ${{ vars.KITMAKER_PROJECT_ID_CUOPT_SERVER_CU13 }} + wheel_asset_pattern: "^cuopt_server_cu13-" + upload: false # TODO: flip to true once POC is validated + kitmaker-portal-release-cuopt-sh-client: + if: startsWith(github.ref, 'refs/tags/v') + needs: release-github + permissions: + id-token: write + contents: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: ./.github/workflows/kitmaker_portal.yaml + with: + mode: release + release_tag: ${{ needs.release-github.outputs.release-tag }} + project_name: cuopt-sh-client + project_id: ${{ vars.KITMAKER_PROJECT_ID_CUOPT_SH_CLIENT }} + wheel_asset_pattern: "^cuopt_sh_client-" + upload: false # TODO: flip to true once POC is validated + tests: # Docker image build / tests aren't necessary for the 'test.yaml' workflow, # so 'test.yaml' can be triggered without waiting for those. diff --git a/.github/workflows/kitmaker_portal.yaml b/.github/workflows/kitmaker_portal.yaml new file mode 100644 index 0000000000..338bb5390b --- /dev/null +++ b/.github/workflows/kitmaker_portal.yaml @@ -0,0 +1,293 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Kitmaker Portal (via Charon Ferry) + +on: + workflow_call: + inputs: + mode: + description: | + mode: 'smoke-test' polls an existing Kitmaker Portal status ID (no side effects). + 'release' requests a new release for 'release_tag' and polls it to completion. + required: true + type: string + status_id: + description: "status_id: existing Kitmaker Portal status UUID. Required for mode == 'smoke-test'." + type: string + release_tag: + description: "release_tag: git tag whose GitHub Release assets should be published to Kitmaker. Required for mode == 'release'." + type: string + project_name: + description: "project_name: Kitmaker project name. Must exactly match the wheel/component name. Required for mode == 'release'." + type: string + project_id: + description: "project_id: numeric Kitmaker project ID (from GET /api/v0/projects). Required for mode == 'release'." + type: string + wheel_asset_pattern: + description: | + wheel_asset_pattern: extended regex (matched with jq's 'test()') selecting this + project's wheel/sdist asset names out of the GitHub Release, e.g. '^libcuopt-'. + Required for mode == 'release'. + type: string + pic: + description: "pic: 'person in charge' email required by the Kitmaker release API. Defaults to secrets.KITMAKER_PORTAL_ACC_OWNER_EMAIL." + type: string + upload: + description: | + upload: forwarded to the Kitmaker release API. 'false' (default) runs validation + checks only, with no real publish. Must be explicitly set to 'true' to actually + publish wheels. Only used for mode == 'release'. + type: boolean + default: false + outputs: + status_id: + description: Kitmaker Portal status UUID created by mode == 'release'. + value: ${{ jobs.release.outputs.status_id }} + workflow_dispatch: + inputs: + mode: + description: "mode: 'smoke-test' polls an existing status ID. 'release' requests a new release and polls it." + required: true + type: choice + options: + - smoke-test + - release + status_id: + description: "status_id: existing Kitmaker Portal status UUID. Required for mode == 'smoke-test'." + type: string + release_tag: + description: "release_tag: git tag whose GitHub Release assets should be published to Kitmaker. Required for mode == 'release'." + type: string + project_name: + description: "project_name: Kitmaker project name. Must exactly match the registered project. Required for mode == 'release'." + type: string + project_id: + description: "project_id: numeric Kitmaker project ID (from GET /api/v0/projects). Required for mode == 'release'." + type: string + wheel_asset_pattern: + description: | + wheel_asset_pattern: extended regex (matched with jq's 'test()') selecting this + project's wheel/sdist asset names out of the GitHub Release, e.g. '^libcuopt-'. + Required for mode == 'release'. + type: string + pic: + description: "pic: 'person in charge' email required by the Kitmaker release API. Defaults to secrets.KITMAKER_PORTAL_ACC_OWNER_EMAIL." + type: string + upload: + description: | + upload: forwarded to the Kitmaker release API. 'false' (default) runs validation + checks only, with no real publish. Only used for mode == 'release'. + type: boolean + default: false + +permissions: + id-token: write + contents: read + +jobs: + smoke-test: + if: inputs.mode == 'smoke-test' + runs-on: ubuntu-latest + env: + # Referenced here (unused) so GitHub Actions masks it in this job's logs too, + # since a polled status response can echo a release's pic email back. + PIC: ${{ secrets.KITMAKER_PORTAL_ACC_OWNER_EMAIL }} + steps: + - name: Install Teleport + uses: teleport-actions/setup@v1 + with: + version: auto + proxy: nv-stg-ps.teleport.sh:443 + + - name: Start staging Ferry tunnel + uses: teleport-actions/application-tunnel@v1 + with: + proxy: nv-stg-ps.teleport.sh:443 + token: charon-gha-runners + app: charon + listen: tcp://127.0.0.1:8888 + + - name: Mint Ferry identity token + id: ferry-token + uses: actions/github-script@v7 + with: + script: | + const token = await core.getIDToken('charon.nvidia.com'); + core.setSecret(token); + core.setOutput('token', token); + + - name: Poll Kitmaker Portal status + env: + FERRY_TOKEN: ${{ steps.ferry-token.outputs.token }} + PORTAL_AUTHORIZATION: ${{ secrets.KITMAKER_PORTAL_AUTHORIZATION }} + STATUS_ID: ${{ inputs.status_id }} + run: | + if [[ -z "$STATUS_ID" ]]; then + echo "::error::mode == 'smoke-test' requires 'status_id'" + exit 1 + fi + + curl --fail-with-body --show-error --retry 3 --retry-delay 5 \ + -H "X-Charon-GHA-Token: $FERRY_TOKEN" \ + -H "Authorization: Bearer $PORTAL_AUTHORIZATION" \ + "http://127.0.0.1:8888/kitmaker-portal/api/v0/status/$STATUS_ID" + + release: + if: inputs.mode == 'release' + runs-on: ubuntu-latest + outputs: + status_id: ${{ steps.kitmaker-release.outputs.status_id }} + env: + GH_TOKEN: ${{ github.token }} + PROJECT_NAME: ${{ inputs.project_name }} + PROJECT_ID: ${{ inputs.project_id }} + ASSET_PATTERN: ${{ inputs.wheel_asset_pattern }} + PIC: ${{ inputs.pic || secrets.KITMAKER_PORTAL_ACC_OWNER_EMAIL }} + steps: + - name: Validate inputs + run: | + [[ -n "${{ inputs.release_tag }}" ]] || { echo "::error::mode == 'release' requires 'release_tag'"; exit 1; } + [[ -n "$PROJECT_NAME" ]] || { echo "::error::mode == 'release' requires 'project_name'"; exit 1; } + [[ -n "$PROJECT_ID" ]] || { echo "::error::mode == 'release' requires 'project_id'"; exit 1; } + [[ -n "$ASSET_PATTERN" ]] || { echo "::error::mode == 'release' requires 'wheel_asset_pattern'"; exit 1; } + [[ -n "$PIC" ]] || { echo "::error::'pic' input or secrets.KITMAKER_PORTAL_ACC_OWNER_EMAIL must be set"; exit 1; } + + - name: Collect wheel asset URLs from the GitHub Release + id: assets + env: + RELEASE_TAG: ${{ inputs.release_tag }} + UPLOAD: ${{ inputs.upload }} + run: | + urls_json=$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json assets \ + --jq "[.assets[] | select(.name | test(\"$ASSET_PATTERN\")) | .url]") + + if [[ "$(echo "$urls_json" | jq 'length')" -eq 0 ]]; then + echo "::error::No assets on release '$RELEASE_TAG' matched pattern '$ASSET_PATTERN'" + exit 1 + fi + + echo "upload=$UPLOAD (false = validate only, no real publish; true = publishes for real)" + + payload=$(echo "$urls_json" | jq -c --arg pic "$PIC" --argjson upload "$UPLOAD" ' + map({pic: $pic, job_type: "wheel-release-job", url: ., upload: $upload}) + ') + echo "payload=$payload" >> "$GITHUB_OUTPUT" + + - name: Install Teleport + uses: teleport-actions/setup@v1 + with: + version: auto + proxy: nv-stg-ps.teleport.sh:443 + + - name: Start staging Ferry tunnel + uses: teleport-actions/application-tunnel@v1 + with: + proxy: nv-stg-ps.teleport.sh:443 + token: charon-gha-runners + app: charon + listen: tcp://127.0.0.1:8888 + + - name: Mint Ferry identity token + id: ferry-token + uses: actions/github-script@v7 + with: + script: | + const token = await core.getIDToken('charon.nvidia.com'); + core.setSecret(token); + core.setOutput('token', token); + + - name: Request Kitmaker Portal release + id: kitmaker-release + env: + FERRY_TOKEN: ${{ steps.ferry-token.outputs.token }} + PORTAL_AUTHORIZATION: ${{ secrets.KITMAKER_PORTAL_AUTHORIZATION }} + PAYLOAD: ${{ steps.assets.outputs.payload }} + run: | + body=$(jq -n --arg project_name "$PROJECT_NAME" --argjson payload "$PAYLOAD" \ + '{project_name: $project_name, payload: $payload}') + + http_response=$(curl -sS --retry 3 --retry-delay 5 -w '\n%{http_code}' \ + -H "X-Charon-GHA-Token: $FERRY_TOKEN" \ + -H "Authorization: Bearer $PORTAL_AUTHORIZATION" \ + -H "Content-Type: application/json" \ + -d "$body" \ + "http://127.0.0.1:8888/kitmaker-portal/api/v0/projects/${PROJECT_ID}/releases") + + http_code=$(echo "$http_response" | tail -n1) + response=$(echo "$http_response" | sed '$d') + + echo "HTTP $http_code" + echo "$response" + + if [[ "$http_code" != "202" ]]; then + exit 1 + fi + + status_id=$(echo "$response" | jq -r '.release_uuid') + echo "status_id=$status_id" >> "$GITHUB_OUTPUT" + + - name: Poll Kitmaker Portal status + env: + FERRY_TOKEN: ${{ steps.ferry-token.outputs.token }} + PORTAL_AUTHORIZATION: ${{ secrets.KITMAKER_PORTAL_AUTHORIZATION }} + STATUS_ID: ${{ steps.kitmaker-release.outputs.status_id }} + run: | + for _ in $(seq 1 60); do + http_response=$(curl -sS --retry 3 --retry-delay 5 -w '\n%{http_code}' \ + -H "X-Charon-GHA-Token: $FERRY_TOKEN" \ + -H "Authorization: Bearer $PORTAL_AUTHORIZATION" \ + "http://127.0.0.1:8888/kitmaker-portal/api/v0/status/$STATUS_ID") + http_code=$(echo "$http_response" | tail -n1) + response=$(echo "$http_response" | sed '$d') + + if [[ "$http_code" != "200" ]]; then + echo "HTTP $http_code" + echo "$response" + exit 1 + fi + + status=$(echo "$response" | jq -r '.status') + echo "Kitmaker Portal release status: $status" + case "$status" in + completed) + exit 0 + ;; + failed) + echo "$response" + exit 1 + ;; + *) ;; # still in progress, keep polling + esac + sleep 30 + done + echo "Timed out waiting for Kitmaker Portal release to complete" + exit 1 + + - name: Notify Slack on failure + if: failure() + env: + SLACK_BOT_TOKEN: ${{ secrets.CUOPT_SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ secrets.CUOPT_SLACK_CHANNEL_ID }} + SLACK_MENTION_ID: ${{ secrets.CUOPT_SLACK_MENTION_ID }} + PROJECT_NAME: ${{ inputs.project_name }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [[ -z "$SLACK_BOT_TOKEN" || -z "$SLACK_CHANNEL_ID" ]]; then + echo "SLACK_BOT_TOKEN or SLACK_CHANNEL_ID not set, skipping notification." + exit 0 + fi + + mention="" + if [[ "$SLACK_MENTION_ID" == S* ]]; then + mention=" " + elif [[ -n "$SLACK_MENTION_ID" ]]; then + mention="<@${SLACK_MENTION_ID}> " + fi + text="${mention}Kitmaker Portal release failed for project '${PROJECT_NAME}': ${RUN_URL}" + + curl -sS --max-time 30 -X POST \ + -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n --arg channel "$SLACK_CHANNEL_ID" --arg text "$text" '{channel: $channel, text: $text}')" \ + "https://slack.com/api/chat.postMessage" diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 3169519c0a..f89e3394c8 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -7,6 +7,43 @@ on: push: branches: - "pull-request/[0-9]+" + workflow_dispatch: + inputs: + kitmaker_mode: + description: | + kitmaker_mode: POC entry point for testing kitmaker_portal.yaml without merging it + to main first ('workflow_dispatch' can't target a brand-new file on another branch, + but this already-registered workflow can 'uses:' one via workflow_call). + 'smoke-test' polls 'kitmaker_status_id'. 'release' requests a release using the + kitmaker_* inputs below. + required: true + type: choice + default: smoke-test + options: + - smoke-test + - release + kitmaker_status_id: + description: "kitmaker_status_id: existing Kitmaker Portal status UUID. Required for kitmaker_mode == 'smoke-test'." + type: string + kitmaker_release_tag: + description: "kitmaker_release_tag: git tag whose GitHub Release assets should be published. Required for kitmaker_mode == 'release'." + type: string + kitmaker_project_name: + description: "kitmaker_project_name: Kitmaker project name. Required for kitmaker_mode == 'release'." + type: string + kitmaker_project_id: + description: "kitmaker_project_id: numeric Kitmaker project ID. Required for kitmaker_mode == 'release'." + type: string + kitmaker_wheel_asset_pattern: + description: "kitmaker_wheel_asset_pattern: regex selecting wheel/sdist assets from the release. Required for kitmaker_mode == 'release'." + type: string + kitmaker_pic: + description: "kitmaker_pic: 'person in charge' email required by the Kitmaker release API." + type: string + kitmaker_upload: + description: "kitmaker_upload: 'false' (default) validates only, no real publish. Only used for kitmaker_mode == 'release'." + type: boolean + default: false concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -585,6 +622,29 @@ jobs: with: build_type: pull-request script: ci/test_self_hosted_service.sh + kitmaker-portal-test: + # Manual-only POC call to validate the Charon Ferry -> kitmaker-portal.nvidia.com + # path before wiring the real publish call in build.yaml. Also used as a workaround + # to test kitmaker_portal.yaml before merging it to main: 'workflow_dispatch' can't + # target a workflow file that only exists on a branch, but this already-registered + # workflow can 'uses:' one via workflow_call. Not part of pr-builder's required + # checks since it isn't triggered by normal PR pushes. + if: github.event_name == 'workflow_dispatch' + permissions: + id-token: write + contents: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: ./.github/workflows/kitmaker_portal.yaml + with: + mode: ${{ inputs.kitmaker_mode }} + status_id: ${{ inputs.kitmaker_status_id }} + release_tag: ${{ inputs.kitmaker_release_tag }} + project_name: ${{ inputs.kitmaker_project_name }} + project_id: ${{ inputs.kitmaker_project_id }} + wheel_asset_pattern: ${{ inputs.kitmaker_wheel_asset_pattern }} + pic: ${{ inputs.kitmaker_pic }} + upload: ${{ inputs.kitmaker_upload }} + pr-test-summary: name: "PR test summary (non-blocking)" needs: