diff --git a/.github/workflows/ext-fprime-stm32-baremetal-reference.yml b/.github/workflows/ext-fprime-stm32-baremetal-reference.yml new file mode 100644 index 00000000000..c6d715ee25f --- /dev/null +++ b/.github/workflows/ext-fprime-stm32-baremetal-reference.yml @@ -0,0 +1,248 @@ +# Dispatch the HIL/cross-compile workflow for +# https://github.com/fprime-community/fprime-stm32-baremetal-reference (STM32H753XI-EVAL2, +# ARM Cortex-M7, Os::Baremetal single-threaded cyclic executive). The self-hosted Pi runner +# and the actual build/flash/test steps live in that repo's own `hil.yml` - this workflow only +# fires workflow_dispatch there with the exact F´ revision under test, polls the remote run, +# and reports the result back as a Check Run on this commit/PR. + +name: "External Repo: STM32 Baremetal Reference" + +on: + schedule: + - cron: "0 21 * * *" # Nightly at 2:00 PM US/Pacific (PDT) + push: + branches: [ release/** ] + pull_request: + branches: [ devel, release/** ] + paths-ignore: + - 'docs/**' + - '**.md' + - '.github/actions/spelling/**' + - '.github/ISSUE_TEMPLATE/**' + - '.github/agents/**' + workflow_dispatch: + inputs: + fprime_ref: + description: "F´ ref/SHA to dispatch for build+test (default: this run's own ref)" + type: string + required: false + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ !contains(github.ref, 'devel') && !contains(github.ref, 'release/') }} + +permissions: + contents: read + checks: write + +env: + TARGET_REPOSITORY: fprime-community/fprime-stm32-baremetal-reference + TARGET_WORKFLOW: hil.yml + +jobs: + dispatch-hil: + name: "Dispatch HIL run on fprime-community/fprime-stm32-baremetal-reference" + runs-on: ubuntu-22.04 + # Generous enough to cover the remote cross-compile + HIL job (today's remote + # HIL-integration alone carries a 30-minute timeout) plus poll/correlate overhead. + timeout-minutes: 90 + # Fork PRs are never dispatched: a fork's head is untrusted code, and the real trust + # gate against it lives on the receiving side (the target repo's `hil-hardware` + # environment requires human approval before a dispatched ref reaches the board) - + # there is still no reason to spend a remote run on an untrusted PR head at all. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + env: + DISPATCH_TOKEN: ${{ secrets.HIL_DISPATCH_TOKEN }} + steps: + - name: "Resolve F´ revision and PR number to dispatch" + id: target + env: + INPUT_FPRIME_REF: ${{ inputs.fprime_ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + echo "sha=${PR_HEAD_SHA}" >> "${GITHUB_OUTPUT}" + echo "pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}" + else + echo "sha=${INPUT_FPRIME_REF:-${GITHUB_SHA}}" >> "${GITHUB_OUTPUT}" + echo "pr_number=" >> "${GITHUB_OUTPUT}" + fi + shell: bash + - name: "Dispatch and create the hil-tests Check Run" + id: dispatch + uses: actions/github-script@v7 + env: + TARGET_REPOSITORY: ${{ env.TARGET_REPOSITORY }} + TARGET_WORKFLOW: ${{ env.TARGET_WORKFLOW }} + FPRIME_SHA: ${{ steps.target.outputs.sha }} + FPRIME_PR_NUMBER: ${{ steps.target.outputs.pr_number }} + with: + script: | + // actions/github-script's bundled github-token only has permissions on this repo; + // dispatching into the external repo needs its own token (actions:write there). + async function extApi(method, path, body) { + const res = await fetch(`https://api.github.com${path}`, { + method, + headers: { + authorization: `Bearer ${process.env.DISPATCH_TOKEN}`, + accept: 'application/vnd.github+json', + 'content-type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 300)}`); + return text ? JSON.parse(text) : null; + } + + const [owner, repo] = process.env.TARGET_REPOSITORY.split('/'); + const fprimeSha = process.env.FPRIME_SHA; + const dispatchId = `${context.runId}-${context.runNumber}`; + const orchestratorUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const check = await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'hil-tests', + head_sha: fprimeSha, + status: 'queued', + details_url: orchestratorUrl, + output: { + title: `Dispatching ${process.env.TARGET_REPOSITORY}`, + summary: `Dispatching \`${process.env.TARGET_WORKFLOW}\` on \`${process.env.TARGET_REPOSITORY}\` with F´ \`${context.repo.owner}/${context.repo.repo}@${fprimeSha.slice(0, 7)}\`.\n\nOrchestrator: ${orchestratorUrl}`, + }, + }); + core.setOutput('check_run_id', check.data.id); + core.setOutput('dispatch_id', dispatchId); + + await extApi('POST', `/repos/${owner}/${repo}/actions/workflows/${process.env.TARGET_WORKFLOW}/dispatches`, { + ref: 'devel', + inputs: { + fprime_repository: `${context.repo.owner}/${context.repo.repo}`, + fprime_ref: fprimeSha, + fprime_pr_number: process.env.FPRIME_PR_NUMBER || '', + dispatch_id: dispatchId, + }, + }); + - name: "Poll the remote run and report its result" + uses: actions/github-script@v7 + env: + TARGET_REPOSITORY: ${{ env.TARGET_REPOSITORY }} + CHECK_RUN_ID: ${{ steps.dispatch.outputs.check_run_id }} + DISPATCH_ID: ${{ steps.dispatch.outputs.dispatch_id }} + with: + script: | + async function extApi(method, path, body) { + const res = await fetch(`https://api.github.com${path}`, { + method, + headers: { + authorization: `Bearer ${process.env.DISPATCH_TOKEN}`, + accept: 'application/vnd.github+json', + 'content-type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 300)}`); + return text ? JSON.parse(text) : null; + } + + const [owner, repo] = process.env.TARGET_REPOSITORY.split('/'); + const checkRunId = Number(process.env.CHECK_RUN_ID); + const dispatchId = process.env.DISPATCH_ID; + const orchestratorUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + // Dispatch doesn't return a run id, so correlate by matching the dispatch_id this + // run stamped into the dispatched workflow's run-name. + let remoteRun = null; + const correlateDeadline = Date.now() + 5 * 60 * 1000; + while (!remoteRun && Date.now() < correlateDeadline) { + const since = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + const runs = await extApi('GET', `/repos/${owner}/${repo}/actions/runs?event=workflow_dispatch&created=%3E%3D${since}&per_page=20`); + remoteRun = runs.workflow_runs.find((r) => (r.display_title || r.name || '').includes(dispatchId)); + if (!remoteRun) await sleep(15_000); + } + + if (!remoteRun) { + await github.rest.checks.update({ + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: checkRunId, + status: 'completed', + conclusion: 'failure', + output: { + title: 'Dispatched run never appeared', + summary: `No run on \`${process.env.TARGET_REPOSITORY}\` matched dispatch id \`${dispatchId}\` within 5 minutes.\n\nOrchestrator: ${orchestratorUrl}`, + }, + }); + core.setFailed(`no remote run found for dispatch_id ${dispatchId}`); + return; + } + + await github.rest.checks.update({ + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: checkRunId, + status: 'in_progress', + details_url: remoteRun.html_url, + output: { + title: `Running on ${process.env.TARGET_REPOSITORY}`, + summary: `Remote run: ${remoteRun.html_url}\n\nOrchestrator: ${orchestratorUrl}`, + }, + }); + + let conclusion = null; + while (!conclusion) { + await sleep(60_000); + const run = await extApi('GET', `/repos/${owner}/${repo}/actions/runs/${remoteRun.id}`); + if (run.status === 'completed') conclusion = run.conclusion; + } + + await github.rest.checks.update({ + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: checkRunId, + status: 'completed', + conclusion, + details_url: remoteRun.html_url, + output: { + title: `${conclusion} on ${process.env.TARGET_REPOSITORY}`, + summary: `Remote run: ${remoteRun.html_url}\n\nOrchestrator: ${orchestratorUrl}`, + }, + }); + + if (conclusion !== 'success') core.setFailed(`HIL run concluded: ${conclusion}`); + - name: "Finalize: close the Check Run if this job was cancelled or crashed" + if: always() + uses: actions/github-script@v7 + env: + TARGET_REPOSITORY: ${{ env.TARGET_REPOSITORY }} + CHECK_RUN_ID: ${{ steps.dispatch.outputs.check_run_id }} + JOB_STATUS: ${{ job.status }} + with: + script: | + const checkRunIdStr = process.env.CHECK_RUN_ID; + if (!checkRunIdStr) return; // dispatch step never created one + const checkRunId = Number(checkRunIdStr); + const current = await github.rest.checks.get({ + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: checkRunId, + }); + if (current.data.status === 'completed') return; // already reported by the poll step + const cancelled = process.env.JOB_STATUS === 'cancelled'; + await github.rest.checks.update({ + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: checkRunId, + status: 'completed', + conclusion: cancelled ? 'cancelled' : 'failure', + output: { + title: cancelled ? 'Cancelled' : 'Dispatcher error', + summary: cancelled + ? 'The dispatcher run was cancelled before the remote HIL run finished.' + : 'The dispatcher run ended before the remote HIL run was reported (dispatcher error or timeout).', + }, + });