diff --git a/.github/workflows/reusable-dispatch.yml b/.github/workflows/reusable-dispatch.yml index f739e8833f..8184342fed 100644 --- a/.github/workflows/reusable-dispatch.yml +++ b/.github/workflows/reusable-dispatch.yml @@ -73,6 +73,12 @@ on: required: false OTEL_EXPORTER_OTLP_HEADERS: required: false + JIRA_TOKEN: + description: "Jira Cloud API token for Jira-based agents" + required: false + JIRA_USER_EMAIL: + description: "Jira account email for Basic auth" + required: false jobs: route: @@ -1519,165 +1525,27 @@ jobs: } >> "${GITHUB_OUTPUT}" harness-run: - name: Harness run (${{ matrix.agent }}) + name: Harness run needs: harness-dispatch - if: ${{ needs.harness-dispatch.outputs.matrix != '' && fromJSON(needs.harness-dispatch.outputs.matrix).include[0] != null }} - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.harness-dispatch.outputs.matrix) }} - concurrency: - group: fullsend-harness-${{ matrix.agent }}-${{ github.repository }}-${{ matrix.status_number }} - cancel-in-progress: true - runs-on: ${{ inputs.runner_image }} + if: needs.harness-dispatch.outputs.matrix != '' && fromJSON(needs.harness-dispatch.outputs.matrix).include[0] != null permissions: actions: write contents: read id-token: write issues: write pull-requests: write - steps: - - name: Checkout config repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }} - persist-credentials: false - allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - - - name: Checkout upstream defaults - if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ job.workflow_repository }} - ref: ${{ job.workflow_sha }} - path: .defaults - fetch-depth: 1 - sparse-checkout: | - .github/actions/ - .github/scripts/ - internal/scaffold/fullsend-repo/ - action.yml - - - name: Prepare workspace (upstream defaults + org/repo overrides) - env: - INSTALL_MODE: ${{ inputs.install_mode }} - run: | - set -euo pipefail - if [[ "${INSTALL_MODE}" != "per-org" && "${INSTALL_MODE}" != "per-repo" ]]; then - printf 'Received install_mode: %q\n' "${INSTALL_MODE}" - echo "::error::Invalid install_mode: must be 'per-org' or 'per-repo'" - exit 1 - fi - SRC=".defaults/internal/scaffold/fullsend-repo" - LAYERED_DIRS="agents skills schemas harness plugins policies scripts env" - DEST="" - if [[ "${INSTALL_MODE}" == "per-repo" ]]; then - DEST=".fullsend/" - fi - for dir in ${LAYERED_DIRS}; do - if [[ -d "${SRC}/${dir}" ]]; then - mkdir -p "${DEST}${dir}" - cp -r "${SRC}/${dir}/." "${DEST}${dir}/" - fi - done - mkdir -p .github/scripts - cp "${SRC}/.github/scripts/setup-agent-env.sh" .github/scripts/setup-agent-env.sh - - - name: Extract repo metadata - id: repo-parts - shell: bash - env: - SOURCE_REPO: ${{ matrix.source_repo }} - run: | - set -euo pipefail - NAME="${SOURCE_REPO#*/}" - echo "name=${NAME}" >> "${GITHUB_OUTPUT}" - - - name: Write dispatch event payload - shell: bash - env: - EVENT_PAYLOAD: ${{ matrix.event_payload }} - run: | - set -euo pipefail - mkdir -p .fullsend/dispatch - printf '%s' "${EVENT_PAYLOAD}" > .fullsend/dispatch/event-payload.json - - - name: Mint agent token - id: app-token - uses: ./.defaults/.github/actions/mint-token - with: - role: ${{ matrix.role }} - repos: ${{ steps.repo-parts.outputs.name }} - mint_url: ${{ inputs.mint_url }} - - - name: Checkout target repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ matrix.source_repo }} - token: ${{ steps.app-token.outputs.token }} - path: target-repo - fetch-depth: 1 - persist-credentials: false - - - name: Setup GCP and prepare credentials - uses: ./.defaults/.github/actions/setup-gcp - with: - gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} - gcp_project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} - fullsend-dir: ${{ inputs.install_mode == 'per-repo' && '.fullsend' || '' }} - - - name: Setup agent environment - shell: bash - env: - MATRIX_ROLE: ${{ matrix.role }} - GCP_PROJECT: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} - GCP_REGION: ${{ inputs.gcp_region }} - run: | - set -euo pipefail - ROLE_UPPER=$(echo "${MATRIX_ROLE}" | tr '[:lower:]' '[:upper:]') - export AGENT_PREFIX="${ROLE_UPPER}_" - export "${ROLE_UPPER}_TARGET_REPO_DIR=target-repo" - export "${ROLE_UPPER}_ANTHROPIC_VERTEX_PROJECT_ID=${GCP_PROJECT}" - export "${ROLE_UPPER}_CLOUD_ML_REGION=${GCP_REGION}" - bash .github/scripts/setup-agent-env.sh - - - name: Export dispatch context env - id: dispatch-env - shell: bash - env: - EVENT_PAYLOAD: ${{ matrix.event_payload }} - run: | - set -euo pipefail - URL=$(printf '%s' "${EVENT_PAYLOAD}" | jq -r '.issue.html_url // .pull_request.html_url // empty') - if [[ -z "${URL}" ]]; then - echo "::error::event_payload missing issue or pull_request html_url" - exit 1 - fi - DELIM="ISSUE_URL_$(openssl rand -hex 8)" - { - echo "issue_url<<${DELIM}" - printf '%s' "${URL}" - echo - echo "${DELIM}" - } >> "${GITHUB_OUTPUT}" - - - name: Run harness agent - uses: ./.defaults/ - env: - GITHUB_ISSUE_URL: ${{ steps.dispatch-env.outputs.issue_url }} - REPO_FULL_NAME: ${{ matrix.source_repo }} - OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_ENDPOINT }} - OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT }} - OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }} - OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} - OTEL_EXPORTER_OTLP_CERTIFICATE: ${{ vars.OTEL_EXPORTER_OTLP_CERTIFICATE }} - OTEL_RESOURCE_ATTRIBUTES: ${{ vars.OTEL_RESOURCE_ATTRIBUTES }} - OTEL_SDK_DISABLED: ${{ vars.OTEL_SDK_DISABLED }} - with: - agent: ${{ matrix.agent }} - version: ${{ inputs.fullsend_version || job.workflow_sha }} - fullsend-dir: ${{ inputs.install_mode == 'per-repo' && '.fullsend' || '' }} - run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - status-repo: ${{ matrix.status_repo }} - status-number: ${{ matrix.status_number }} - mint-url: ${{ inputs.mint_url }} - pr-head-sha: ${{ fromJSON(matrix.event_payload).pull_request.head.sha || '' }} + uses: ./.github/workflows/reusable-harness-run.yml + with: + matrix: ${{ needs.harness-dispatch.outputs.matrix }} + install_mode: ${{ inputs.install_mode }} + mint_url: ${{ inputs.mint_url }} + gcp_region: ${{ inputs.gcp_region }} + fullsend_version: ${{ inputs.fullsend_version }} + runner_image: ${{ inputs.runner_image }} + secrets: + FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} + OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }} + OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} + JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} diff --git a/.github/workflows/reusable-harness-run.yml b/.github/workflows/reusable-harness-run.yml new file mode 100644 index 0000000000..6886d3d1ca --- /dev/null +++ b/.github/workflows/reusable-harness-run.yml @@ -0,0 +1,270 @@ +# Reusable harness run workflow. +# Executes harness agents according to a provided matrix. +# +# This workflow is called by: +# - reusable-dispatch.yml (after harness-dispatch builds the matrix) +# - Custom pollers in user repos (to directly invoke agents) +# +# The matrix input should follow the format produced by `fullsend dispatch`: +# { +# "include": [ +# { +# "agent": "agent-name", +# "source_repo": "org/repo", +# "role": "harness", +# "event_payload": "{...}", +# "status_repo": "org/repo", +# "status_number": "123" +# } +# ] +# } +# +# Minimal caller example for direct invocation from custom pollers: +# +# jobs: +# my-poller: +# runs-on: ubuntu-24.04 +# steps: +# # ... poller logic that builds matrix ... +# +# harness-run: +# needs: my-poller +# permissions: +# actions: write +# contents: read +# id-token: write +# issues: write +# pull-requests: write +# uses: fullsend-ai/fullsend/.github/workflows/reusable-harness-run.yml@v0 +# with: +# matrix: ${{ needs.my-poller.outputs.matrix }} +# mint_url: ${{ vars.FULLSEND_MINT_URL }} +# gcp_region: ${{ vars.GCP_REGION }} +# secrets: +# FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} +# FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} +# OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }} +# OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} +# +# The caller's `vars` context should define: FULLSEND_MINT_URL, GCP_REGION, +# OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, +# OTEL_EXPORTER_OTLP_CERTIFICATE, OTEL_RESOURCE_ATTRIBUTES, OTEL_SDK_DISABLED, +# and JIRA_BASE_URL (if using Jira integration). +# +# Authorization: matrix is caller-trusted — the mint service is the +# authorization boundary. Agent-enablement checks in .fullsend/config.yaml +# are not applied on the direct-call path. +name: Harness run + +on: + workflow_call: + inputs: + matrix: + description: "Matrix configuration for harness runs (JSON string produced by fullsend dispatch)" + required: true + type: string + install_mode: + description: "Installation mode: per-repo (default) or per-org" + required: false + type: string + default: "per-repo" + mint_url: + description: "Token mint URL for OIDC token exchange" + required: true + type: string + gcp_region: + description: "GCP region for Vertex AI" + required: true + type: string + fullsend_version: + description: "Fullsend CLI version to use" + required: false + type: string + default: "" + runner_image: + description: "GitHub Actions runner image for agent jobs" + type: string + required: false + default: "ubuntu-24.04" + secrets: + FULLSEND_GCP_WIF_PROVIDER: + required: true + FULLSEND_GCP_PROJECT_ID: + required: true + OTEL_EXPORTER_OTLP_TRACES_HEADERS: + required: false + OTEL_EXPORTER_OTLP_HEADERS: + required: false + JIRA_TOKEN: + description: "Jira Cloud API token for Jira-based agents" + required: false + JIRA_USER_EMAIL: + description: "Jira account email for Basic auth" + required: false + +jobs: + harness-run: + name: Harness run (${{ matrix.agent }}) + if: ${{ inputs.matrix != '' && fromJSON(inputs.matrix).include[0] != null }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(inputs.matrix) }} + concurrency: + group: fullsend-harness-${{ matrix.agent }}-${{ matrix.status_repo }}-${{ matrix.status_number }} + cancel-in-progress: true + runs-on: ${{ inputs.runner_image }} + permissions: + actions: write + contents: read + id-token: write + issues: write + pull-requests: write + steps: + - name: Checkout config repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }} + persist-credentials: false + allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} + + - name: Checkout upstream defaults + if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .defaults + fetch-depth: 1 + sparse-checkout: | + .github/actions/ + .github/scripts/ + internal/scaffold/fullsend-repo/ + action.yml + + - name: Prepare workspace (upstream defaults + org/repo overrides) + env: + INSTALL_MODE: ${{ inputs.install_mode }} + run: | + set -euo pipefail + if [[ "${INSTALL_MODE}" != "per-org" && "${INSTALL_MODE}" != "per-repo" ]]; then + printf 'Received install_mode: %q\n' "${INSTALL_MODE}" + echo "::error::Invalid install_mode: must be 'per-org' or 'per-repo'" + exit 1 + fi + SRC=".defaults/internal/scaffold/fullsend-repo" + LAYERED_DIRS="agents skills schemas harness plugins policies scripts env" + DEST="" + if [[ "${INSTALL_MODE}" == "per-repo" ]]; then + DEST=".fullsend/" + fi + for dir in ${LAYERED_DIRS}; do + if [[ -d "${SRC}/${dir}" ]]; then + mkdir -p "${DEST}${dir}" + cp -r "${SRC}/${dir}/." "${DEST}${dir}/" + fi + done + mkdir -p .github/scripts + cp "${SRC}/.github/scripts/setup-agent-env.sh" .github/scripts/setup-agent-env.sh + + - name: Extract repo metadata + id: repo-parts + shell: bash + env: + SOURCE_REPO: ${{ matrix.source_repo }} + run: | + set -euo pipefail + NAME="${SOURCE_REPO#*/}" + echo "name=${NAME}" >> "${GITHUB_OUTPUT}" + + - name: Write dispatch event payload + shell: bash + env: + EVENT_PAYLOAD: ${{ matrix.event_payload }} + run: | + set -euo pipefail + mkdir -p .fullsend/dispatch + printf '%s' "${EVENT_PAYLOAD}" > .fullsend/dispatch/event-payload.json + + - name: Mint agent token + id: app-token + uses: ./.defaults/.github/actions/mint-token + with: + role: ${{ matrix.role }} + repos: ${{ steps.repo-parts.outputs.name }} + mint_url: ${{ inputs.mint_url }} + + - name: Checkout target repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ matrix.source_repo }} + token: ${{ steps.app-token.outputs.token }} + path: target-repo + fetch-depth: 1 + persist-credentials: false + + - name: Setup GCP and prepare credentials + uses: ./.defaults/.github/actions/setup-gcp + with: + gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + gcp_project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} + fullsend-dir: ${{ inputs.install_mode == 'per-repo' && '.fullsend' || '' }} + + - name: Setup agent environment + shell: bash + env: + MATRIX_ROLE: ${{ matrix.role }} + GCP_PROJECT: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} + GCP_REGION: ${{ inputs.gcp_region }} + run: | + set -euo pipefail + ROLE_UPPER=$(echo "${MATRIX_ROLE}" | tr '[:lower:]' '[:upper:]') + export AGENT_PREFIX="${ROLE_UPPER}_" + export "${ROLE_UPPER}_TARGET_REPO_DIR=target-repo" + export "${ROLE_UPPER}_ANTHROPIC_VERTEX_PROJECT_ID=${GCP_PROJECT}" + export "${ROLE_UPPER}_CLOUD_ML_REGION=${GCP_REGION}" + bash .github/scripts/setup-agent-env.sh + + - name: Export dispatch context env + id: dispatch-env + shell: bash + env: + EVENT_PAYLOAD: ${{ matrix.event_payload }} + run: | + set -euo pipefail + URL=$(printf '%s' "${EVENT_PAYLOAD}" | jq -r '.issue.html_url // .pull_request.html_url // empty') + if [[ -z "${URL}" ]]; then + echo "::error::event_payload missing issue or pull_request html_url" + exit 1 + fi + DELIM="ISSUE_URL_$(openssl rand -hex 8)" + { + echo "issue_url<<${DELIM}" + printf '%s' "${URL}" + echo + echo "${DELIM}" + } >> "${GITHUB_OUTPUT}" + + - name: Run harness agent + uses: ./.defaults/ + env: + GITHUB_ISSUE_URL: ${{ steps.dispatch-env.outputs.issue_url }} + REPO_FULL_NAME: ${{ matrix.source_repo }} + OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_ENDPOINT }} + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT }} + OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }} + OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} + OTEL_EXPORTER_OTLP_CERTIFICATE: ${{ vars.OTEL_EXPORTER_OTLP_CERTIFICATE }} + OTEL_RESOURCE_ATTRIBUTES: ${{ vars.OTEL_RESOURCE_ATTRIBUTES }} + OTEL_SDK_DISABLED: ${{ vars.OTEL_SDK_DISABLED }} + JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + JIRA_BASE_URL: ${{ vars.JIRA_BASE_URL }} + with: + agent: ${{ matrix.agent }} + version: ${{ inputs.fullsend_version || job.workflow_sha }} + fullsend-dir: ${{ inputs.install_mode == 'per-repo' && '.fullsend' || '' }} + run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + status-repo: ${{ matrix.status_repo }} + status-number: ${{ matrix.status_number }} + mint-url: ${{ inputs.mint_url }} + pr-head-sha: ${{ fromJSON(matrix.event_payload).pull_request.head.sha || '' }} diff --git a/internal/scaffold/vendormanifest.go b/internal/scaffold/vendormanifest.go index 0773011026..3634dc48a8 100644 --- a/internal/scaffold/vendormanifest.go +++ b/internal/scaffold/vendormanifest.go @@ -138,6 +138,7 @@ var vendoredReusableWorkflows = []string{ "reusable-code.yml", "reusable-dispatch.yml", "reusable-fix.yml", + "reusable-harness-run.yml", "reusable-prioritize.yml", "reusable-retro.yml", "reusable-review.yml", diff --git a/internal/scaffold/workflow_call_alignment_test.go b/internal/scaffold/workflow_call_alignment_test.go index d022165a56..b6001ae949 100644 --- a/internal/scaffold/workflow_call_alignment_test.go +++ b/internal/scaffold/workflow_call_alignment_test.go @@ -174,7 +174,7 @@ func extractStepSection(t *testing.T, content, marker string) string { // reusableWorkflowRef extracts the reusable workflow filename from a uses: reference. // Handles both "fullsend-ai/fullsend/.github/workflows/reusable-foo.yml@v0" // and "./.github/workflows/reusable-foo.yml". -var reusableWorkflowRef = regexp.MustCompile(`reusable-[a-z]+\.yml`) +var reusableWorkflowRef = regexp.MustCompile(`reusable-[a-z-]+\.yml`) // callerPair defines a caller → reusable workflow relationship to validate. type callerPair struct { @@ -391,8 +391,20 @@ func TestOTELHeadersSecretThreading(t *testing.T) { } } + // reusable-harness-run.yml: check the agent step. + t.Run("reusable-harness-run.yml", func(t *testing.T) { + content := string(loadRepoFile(".github/workflows/reusable-harness-run.yml")(t)) + section := extractStepSection(t, content, "Run harness agent") + for secretName, forward := range forwards { + assert.Contains(t, section, forward, + "reusable-harness-run.yml must inject %s into agent env", secretName) + } + }) + // reusable-dispatch.yml: check each inline stage step individually so a // secret missing from one stage is not masked by its presence in others. + // Note: harness-run is now a workflow call to reusable-harness-run.yml, + // so it's tested separately above. t.Run("reusable-dispatch.yml", func(t *testing.T) { content := string(loadRepoFile(".github/workflows/reusable-dispatch.yml")(t)) stepMarkers := []string{ @@ -402,7 +414,6 @@ func TestOTELHeadersSecretThreading(t *testing.T) { "Run fix agent", "Run retro agent", "Run prioritize agent", - "Run harness agent", } for _, marker := range stepMarkers { t.Run(marker, func(t *testing.T) { @@ -449,7 +460,19 @@ func TestOTELVariableForwarding(t *testing.T) { }) } + // reusable-harness-run.yml: check the agent step. + t.Run("reusable-harness-run.yml", func(t *testing.T) { + content := string(loadRepoFile(".github/workflows/reusable-harness-run.yml")(t)) + section := extractStepSection(t, content, "Run harness agent") + for _, v := range otelVars { + assert.Contains(t, section, forwardLine(v), + "reusable-harness-run.yml must inject %s into agent env", v) + } + }) + // reusable-dispatch.yml: check each inline stage step individually. + // Note: harness-run is now a workflow call to reusable-harness-run.yml, + // so it's tested separately above. t.Run("reusable-dispatch.yml", func(t *testing.T) { content := string(loadRepoFile(".github/workflows/reusable-dispatch.yml")(t)) stepMarkers := []string{ @@ -459,7 +482,6 @@ func TestOTELVariableForwarding(t *testing.T) { "Run fix agent", "Run retro agent", "Run prioritize agent", - "Run harness agent", } for _, marker := range stepMarkers { t.Run(marker, func(t *testing.T) { @@ -763,22 +785,25 @@ func TestReusableDispatchPRHeadSHAPassthrough(t *testing.T) { }) } + // harness-run is now a workflow call to reusable-harness-run.yml. + // The reusable workflow receives the matrix and extracts pr-head-sha + // from matrix.event_payload internally. t.Run("harness-run", func(t *testing.T) { - marker := "Run harness agent" - idx := strings.Index(s, marker) - require.NotEqual(t, -1, idx, - "workflow must contain %q step", marker) - section := s[idx:] - nextStep := strings.Index(section, "\n - name:") - if nextStep > 0 { - section = section[:nextStep] - } + // Load reusable-harness-run.yml + harnessRunContent, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", "reusable-harness-run.yml")) + require.NoError(t, err) + harnessRunStr := string(harnessRunContent) + + // Extract the "Run harness agent" step + section := extractStepSection(t, harnessRunStr, "Run harness agent") + + // Assert the step contains pr-head-sha passthrough from matrix.event_payload assert.Contains(t, section, "pr-head-sha:", - "harness-run agent step must pass pr-head-sha to action.yml") + "Run harness agent step must pass pr-head-sha to action.yml") assert.Contains(t, section, ".pull_request.head.sha", - "harness-run pr-head-sha must be populated from event_payload") + "pr-head-sha must be populated from event_payload") assert.Contains(t, section, "matrix.event_payload", - "harness-run must use matrix.event_payload, not needs.route.outputs") + "pr-head-sha must be extracted from matrix.event_payload") }) }