diff --git a/.github/workflows/batch-merge-release-branch.yaml b/.github/workflows/batch-merge-release-branch.yaml index 83cef5ce..2b08a1b6 100644 --- a/.github/workflows/batch-merge-release-branch.yaml +++ b/.github/workflows/batch-merge-release-branch.yaml @@ -23,7 +23,7 @@ jobs: - name: Get current release branch id: get_current_release_branch - uses: PickNikRobotics/moveit_pro_ci/.github/actions/find_release_branch@d8676de7c5e49d274277cf536b6d41fbacf71642 # v0.2.0 + uses: PickNikRobotics/moveit_pro_ci/.github/actions/find_release_branch@baf17129fd55f858b7965fb971fa333cf45463cb # v0.3.2 - name: Merge release branch into main id: merge diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3631feb6..e51608ae 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,19 +9,151 @@ on: description: 'The tag of the image to use for the container' required: false default: '' + repository_dispatch: + # Fired by the paired moveit_pro repo when a PR there finishes its image + # push. The payload carries `image_ref` (full GHCR reference with `{0}` + # placeholder for ros_distro), `image_tag`, `base_branch`, `moveit_pro_sha`, + # and `moveit_pro_pr` so this workflow can run the integration suite + # against the just-built image and post a commit status back to that PR. + types: [moveit_pro_pr] # Run every 6 hours Mon-Fri schedule: - cron: "0 */6 * * 1-5" concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + # Dispatch-triggered runs from different moveit_pro PRs all share the same + # `github.ref` (this repo's default branch), so a plain ref-keyed group + # would let one dispatch cancel another and lose its status post-back. + # Include the dispatch payload's `moveit_pro_sha` / `moveit_pro_pr` (and + # for non-dispatch events, the PR head SHA) so each PR gets its own slot. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.client_payload.moveit_pro_sha || github.event.client_payload.moveit_pro_pr || github.event.pull_request.head.sha || github.ref }} cancel-in-progress: true jobs: - integration-test-in-studio-container: + # Compute the inputs we forward to the reusable integration workflow and to + # the rollup status post-back. The logic differs by trigger: + # - `pull_request`: image_tag = PR base ref; checkout uses the PR head + # by default (no explicit `git_ref`). If the PR body contains a + # `needs: moveit_pro/#N` token, also fetch that moveit_pro PR's head + # SHA so we can pull its private GHCR image as `image_ref` and post + # the rollup status back to the moveit_pro PR. + # - `repository_dispatch`: every value comes from the payload, including + # `git_ref` (the version-paired example_ws branch to check out) since + # the dispatch event itself has no PR context. + # - everything else (push, schedule, workflow_dispatch): image_tag = the + # triggering ref's branch name. + resolve: + name: Resolve dispatch context + runs-on: ubuntu-22.04 + outputs: + image_ref: ${{ steps.resolve.outputs.image_ref }} + image_tag: ${{ steps.resolve.outputs.image_tag }} + git_ref: ${{ steps.resolve.outputs.git_ref }} + moveit_pro_sha: ${{ steps.resolve.outputs.moveit_pro_sha }} + moveit_pro_pr_number: ${{ steps.resolve.outputs.moveit_pro_pr_number }} + steps: + - name: Detect `needs:` token in PR body + id: detect_needs + if: github.event_name == 'pull_request' + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + set -e + matched="$(printf '%s' "$PR_BODY" | grep -oiE 'needs:[[:space:]]*moveit_pro/#[0-9]+' || true)" + if [ -n "$matched" ]; then + pr_num="$(printf '%s' "$matched" | grep -oE '[0-9]+' | head -1)" + echo "moveit_pro_pr=$pr_num" >> "$GITHUB_OUTPUT" + echo "Detected paired moveit_pro PR: #$pr_num" + else + echo "No paired moveit_pro PR in body." + fi + + - name: Generate cross-repo App token + id: app-token + if: steps.detect_needs.outputs.moveit_pro_pr != '' || github.event_name == 'repository_dispatch' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.SISTER_REPOS_APP_CLIENT_ID }} + private-key: ${{ secrets.SISTER_REPOS_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: | + moveit_pro_example_ws + moveit_pro + + - name: Resolve inputs + id: resolve + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MOVEIT_PRO_PR_FROM_BODY: ${{ steps.detect_needs.outputs.moveit_pro_pr }} + GHCR_URL: ${{ vars.GHCR_URL }} + with: + # Falls back to the default GITHUB_TOKEN when the App token wasn't + # minted (no paired PR, not a dispatch). The default is sufficient + # for everything else this step does. + github-token: ${{ steps.app-token.outputs.token || github.token }} + script: | + const event = context.eventName; + const ghcrUrl = process.env.GHCR_URL || 'ghcr.io/picknikrobotics'; + let image_ref = ''; + let image_tag = ''; + let git_ref = ''; + let moveit_pro_sha = ''; + let moveit_pro_pr_number = ''; + + const sanitizeBranch = (s) => s.replace(/[^a-zA-Z0-9._-]/g, '-'); + + if (event === 'repository_dispatch') { + const p = context.payload.client_payload || {}; + image_ref = p.image_ref || ''; + image_tag = p.image_tag || p.base_branch || 'main'; + git_ref = p.base_branch || ''; + moveit_pro_sha = p.moveit_pro_sha || ''; + moveit_pro_pr_number = String(p.moveit_pro_pr || ''); + } else if (event === 'pull_request') { + image_tag = context.payload.pull_request.base.ref; + const needsPr = process.env.MOVEIT_PRO_PR_FROM_BODY; + if (needsPr) { + const { data: pr } = await github.rest.pulls.get({ + owner: 'PickNikRobotics', + repo: 'moveit_pro', + pull_number: parseInt(needsPr, 10), + }); + moveit_pro_sha = pr.head.sha; + moveit_pro_pr_number = needsPr; + image_ref = `${ghcrUrl}/moveit-studio:${sanitizeBranch(pr.head.ref)}-{0}-amd64`; + } + } else { + // push, schedule, workflow_dispatch + image_tag = (context.ref || 'refs/heads/main').replace(/^refs\/heads\//, ''); + } + + core.info(`event=${event}`); + core.info(`image_ref=${image_ref}`); + core.info(`image_tag=${image_tag}`); + core.info(`git_ref=${git_ref}`); + core.info(`moveit_pro_sha=${moveit_pro_sha}`); + core.info(`moveit_pro_pr_number=${moveit_pro_pr_number}`); + + core.setOutput('image_ref', image_ref); + core.setOutput('image_tag', image_tag); + core.setOutput('git_ref', git_ref); + core.setOutput('moveit_pro_sha', moveit_pro_sha); + core.setOutput('moveit_pro_pr_number', moveit_pro_pr_number); + + integration-test: + needs: resolve + strategy: + fail-fast: false + matrix: + # Stream 2 expands this list one PR at a time as each sim gains a + # pytest entry. Today only lab_sim has one. + config_package: [lab_sim] uses: PickNikRobotics/moveit_pro_ci/.github/workflows/workspace_integration_test.yaml@baf17129fd55f858b7965fb971fa333cf45463cb # v0.3.2 with: - image_tag: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref_name }} + image_tag: ${{ needs.resolve.outputs.image_tag }} + image_ref: ${{ needs.resolve.outputs.image_ref }} + git_ref: ${{ needs.resolve.outputs.git_ref }} + config_package: ${{ matrix.config_package }} colcon_test_args: "--executor sequential" runner: "picknik-16-amd64" # Coarsen MuJoCo timestep on CI (default 0.002s = 500Hz) so the heavier 3.6.0 @@ -32,6 +164,96 @@ jobs: secrets: moveit_license_key: ${{ secrets.STUDIO_CI_LICENSE_KEY }} + # Single byte-identical commit-status context (`example_ws / integration`) + # is posted from here only. Per the design doc: the matrix jobs must NOT + # post their own statuses, or branch protection / dedup semantics break. + integration-status: + name: Post integration status + needs: [resolve, integration-test] + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Generate cross-repo App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.SISTER_REPOS_APP_CLIENT_ID }} + private-key: ${{ secrets.SISTER_REPOS_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: | + moveit_pro_example_ws + moveit_pro + + - name: Post commit status + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + INTEGRATION_RESULT: ${{ needs.integration-test.result }} + MOVEIT_PRO_SHA: ${{ needs.resolve.outputs.moveit_pro_sha }} + EVENT_NAME: ${{ github.event_name }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const result = process.env.INTEGRATION_RESULT; + // Map GitHub Actions job result → Commit Status API state. + // success/failure are obvious; cancelled / skipped map to error + // so reviewers see something other than a stale "pending". + const state = result === 'success' ? 'success' + : (result === 'cancelled' || result === 'skipped') ? 'error' + : 'failure'; + const description = ({ + success: 'Integration tests passed', + failure: 'Integration tests failed', + cancelled: 'Integration tests cancelled', + skipped: 'Integration tests skipped', + })[result] || `Integration tests result: ${result}`; + // Byte-identical across every post: GitHub deduplicates commit + // statuses by `(sha, context)`, so the dispatch-only run that + // fired against `:main` can be overwritten by the paired run. + // Drift here would break that dedup and leave stale checks. + const contextString = 'example_ws / integration'; + const target_url = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const targets = []; + // example_ws PR head SHA (when triggered by pull_request). + if (process.env.EVENT_NAME === 'pull_request') { + targets.push({ + owner: 'PickNikRobotics', + repo: 'moveit_pro_example_ws', + sha: context.payload.pull_request.head.sha, + label: 'example_ws PR', + }); + } + // moveit_pro PR commit SHA (either resolved via `needs:` token in + // an example_ws PR body, or carried by a `repository_dispatch` + // payload from moveit_pro CI). + const moveitProSha = process.env.MOVEIT_PRO_SHA; + if (moveitProSha) { + targets.push({ + owner: 'PickNikRobotics', + repo: 'moveit_pro', + sha: moveitProSha, + label: 'moveit_pro PR', + }); + } + + if (targets.length === 0) { + core.info('No commit-status targets resolved (likely a push / schedule run). Nothing to post.'); + return; + } + + for (const t of targets) { + core.info(`Posting state=${state} to ${t.label} (${t.owner}/${t.repo}@${t.sha}).`); + await github.rest.repos.createCommitStatus({ + owner: t.owner, + repo: t.repo, + sha: t.sha, + state, + context: contextString, + description, + target_url, + }); + } + ensure-no-ssh-in-gitmodules: name: Ensure no SSH URLs in .gitmodules runs-on: ubuntu-22.04