diff --git a/.github/actions/nr-ci-event/action.yml b/.github/actions/nr-ci-event/action.yml new file mode 100644 index 0000000..4da401f --- /dev/null +++ b/.github/actions/nr-ci-event/action.yml @@ -0,0 +1,207 @@ +name: nr-ci-event +description: >- + Emit a CI test-run result to New Relic so a red CI run (test, e2e, smoke, + deploy gate) is studyable from NR dashboards, not just GitHub logs. Posts an + InstantCITestRun custom event on every gated job, plus an InstantCITestFailure + event when result=fail. No-ops cleanly (logs the payload it WOULD send) when + the NR secret/account is absent — never fails the calling job because NR is + unreachable (fork PRs, secret not yet provisioned). + +# Mechanism (CLAUDE.md design ref docs/ci/01-CI-INTEGRATION-DESIGN.md §NR +# observability): the NR Event API is a single HTTP POST to +# https://insights-collector.newrelic.com/v1/accounts//events +# authenticated with the ingest license key (the SAME NEW_RELIC_LICENSE_KEY the +# Go agents use at runtime — it is a valid Insert Key for the Event API). The +# account id is the numeric NEW_RELIC_ACCOUNT_ID. Both are passed as inputs from +# repo secrets by the caller. When EITHER is empty the action prints the payload +# and exits 0 (the no-op-without-secret contract — rule: never red a PR because +# NR is down). EU-region accounts override the collector host via nr-region. + +inputs: + # --- NR credentials (caller passes from secrets; empty => no-op) --- + license-key: + description: >- + NR ingest license key (Insert Key for the Event API). Pass + `secrets.NEW_RELIC_LICENSE_KEY`. Empty => action no-ops (dry-run log). + required: false + default: '' + account-id: + description: >- + Numeric NR account id. Pass `secrets.NEW_RELIC_ACCOUNT_ID`. + Empty => action no-ops (dry-run log). + required: false + default: '' + nr-region: + description: 'US (default) or EU — selects the insights-collector host.' + required: false + default: 'US' + + # --- event payload (caller fills from the GitHub context + job result) --- + result: + description: 'pass | fail — usually `job.status == ''success'' && ''pass'' || ''fail''`.' + required: true + suite: + description: >- + Logical suite name, e.g. build-and-test, coverage, playwright, pr-smoke, + e2e-prod, deploy-gate. The dashboard FACETs on this. + required: true + # NOTE: composite actions cannot read the `github` context in their own + # expressions, so the caller MUST pass these from its `with:` block (e.g. + # repo: `github.repository`). The defaults below only apply when a caller + # omits them entirely. + repo: + description: 'Repository (owner/name). Caller passes `github.repository`.' + required: false + default: '' + workflow: + description: 'Workflow name. Caller passes `github.workflow`.' + required: false + default: '' + branch: + description: 'Branch ref name. Caller passes `github.ref_name`.' + required: false + default: '' + commit-sha: + description: 'Commit SHA under test. Caller passes `github.sha`.' + required: false + default: '' + pr-number: + description: 'PR number (empty on push). Caller passes `github.event.pull_request.number`.' + required: false + default: '' + duration-ms: + description: 'Suite duration in milliseconds (0 if not measured).' + required: false + default: '0' + failed-step: + description: 'On failure: the step/phase that failed (free text, no PII). Empty on pass.' + required: false + default: '' + log-url: + description: 'URL to the run logs for triage. Caller passes the run URL.' + required: false + default: '' + event-name: + description: 'GitHub event name. Caller passes `github.event_name`.' + required: false + default: '' + actor: + description: 'GitHub actor. Caller passes `github.actor`.' + required: false + default: '' + +runs: + using: composite + steps: + - name: Emit CI result to New Relic (no-op without secret) + shell: bash + env: + # Composite actions may reference ONLY `inputs` (+ env/runner/steps) in + # their expressions — `secrets`, `job`, and `github` are NOT available + # here, so the CALLER resolves those and passes them as inputs. All + # untrusted/free-form values flow through env, never interpolated into + # the shell body (injection-safe — same posture as ci.yml's + # dispatch-auth-contract-e2e job). + NR_LICENSE_KEY: ${{ inputs.license-key }} + NR_ACCOUNT_ID: ${{ inputs.account-id }} + NR_REGION: ${{ inputs.nr-region }} + EV_RESULT: ${{ inputs.result }} + EV_SUITE: ${{ inputs.suite }} + EV_REPO: ${{ inputs.repo }} + EV_WORKFLOW: ${{ inputs.workflow }} + EV_BRANCH: ${{ inputs.branch }} + EV_COMMIT: ${{ inputs.commit-sha }} + EV_PR: ${{ inputs.pr-number }} + EV_DURATION_MS: ${{ inputs.duration-ms }} + EV_FAILED_STEP: ${{ inputs.failed-step }} + EV_LOG_URL: ${{ inputs.log-url }} + EV_EVENT_NAME: ${{ inputs.event-name }} + EV_ACTOR: ${{ inputs.actor }} + run: | + set -uo pipefail + + # Normalise the result to the pass|fail enum the dashboard FACETs on. + # Anything that isn't exactly "pass" is treated as "fail" so a typo or + # a cancelled job reads as a non-pass (conservative — never a false green). + case "${EV_RESULT}" in + pass) RESULT="pass" ;; + *) RESULT="fail" ;; + esac + + # Build the InstantCITestRun event (always) and, on fail, the + # InstantCITestFailure event. jq composes the JSON so every value is + # passed as an argument (no shell concatenation of free-form text). + DURATION="${EV_DURATION_MS}" + case "${DURATION}" in ''|*[!0-9]*) DURATION=0 ;; esac + + RUN_EVENT=$(jq -n -c \ + --arg eventType "InstantCITestRun" \ + --arg repo "${EV_REPO}" \ + --arg workflow "${EV_WORKFLOW}" \ + --arg branch "${EV_BRANCH}" \ + --arg commit_sha "${EV_COMMIT}" \ + --arg pr_number "${EV_PR}" \ + --arg result "${RESULT}" \ + --arg suite "${EV_SUITE}" \ + --arg event_name "${EV_EVENT_NAME}" \ + --arg actor "${EV_ACTOR}" \ + --arg log_url "${EV_LOG_URL}" \ + --argjson duration_ms "${DURATION}" \ + '{eventType:$eventType, repo:$repo, workflow:$workflow, branch:$branch, + commit_sha:$commit_sha, pr_number:$pr_number, result:$result, + suite:$suite, event_name:$event_name, actor:$actor, log_url:$log_url, + duration_ms:$duration_ms}') + + PAYLOAD="[${RUN_EVENT}]" + if [ "${RESULT}" = "fail" ]; then + FAIL_EVENT=$(jq -n -c \ + --arg eventType "InstantCITestFailure" \ + --arg repo "${EV_REPO}" \ + --arg workflow "${EV_WORKFLOW}" \ + --arg branch "${EV_BRANCH}" \ + --arg commit_sha "${EV_COMMIT}" \ + --arg pr_number "${EV_PR}" \ + --arg suite "${EV_SUITE}" \ + --arg failed_step "${EV_FAILED_STEP}" \ + --arg log_url "${EV_LOG_URL}" \ + --arg event_name "${EV_EVENT_NAME}" \ + '{eventType:$eventType, repo:$repo, workflow:$workflow, branch:$branch, + commit_sha:$commit_sha, pr_number:$pr_number, suite:$suite, + failed_step:$failed_step, log_url:$log_url, event_name:$event_name}') + PAYLOAD="[${RUN_EVENT},${FAIL_EVENT}]" + fi + + # No-op-without-secret contract: print what WOULD be sent and exit 0 so + # a fork PR (no secret) or an unprovisioned repo never reds because NR + # is unreachable. + if [ -z "${NR_LICENSE_KEY}" ] || [ -z "${NR_ACCOUNT_ID}" ]; then + echo "::notice title=nr-ci-event::NEW_RELIC_LICENSE_KEY or NEW_RELIC_ACCOUNT_ID absent — dry-run only (no event sent)." + echo "would POST to NR Event API the following payload:" + echo "${PAYLOAD}" | jq . + exit 0 + fi + + case "$(echo "${NR_REGION}" | tr '[:lower:]' '[:upper:]')" in + EU) HOST="insights-collector.eu01.nr-data.net" ;; + *) HOST="insights-collector.newrelic.com" ;; + esac + URL="https://${HOST}/v1/accounts/${NR_ACCOUNT_ID}/events" + + echo "POSTing ${RESULT} result for suite='${EV_SUITE}' to NR account ${NR_ACCOUNT_ID} (${HOST})" + HTTP_CODE=$(curl -sS -o /tmp/nr_ci_event.out -w '%{http_code}' \ + -X POST "${URL}" \ + -H "Content-Type: application/json" \ + -H "Api-Key: ${NR_LICENSE_KEY}" \ + --data-binary "${PAYLOAD}" || echo "000") + + echo "NR Event API responded HTTP ${HTTP_CODE}" + cat /tmp/nr_ci_event.out 2>/dev/null || true + echo + + # NR returns 200 on accept. Any other code (incl. network failure 000) + # is logged as a warning but NEVER fails the job — observability must + # not gate the pipeline. + if [ "${HTTP_CODE}" != "200" ]; then + echo "::warning title=nr-ci-event::NR Event API returned ${HTTP_CODE} (expected 200). CI result not recorded in NR; not failing the job." + fi + exit 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c96045..f080d56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,28 @@ jobs: - run: npm run build - run: npm test + # Wave 5 — push the gated-test result to New Relic so a red run is + # studyable from an NR dashboard, not just the GitHub Actions log. + # if: always() records a FAILED tsc/build/vitest as InstantCITestRun + # result=fail + InstantCITestFailure. No-ops cleanly without the NR secret. + - name: Emit CI result to New Relic + if: always() + uses: ./.github/actions/nr-ci-event + with: + license-key: ${{ secrets.NEW_RELIC_LICENSE_KEY }} + account-id: ${{ secrets.NEW_RELIC_ACCOUNT_ID }} + result: ${{ job.status == 'success' && 'pass' || 'fail' }} + suite: build-and-test + pr-number: ${{ github.event.pull_request.number }} + failed-step: ${{ job.status != 'success' && 'gen:api-types:check / build (tsc+vite+prerender) / vitest' || '' }} + repo: ${{ github.repository }} + workflow: ${{ github.workflow }} + branch: ${{ github.ref_name }} + commit-sha: ${{ github.sha }} + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + event-name: ${{ github.event_name }} + actor: ${{ github.actor }} + playwright: runs-on: ubuntu-latest steps: @@ -68,3 +90,24 @@ jobs: - run: npm ci - run: npx playwright install --with-deps chromium - run: VITE_NO_PROXY=1 npx playwright test --project=chromium + + # Wave 5 — record the mocked-Playwright suite outcome in NR + # (suite=playwright) so a red is visible on the CI-health dashboard. + # No-ops without the NR secret. + - name: Emit Playwright result to New Relic + if: always() + uses: ./.github/actions/nr-ci-event + with: + license-key: ${{ secrets.NEW_RELIC_LICENSE_KEY }} + account-id: ${{ secrets.NEW_RELIC_ACCOUNT_ID }} + result: ${{ job.status == 'success' && 'pass' || 'fail' }} + suite: playwright + pr-number: ${{ github.event.pull_request.number }} + failed-step: ${{ job.status != 'success' && 'playwright (mocked, chromium)' || '' }} + repo: ${{ github.repository }} + workflow: ${{ github.workflow }} + branch: ${{ github.ref_name }} + commit-sha: ${{ github.sha }} + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + event-name: ${{ github.event_name }} + actor: ${{ github.actor }} diff --git a/.github/workflows/e2e-pr-smoke.yml b/.github/workflows/e2e-pr-smoke.yml index be0ae35..2618cdc 100644 --- a/.github/workflows/e2e-pr-smoke.yml +++ b/.github/workflows/e2e-pr-smoke.yml @@ -175,3 +175,26 @@ jobs: e2e/.cleanup-ledger.json if-no-files-found: ignore retention-days: 7 + + # Wave 5 — record the PR-smoke outcome in NR (suite=pr-smoke). Gated on + # RUN == '1' so a fork/secret-less no-op run does NOT report a misleading + # pass (the suite did not actually execute). When it DID run, if: always() + # captures both the journey failure and a leak-reaper failure. No-ops + # without the NR secret. The pr-smoke-failing-main NR alert reads this. + - name: Emit PR-smoke result to New Relic + if: always() && env.RUN == '1' + uses: ./.github/actions/nr-ci-event + with: + license-key: ${{ secrets.NEW_RELIC_LICENSE_KEY }} + account-id: ${{ secrets.NEW_RELIC_ACCOUNT_ID }} + result: ${{ job.status == 'success' && 'pass' || 'fail' }} + suite: pr-smoke + pr-number: ${{ github.event.pull_request.number }} + failed-step: ${{ job.status != 'success' && 'real-backend UI @pr-smoke journeys / mint / reap' || '' }} + repo: ${{ github.repository }} + workflow: ${{ github.workflow }} + branch: ${{ github.ref_name }} + commit-sha: ${{ github.sha }} + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + event-name: ${{ github.event_name }} + actor: ${{ github.actor }} diff --git a/.github/workflows/e2e-prod.yml b/.github/workflows/e2e-prod.yml index 9c02e80..4383445 100644 --- a/.github/workflows/e2e-prod.yml +++ b/.github/workflows/e2e-prod.yml @@ -196,3 +196,26 @@ jobs: e2e/.cleanup-ledger.json if-no-files-found: ignore retention-days: 14 + + # Wave 5 — record the live-prod E2E outcome in NR (suite=e2e-prod). This + # is the user-facing-journeys-against-prod signal; the e2e-prod-suite- + # failing NR alert (P1) reads it. Gated on RUN == '1' so a secret-less + # no-op run does NOT report a misleading pass. if: always() captures the + # journey failure and the leak-reaper failure. No-ops without the NR + # secret. + - name: Emit e2e-prod result to New Relic + if: always() && env.RUN == '1' + uses: ./.github/actions/nr-ci-event + with: + license-key: ${{ secrets.NEW_RELIC_LICENSE_KEY }} + account-id: ${{ secrets.NEW_RELIC_ACCOUNT_ID }} + result: ${{ job.status == 'success' && 'pass' || 'fail' }} + suite: e2e-prod + failed-step: ${{ job.status != 'success' && 'live-prod E2E journeys / mint / reap' || '' }} + repo: ${{ github.repository }} + workflow: ${{ github.workflow }} + branch: ${{ github.ref_name }} + commit-sha: ${{ github.sha }} + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + event-name: ${{ github.event_name }} + actor: ${{ github.actor }}