From be61d7b621977483911442e00310c6fad3bb769d Mon Sep 17 00:00:00 2001 From: Gustavo Lira e Silva Date: Thu, 22 Oct 2026 18:00:19 -0300 Subject: [PATCH 1/3] feat(ci): run plugin-sanity daily for the release branch indexes plugin-sanity only ran on schedule on main, against the next catalog index. Release branches could only be checked by a manual workflow_dispatch, which needs repository admin rights, so RC and z-stream indexes were never checked automatically. GitHub only fires `schedule` on the default branch, so a new release-plugin-sanity job on main's schedule dispatches the workflow on the two newest release-X.Y branches that carry the harness and waits for those runs, failing if any fails. Each branch runs its own harness against its own backend and plugin-catalog-index:X.Y tag; mixing main's backend with a release index would test a combination that is never shipped. The dispatch API's return_run_details gives the run id to wait on. Branches older than the harness (1.10 and earlier) stop the search. Also drop four excludes whose reason no longer holds. homepage, both intelligent-assistant packages (RHDHBUGS-3515) and the keycloak auth provider (RHDHBUGS-3678, closed as obsolete) were excluded because their refs could not be pulled anonymously. The next and 2.1 indexes now pin them by digest and `skopeo inspect --no-creds` resolves all four. Fork runs with them enabled passed on main (next index) and on release-2.1 (2.1 index): https://github.com/gustavolira/rhdh/actions/runs/39851389654 https://github.com/gustavolira/rhdh/actions/runs/39851624217 The two orchestrator excludes (RHIDP-15961) stay, now noting where the orchestrator backend is covered instead. The harness doc gains a plugin sanity section. Part of RHIDP-18205. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/e2e-cluster-free.yaml | 25 ++++++- docs/e2e-tests/local-e2e-harness.md | 16 +++++ .../dispatch-release-plugin-sanity.sh | 65 +++++++++++++++++++ .../local-harness/plugin-sanity-excludes.txt | 22 ++----- 4 files changed, 108 insertions(+), 20 deletions(-) create mode 100755 e2e-tests/local-harness/dispatch-release-plugin-sanity.sh diff --git a/.github/workflows/e2e-cluster-free.yaml b/.github/workflows/e2e-cluster-free.yaml index a42885e580..e3115fb8f3 100644 --- a/.github/workflows/e2e-cluster-free.yaml +++ b/.github/workflows/e2e-cluster-free.yaml @@ -32,8 +32,8 @@ on: - "e2e-tests/local-harness/resolve-catalog-index-image.sh" # The catalog index is built outside this repo and changes on its own, so the # plugin sanity job needs a trigger that is not tied to a commit here. GitHub - # only runs `schedule` from the default branch, so this covers the `next` - # index; use the dispatch input below to check a release branch's index. + # only runs `schedule` from the default branch: it checks the `next` index + # here and dispatches the release branches (release-plugin-sanity below). schedule: - cron: "0 5 * * *" workflow_dispatch: @@ -177,3 +177,24 @@ jobs: e2e-tests/playwright-report-plugin-sanity e2e-tests/plugin-sanity.log retention-days: 7 + + release-plugin-sanity: + name: release-plugin-sanity + runs-on: ubuntu-latest + # Each release branch runs plugin-sanity on its own ref, against its own + # index tag and backend; this job only dispatches and waits for them. + if: ${{ github.event_name == 'schedule' }} + timeout-minutes: 60 + permissions: + actions: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + - name: Run plugin-sanity on the newest release branches + env: + GH_TOKEN: ${{ github.token }} + run: ./e2e-tests/local-harness/dispatch-release-plugin-sanity.sh 2 diff --git a/docs/e2e-tests/local-e2e-harness.md b/docs/e2e-tests/local-e2e-harness.md index 3ad4a2ea1a..bbd1e4f313 100644 --- a/docs/e2e-tests/local-e2e-harness.md +++ b/docs/e2e-tests/local-e2e-harness.md @@ -190,6 +190,22 @@ cluster-free phase: it installs deps + skopeo, resolves a catalog index image, p image is built. It triggers on `e2e-tests/**` and `app-config*.yaml` changes; the scope can widen to `packages/app/**` / `packages/backend/**` once it is proven stable. +### Plugin sanity + +The `plugin-sanity` job in the same workflow installs every plugin in a catalog index +(`populate-catalog-index.sh`), boots the backend with `app-config.plugin-sanity.yaml`, and +fails when an installed plugin does not load. It runs daily: + +- on `main`, against `quay.io/rhdh/plugin-catalog-index:next`; +- on the two newest `release-X.Y` branches that carry the harness, against + `plugin-catalog-index:X.Y`. GitHub only schedules the default branch, so main's + `release-plugin-sanity` job dispatches each branch on its own ref and fails if any of + those runs fails. Branches older than the harness (1.10 and earlier) are not covered. + +For an RC, dispatch the workflow on the release branch with the `catalog_index_image` +input. Packages that cannot boot standalone are listed in `plugin-sanity-excludes.txt`, +each with its tracking ticket and where it is covered instead. + ## NFS frontend (`packages/app`) The harness targets the NFS frontend (`packages/app`). The backend serves standard diff --git a/e2e-tests/local-harness/dispatch-release-plugin-sanity.sh b/e2e-tests/local-harness/dispatch-release-plugin-sanity.sh new file mode 100755 index 0000000000..b5d231336d --- /dev/null +++ b/e2e-tests/local-harness/dispatch-release-plugin-sanity.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# +# Runs the plugin-sanity job on the newest release branches that carry the +# harness, each on its own ref, so every release index is checked against the +# backend it ships with. GitHub only fires `schedule` on the default branch, +# so main's scheduled run calls this. +# +# Waits for every dispatched run and exits non-zero if any of them failed. +# +# Usage (GH_TOKEN needs actions:write on the repository): +# GITHUB_REPOSITORY=owner/repo ./e2e-tests/local-harness/dispatch-release-plugin-sanity.sh [count] +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +RELEASE_COUNT="${1:-2}" +WORKFLOW="e2e-cluster-free.yaml" +HARNESS_MARKER="e2e-tests/local-harness/populate-catalog-index.sh" +SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}" + +has_harness() { + gh api "repos/${GITHUB_REPOSITORY}/contents/${HARNESS_MARKER}?ref=$1" --silent 2> /dev/null +} + +mapfile -t candidates < <( + gh api --paginate "repos/${GITHUB_REPOSITORY}/branches" --jq '.[].name' \ + | grep -E '^release-[0-9]+\.[0-9]+$' | sort -rV +) + +branches=() +for branch in "${candidates[@]}"; do + # Branches older than the harness have nothing to run; the list is sorted + # newest first, so the first one without it ends the search. + has_harness "${branch}" || break + branches+=("${branch}") + ((${#branches[@]} < RELEASE_COUNT)) || break +done + +if ((${#branches[@]} == 0)); then + echo "No release branch carries ${HARNESS_MARKER}; nothing to dispatch." + exit 0 +fi + +declare -A run_ids +for branch in "${branches[@]}"; do + run_ids[${branch}]=$(gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/workflows/${WORKFLOW}/dispatches" \ + -f ref="${branch}" -F return_run_details=true --jq '.workflow_run_id') + echo "Dispatched plugin-sanity on ${branch}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_ids[${branch}]}" +done + +echo "### Release plugin-sanity" >> "${SUMMARY}" +failed=0 +for branch in "${branches[@]}"; do + run_id="${run_ids[${branch}]}" + # --exit-status turns a failed run into a non-zero exit. + if gh run watch "${run_id}" --repo "${GITHUB_REPOSITORY}" --interval 60 --exit-status > /dev/null; then + result="success" + else + result="failure" + failed=1 + fi + echo "${branch}: ${result}" + echo "- \`${branch}\`: ${result} ([run](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}))" >> "${SUMMARY}" +done + +exit "${failed}" diff --git a/e2e-tests/local-harness/plugin-sanity-excludes.txt b/e2e-tests/local-harness/plugin-sanity-excludes.txt index 2b9267efb0..03615867dc 100644 --- a/e2e-tests/local-harness/plugin-sanity-excludes.txt +++ b/e2e-tests/local-harness/plugin-sanity-excludes.txt @@ -36,26 +36,12 @@ # Re-evaluate when the orchestrator backend gains a standalone-friendly init # path: keeping an entry here means the check is being told not to look at the # class of breakage it exists to catch. +# +# Coverage meanwhile: the orchestrator E2E in rhdh-plugin-export-overlays +# (e2e-ocp-helm and its nightly), which loads the overlay build, not this +# index artifact. No rhdh job loads it. red-hat-developer-hub-backstage-plugin-orchestrator-backend(@|:) # TODO(RHIDP-15961): module on the orchestrator backend plugin - excluded with its host plugin. # (Also requires orchestrator.workflowLogProvider.loki.baseUrl at startup.) red-hat-developer-hub-backstage-plugin-orchestrator-backend-module-loki(@|:) - -# TODO(RHDHBUGS-3515): the three below are not published to any public registry: `skopeo inspect -# --no-creds` reports "unauthorized" for the whole repository, on quay.io and on -# registry.access.redhat.com alike. Note these are tag-style refs -# (:1.11.0--x.y.z); every digest-pinned ref the index declares does resolve -# anonymously via the CLI's quay.io fallback, so this is about publication, not -# about the digest-lookup trap described above. -# -# Excluding them costs real coverage - homepage in particular is a core plugin. -# Re-check periodically with: -# skopeo inspect --no-creds docker://quay.io/rhdh/: -# and delete the entry the moment it resolves. -red-hat-developer-hub-backstage-plugin-homepage(@|:) -red-hat-developer-hub-backstage-plugin-intelligent-assistant(@|:) -red-hat-developer-hub-backstage-plugin-intelligent-assistant-backend(@|:) - -# TODO(RHDHBUGS-3678): https://redhat.atlassian.net/browse/RHDHBUGS-3678 -backstage-community-plugin-auth-backend-module-keycloak-provider(@|:) From bdbd1aaf91bff98bd31e4689b2945a2a25bdbdbd Mon Sep 17 00:00:00 2001 From: Gustavo Lira e Silva Date: Thu, 22 Oct 2026 18:06:14 -0300 Subject: [PATCH 2/3] fix(ci): fail release-plugin-sanity on API errors, report run conclusions Review fixes on dispatch-release-plugin-sanity.sh: - has_harness treated every API error as "no harness", so a rate limit or 5xx on the first branch ended the search and the job exited 0 having checked nothing. Only a 404 now means "no harness"; anything else fails. - A failing branch listing inside the mapfile process substitution was swallowed the same way (verified: against a missing repository the old script printed "nothing to dispatch" and exited 0). The listing now runs in a plain assignment, so set -e catches it. - Each run is reported with its real conclusion instead of success/failure, so a run cancelled by a manual dispatch on the same branch (shared concurrency group) shows as cancelled. Anything but success still fails the job. Co-Authored-By: Claude Opus 5.5 --- .../dispatch-release-plugin-sanity.sh | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/e2e-tests/local-harness/dispatch-release-plugin-sanity.sh b/e2e-tests/local-harness/dispatch-release-plugin-sanity.sh index b5d231336d..c98cd35010 100755 --- a/e2e-tests/local-harness/dispatch-release-plugin-sanity.sh +++ b/e2e-tests/local-harness/dispatch-release-plugin-sanity.sh @@ -17,14 +17,18 @@ WORKFLOW="e2e-cluster-free.yaml" HARNESS_MARKER="e2e-tests/local-harness/populate-catalog-index.sh" SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}" +# Only a 404 means "no harness"; any other API error must fail the job rather +# than read as "nothing to check". has_harness() { - gh api "repos/${GITHUB_REPOSITORY}/contents/${HARNESS_MARKER}?ref=$1" --silent 2> /dev/null + local out + out=$(gh api "repos/${GITHUB_REPOSITORY}/contents/${HARNESS_MARKER}?ref=$1" --silent 2>&1) && return 0 + [[ "${out}" == *"HTTP 404"* ]] && return 1 + echo "${out}" >&2 + exit 1 } -mapfile -t candidates < <( - gh api --paginate "repos/${GITHUB_REPOSITORY}/branches" --jq '.[].name' \ - | grep -E '^release-[0-9]+\.[0-9]+$' | sort -rV -) +all_branches=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/branches" --jq '.[].name') +mapfile -t candidates < <(grep -E '^release-[0-9]+\.[0-9]+$' <<< "${all_branches}" | sort -rV) branches=() for branch in "${candidates[@]}"; do @@ -51,13 +55,11 @@ echo "### Release plugin-sanity" >> "${SUMMARY}" failed=0 for branch in "${branches[@]}"; do run_id="${run_ids[${branch}]}" - # --exit-status turns a failed run into a non-zero exit. - if gh run watch "${run_id}" --repo "${GITHUB_REPOSITORY}" --interval 60 --exit-status > /dev/null; then - result="success" - else - result="failure" - failed=1 - fi + gh run watch "${run_id}" --repo "${GITHUB_REPOSITORY}" --interval 60 > /dev/null || true + # Report the real conclusion, so a run cancelled by a manual dispatch on the + # same branch (shared concurrency group) does not read as a plugin failure. + result=$(gh run view "${run_id}" --repo "${GITHUB_REPOSITORY}" --json conclusion --jq '.conclusion') + [[ "${result}" == "success" ]] || failed=1 echo "${branch}: ${result}" echo "- \`${branch}\`: ${result} ([run](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}))" >> "${SUMMARY}" done From 27eca298cd3d13a94d04e0dadef531ce30ec57a8 Mon Sep 17 00:00:00 2001 From: Gustavo Lira e Silva Date: Thu, 22 Oct 2026 18:15:04 -0300 Subject: [PATCH 3/3] fix(ci): address review of the release plugin-sanity dispatcher Script (dispatch-release-plugin-sanity.sh): - A failed dispatch no longer aborts the script under set -e and leaves the runs already dispatched unwatched. It is recorded as "dispatch failed", the other runs are still waited on, and the job fails. - The run id is checked before watching, and a failing `gh run view` reports "unknown" instead of ending the job with half a summary. - The run URL comes from the dispatch response's html_url instead of being rebuilt. - RELEASE_COUNT is an env var with its default in one place, validated as a positive integer; the workflow no longer repeats the 2. - Bash 3 compatible (no mapfile or associative arrays), so the unit test runs with /bin/bash on macOS too; the branch listing asks for 100 per page. Tests: unit/dispatch-release-plugin-sanity.test.ts drives the script with a stub gh on PATH: default count, stop at the first branch without the harness, nothing to dispatch, API error vs 404, failed conclusion, failed dispatch, and RELEASE_COUNT validation. Each case was seen failing under a mutation of the behaviour it covers. Docs: the X.Y index tag follows the newest build of the stream (plugin- catalog-index:2.1 currently resolves to the 2.1-111 digest), so RC builds are covered by the schedule; a manual dispatch is for one specific build. The doc now says the job fails unless all runs succeed, "up to two" branches, and that manual and scheduled dispatches on a branch cancel each other. The loki exclude notes its coverage. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/e2e-cluster-free.yaml | 2 +- docs/e2e-tests/local-e2e-harness.md | 21 +-- .../dispatch-release-plugin-sanity.sh | 60 +++++--- .../local-harness/plugin-sanity-excludes.txt | 1 + .../dispatch-release-plugin-sanity.test.ts | 128 ++++++++++++++++++ 5 files changed, 183 insertions(+), 29 deletions(-) create mode 100644 e2e-tests/unit/dispatch-release-plugin-sanity.test.ts diff --git a/.github/workflows/e2e-cluster-free.yaml b/.github/workflows/e2e-cluster-free.yaml index e3115fb8f3..380f21df6a 100644 --- a/.github/workflows/e2e-cluster-free.yaml +++ b/.github/workflows/e2e-cluster-free.yaml @@ -197,4 +197,4 @@ jobs: - name: Run plugin-sanity on the newest release branches env: GH_TOKEN: ${{ github.token }} - run: ./e2e-tests/local-harness/dispatch-release-plugin-sanity.sh 2 + run: ./e2e-tests/local-harness/dispatch-release-plugin-sanity.sh diff --git a/docs/e2e-tests/local-e2e-harness.md b/docs/e2e-tests/local-e2e-harness.md index bbd1e4f313..67a3c1f74f 100644 --- a/docs/e2e-tests/local-e2e-harness.md +++ b/docs/e2e-tests/local-e2e-harness.md @@ -197,14 +197,19 @@ The `plugin-sanity` job in the same workflow installs every plugin in a catalog fails when an installed plugin does not load. It runs daily: - on `main`, against `quay.io/rhdh/plugin-catalog-index:next`; -- on the two newest `release-X.Y` branches that carry the harness, against - `plugin-catalog-index:X.Y`. GitHub only schedules the default branch, so main's - `release-plugin-sanity` job dispatches each branch on its own ref and fails if any of - those runs fails. Branches older than the harness (1.10 and earlier) are not covered. - -For an RC, dispatch the workflow on the release branch with the `catalog_index_image` -input. Packages that cannot boot standalone are listed in `plugin-sanity-excludes.txt`, -each with its tracking ticket and where it is covered instead. +- on up to two of the newest `release-X.Y` branches that carry the harness, against + `plugin-catalog-index:X.Y`. That tag follows the newest index build of the stream, so + RC and z-stream builds are checked as they land. GitHub only schedules the default + branch, so main's `release-plugin-sanity` job dispatches each branch on its own ref and + fails unless all of those runs succeed. Branches older than the harness (1.10 and + earlier) are not covered. + +To check one specific index build, dispatch the workflow on the release branch with the +`catalog_index_image` input (for example `quay.io/rhdh/plugin-catalog-index:2.1-110`). +A manual dispatch and the scheduled one share a concurrency group on that branch, so +whichever starts second cancels the other. Packages that cannot boot standalone are +listed in `plugin-sanity-excludes.txt`, each with its tracking ticket and where it is +covered instead. ## NFS frontend (`packages/app`) diff --git a/e2e-tests/local-harness/dispatch-release-plugin-sanity.sh b/e2e-tests/local-harness/dispatch-release-plugin-sanity.sh index c98cd35010..2bc3e3309e 100755 --- a/e2e-tests/local-harness/dispatch-release-plugin-sanity.sh +++ b/e2e-tests/local-harness/dispatch-release-plugin-sanity.sh @@ -5,18 +5,23 @@ # backend it ships with. GitHub only fires `schedule` on the default branch, # so main's scheduled run calls this. # -# Waits for every dispatched run and exits non-zero if any of them failed. +# Waits for every dispatched run and exits non-zero unless all of them succeed. # # Usage (GH_TOKEN needs actions:write on the repository): -# GITHUB_REPOSITORY=owner/repo ./e2e-tests/local-harness/dispatch-release-plugin-sanity.sh [count] +# GITHUB_REPOSITORY=owner/repo ./e2e-tests/local-harness/dispatch-release-plugin-sanity.sh set -euo pipefail : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" -RELEASE_COUNT="${1:-2}" +RELEASE_COUNT="${RELEASE_COUNT:-2}" WORKFLOW="e2e-cluster-free.yaml" HARNESS_MARKER="e2e-tests/local-harness/populate-catalog-index.sh" SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}" +if [[ ! "${RELEASE_COUNT}" =~ ^[1-9][0-9]*$ ]]; then + echo "RELEASE_COUNT must be a positive integer, got: ${RELEASE_COUNT}" >&2 + exit 1 +fi + # Only a 404 means "no harness"; any other API error must fail the job rather # than read as "nothing to check". has_harness() { @@ -27,41 +32,56 @@ has_harness() { exit 1 } -all_branches=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/branches" --jq '.[].name') -mapfile -t candidates < <(grep -E '^release-[0-9]+\.[0-9]+$' <<< "${all_branches}" | sort -rV) +all_branches=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/branches?per_page=100" --jq '.[].name') branches=() -for branch in "${candidates[@]}"; do - # Branches older than the harness have nothing to run; the list is sorted - # newest first, so the first one without it ends the search. +while read -r branch; do + # Sorted newest first, so the first branch older than the harness ends the + # search. has_harness "${branch}" || break branches+=("${branch}") ((${#branches[@]} < RELEASE_COUNT)) || break -done +done < <(grep -E '^release-[0-9]+\.[0-9]+$' <<< "${all_branches}" | sort -rV || true) if ((${#branches[@]} == 0)); then echo "No release branch carries ${HARNESS_MARKER}; nothing to dispatch." exit 0 fi -declare -A run_ids +# A failed dispatch is recorded and skipped, so the runs already dispatched are +# still waited on and reported. +run_ids=() +run_urls=() for branch in "${branches[@]}"; do - run_ids[${branch}]=$(gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/workflows/${WORKFLOW}/dispatches" \ - -f ref="${branch}" -F return_run_details=true --jq '.workflow_run_id') - echo "Dispatched plugin-sanity on ${branch}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_ids[${branch}]}" + response=$(gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/workflows/${WORKFLOW}/dispatches" \ + -f ref="${branch}" -F return_run_details=true --jq '"\(.workflow_run_id) \(.html_url)"') || response="" + if [[ -z "${response}" ]]; then + echo "Failed to dispatch plugin-sanity on ${branch}" >&2 + else + echo "Dispatched plugin-sanity on ${branch}: ${response#* }" + fi + run_ids+=("${response%% *}") + run_urls+=("${response#* }") done echo "### Release plugin-sanity" >> "${SUMMARY}" failed=0 -for branch in "${branches[@]}"; do - run_id="${run_ids[${branch}]}" - gh run watch "${run_id}" --repo "${GITHUB_REPOSITORY}" --interval 60 > /dev/null || true - # Report the real conclusion, so a run cancelled by a manual dispatch on the - # same branch (shared concurrency group) does not read as a plugin failure. - result=$(gh run view "${run_id}" --repo "${GITHUB_REPOSITORY}" --json conclusion --jq '.conclusion') +for i in "${!branches[@]}"; do + branch="${branches[i]}" + run_id="${run_ids[i]}" + if [[ ! "${run_id}" =~ ^[0-9]+$ ]]; then + result="dispatch failed" + else + gh run watch "${run_id}" --repo "${GITHUB_REPOSITORY}" --interval 60 > /dev/null || true + # The real conclusion, so a run cancelled through the concurrency group it + # shares with manual dispatches on the same branch does not read as a + # plugin failure. + result=$(gh run view "${run_id}" --repo "${GITHUB_REPOSITORY}" --json conclusion --jq '.conclusion') \ + || result="unknown" + fi [[ "${result}" == "success" ]] || failed=1 echo "${branch}: ${result}" - echo "- \`${branch}\`: ${result} ([run](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}))" >> "${SUMMARY}" + echo "- \`${branch}\`: ${result} ${run_urls[i]:+([run](${run_urls[i]}))}" >> "${SUMMARY}" done exit "${failed}" diff --git a/e2e-tests/local-harness/plugin-sanity-excludes.txt b/e2e-tests/local-harness/plugin-sanity-excludes.txt index 03615867dc..5f0cdaf021 100644 --- a/e2e-tests/local-harness/plugin-sanity-excludes.txt +++ b/e2e-tests/local-harness/plugin-sanity-excludes.txt @@ -44,4 +44,5 @@ red-hat-developer-hub-backstage-plugin-orchestrator-backend(@|:) # TODO(RHIDP-15961): module on the orchestrator backend plugin - excluded with its host plugin. # (Also requires orchestrator.workflowLogProvider.loki.baseUrl at startup.) +# Loaded by the same overlays orchestrator E2E, but no spec exercises it. red-hat-developer-hub-backstage-plugin-orchestrator-backend-module-loki(@|:) diff --git a/e2e-tests/unit/dispatch-release-plugin-sanity.test.ts b/e2e-tests/unit/dispatch-release-plugin-sanity.test.ts new file mode 100644 index 0000000000..75e5a86ba9 --- /dev/null +++ b/e2e-tests/unit/dispatch-release-plugin-sanity.test.ts @@ -0,0 +1,128 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const script = fileURLToPath( + new URL("../local-harness/dispatch-release-plugin-sanity.sh", import.meta.url), +); +let root: string; + +// Stub gh: branches, harness presence, dispatch and run conclusions come from +// the environment; every call is appended to $root/events. +const ghStub = `#!/bin/bash +echo "$*" >> "$STUB_ROOT/events" +has() { [[ " $1 " == *" $2 "* ]]; } +case "$1 $2" in + "api --paginate") printf '%s\\n' $BRANCHES ;; + "api -X") + ref=$(printf '%s\\n' "$@" | sed -n 's/^ref=//p') + has "$DISPATCH_FAIL" "$ref" && exit 1 + id=\${ref//[^0-9]/} + echo "$id https://github.example/runs/$id" ;; + "run watch") ;; + "run view") + var="CONCLUSION_$3" + echo "\${!var:-success}" ;; + api*) + ref=\${2##*ref=} + has "$HARNESS" "$ref" && exit 0 + [[ -n "$CONTENTS_ERROR" ]] && { echo "gh: Server Error (HTTP 502)" >&2; exit 1; } + echo "gh: Not Found (HTTP 404)" >&2 + exit 1 ;; +esac +`; + +function run(env: Record) { + const result = spawnSync("/bin/bash", [script], { + env: { + PATH: `${join(root, "bin")}:/usr/bin:/bin`, + STUB_ROOT: root, + GITHUB_REPOSITORY: "example/rhdh", + GITHUB_STEP_SUMMARY: join(root, "summary"), + BRANCHES: "main release-1.10 release-2.1 release-2.2 release-2.3", + HARNESS: "release-2.1 release-2.2 release-2.3", + ...env, + }, + encoding: "utf8", + timeout: 10_000, + }); + const read = (name: string) => { + try { + return readFileSync(join(root, name), "utf8"); + } catch { + return ""; + } + }; + const dispatched = read("events") + .split("\n") + .filter((line) => line.includes("/dispatches")) + .map((line) => / ref=(\S+)/u.exec(line)?.[1]); + return { ...result, dispatched, summary: read("summary") }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "rhdh-dispatch-sanity-")); + mkdirSync(join(root, "bin")); + writeFileSync(join(root, "bin", "gh"), ghStub, { mode: 0o700 }); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("dispatch-release-plugin-sanity.sh", () => { + it("dispatches the two newest release branches by default", () => { + const result = run({}); + expect(result.status).toBe(0); + expect(result.dispatched).toEqual(["release-2.3", "release-2.2"]); + }); + + it("stops at the first branch older than the harness", () => { + const result = run({ RELEASE_COUNT: "5", HARNESS: "release-2.1 release-2.3" }); + expect(result.status).toBe(0); + expect(result.dispatched).toEqual(["release-2.3"]); + }); + + it("exits 0 without dispatching when no release branch has the harness", () => { + const result = run({ HARNESS: "" }); + expect(result.status).toBe(0); + expect(result.dispatched).toEqual([]); + expect(result.stdout).toContain("nothing to dispatch"); + }); + + it("fails instead of reading an API error as a missing harness", () => { + const result = run({ HARNESS: "", CONTENTS_ERROR: "1" }); + expect(result.status).toBe(1); + expect(result.dispatched).toEqual([]); + expect(result.stderr).toContain("HTTP 502"); + }); + + it("fails when a run does not succeed and reports each conclusion", () => { + const result = run({ CONCLUSION_22: "failure" }); + expect(result.status).toBe(1); + expect(result.summary).toContain( + "`release-2.3`: success ([run](https://github.example/runs/23))", + ); + expect(result.summary).toContain( + "`release-2.2`: failure ([run](https://github.example/runs/22))", + ); + }); + + it("still waits for the other runs when one dispatch fails", () => { + const result = run({ DISPATCH_FAIL: "release-2.3" }); + expect(result.status).toBe(1); + expect(result.summary).toContain("`release-2.3`: dispatch failed"); + expect(result.summary).toContain("`release-2.2`: success"); + }); + + it.each(["0", "two"])("rejects RELEASE_COUNT=%s", (count) => { + const result = run({ RELEASE_COUNT: count }); + expect(result.status).toBe(1); + expect(result.dispatched).toEqual([]); + expect(result.stderr).toContain("must be a positive integer"); + }); +});