From 3a5f344457eaecb6e983782103f6f372915d2102 Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Fri, 9 Oct 2026 14:18:45 +0200 Subject: [PATCH 01/14] feat(export-dynamic): reject hollow OCI payloads before push Fail incomplete dist-dynamic exports and empty io.backstage.dynamic-packages images into failed-exports so overlay publish does not push garbage to ghcr. Co-authored-by: Cursor --- .../test-validate-plugin-payload.yaml | 20 ++ README.md | 14 ++ export-dynamic/export-dynamic.sh | 26 +- export-dynamic/validate-plugin-payload.sh | 224 ++++++++++++++++++ .../validate-plugin-payload.test.sh | 129 ++++++++++ 5 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test-validate-plugin-payload.yaml create mode 100755 export-dynamic/validate-plugin-payload.sh create mode 100755 export-dynamic/validate-plugin-payload.test.sh diff --git a/.github/workflows/test-validate-plugin-payload.yaml b/.github/workflows/test-validate-plugin-payload.yaml new file mode 100644 index 0000000..47958ea --- /dev/null +++ b/.github/workflows/test-validate-plugin-payload.yaml @@ -0,0 +1,20 @@ +name: Test validate-plugin-payload +on: + push: + paths: + - export-dynamic/** + - .github/workflows/test-validate-plugin-payload.yaml + pull_request: + paths: + - export-dynamic/** + - .github/workflows/test-validate-plugin-payload.yaml + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run validate-plugin-payload tests + run: bash export-dynamic/validate-plugin-payload.test.sh diff --git a/README.md b/README.md index 7a49131..4d1f6d9 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,20 @@ Exports plugins as dynamic plugin archives. This should be run **after** the `ov - Exports plugins as dynamic plugin packages - Handles both frontend and backend plugins - Optional container image packaging +- **Hollow-OCI gates** (see below) so empty exports are not pushed to ghcr.io + +#### Hollow / empty OCI payload gates + +Before packaging or publishing, `export-dynamic` validates that each plugin has an installable payload: + +1. **After `plugin export`** — `dist-dynamic/package.json` must exist, the package name must end with `-dynamic`, and a dist entry (`index.cjs.js` / `index.esm.js` / `index.js`, or `package.json` `main` under `dist/`) must be present. +2. **After `plugin package`, before push** — the local image must have a non-empty `io.backstage.dynamic-packages` annotation (or, if the annotation cannot be read, a non-empty layer `index.json`). + +Failures are recorded in the `failed-exports` output (same path as other export errors). The image is **not** pushed and is **not** listed in `published-exports`. Overlay publish workflows that already fail on non-empty `failed-exports` will block the publish. + +If a workspace fails these checks, fix the overlay `plugins-list.yaml` (export args, `--embed-package`, missing packages) and re-run publish. Do not rely on midstream or weekly registry scans as the primary gate. + +Helpers live in [`export-dynamic/validate-plugin-payload.sh`](export-dynamic/validate-plugin-payload.sh); unit tests: `bash export-dynamic/validate-plugin-payload.test.sh`. ### override-sources diff --git a/export-dynamic/export-dynamic.sh b/export-dynamic/export-dynamic.sh index ac33f6f..dc72f58 100755 --- a/export-dynamic/export-dynamic.sh +++ b/export-dynamic/export-dynamic.sh @@ -40,6 +40,8 @@ INPUTS_CLI_CALLER=${INPUTS_CLI_CALLER:-"npx --yes ${INPUTS_CLI_PACKAGE}@${INPUTS SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=export-dynamic/pack-dist-dynamic.sh source "${SCRIPT_DIR}/pack-dist-dynamic.sh" +# shellcheck source=export-dynamic/validate-plugin-payload.sh +source "${SCRIPT_DIR}/validate-plugin-payload.sh" # Check local installation first, then fall back to npx --yes (requires network) run_cli() { @@ -175,6 +177,15 @@ else echo " ✅ backstage.features: $features" fi fi + + # Gate 1: refuse hollow/incomplete dist-dynamic before package or npm pack + if ! assert_dist_dynamic_payload "$(pwd)/dist-dynamic"; then + echo " Error: hollow or incomplete dist-dynamic export for ${pluginPath}" + errors+=("${pluginPath}") + set -e + popd > /dev/null + continue + fi echo # package the dynamic plugin in a container image @@ -186,7 +197,11 @@ else echo "========== Packaging Container ${PLUGIN_CONTAINER_TAG} ==========" if run_cli "${PACKAGE_COMMAND[@]}" --tag "${PLUGIN_CONTAINER_TAG}"; then - if [[ "${INPUTS_PUSH_CONTAINER_IMAGE}" == "true" ]] + # Gate 2: refuse hollow OCI before push / published-exports + if ! assert_local_plugin_image "${PLUGIN_CONTAINER_TAG}"; then + echo " Error: packaged image has no installable payload for ${pluginPath}" + errors+=("${pluginPath}") + elif [[ "${INPUTS_PUSH_CONTAINER_IMAGE}" == "true" ]] then echo "========== Publishing Container ${PLUGIN_CONTAINER_TAG} ==========" if ${INPUTS_CONTAINER_BUILD_TOOL} push "$PLUGIN_CONTAINER_TAG"; then @@ -212,6 +227,15 @@ else packDestination=${INPUTS_DESTINATION} mkdir -pv "${packDestination}" + # Gate 1 again for archive-only clarity (already passed above after export) + if ! assert_dist_dynamic_payload "$(pwd)/dist-dynamic"; then + echo " Error: hollow or incomplete dist-dynamic export for ${pluginPath}" + errors+=("${pluginPath}") + set -e + popd > /dev/null + continue + fi + echo " running npm pack on a hardlink-free copy of './dist-dynamic'" if ! json=$(pack_dist_dynamic "$(pwd)/dist-dynamic" "$packDestination"); then errors+=("${pluginPath}") diff --git a/export-dynamic/validate-plugin-payload.sh b/export-dynamic/validate-plugin-payload.sh new file mode 100755 index 0000000..8eda4dd --- /dev/null +++ b/export-dynamic/validate-plugin-payload.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# Validate dynamic-plugin export / OCI payload before publish. +# Hollow images (empty io.backstage.dynamic-packages or empty layer index.json) +# must not be pushed to the registry. +# +# Sourced by export-dynamic.sh. Functions return 0 on success / healthy, +# non-zero on failure / hollow. + +EMPTY_DYNAMIC_PACKAGES_ANNOTATION='W10=' # base64("[]") + +# Exit 0 = hollow/empty, 1 = has payload. +is_hollow_dynamic_packages_annotation() { + local value="${1:-}" + local trimmed decoded parsed + + trimmed=$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + if [[ -z "$trimmed" ]]; then + return 0 + fi + if [[ "$trimmed" == "$EMPTY_DYNAMIC_PACKAGES_ANNOTATION" ]]; then + return 0 + fi + + # Linux: base64 -d; macOS: base64 -D / --decode + if ! decoded=$(printf '%s' "$trimmed" | base64 -d 2>/dev/null \ + || printf '%s' "$trimmed" | base64 -D 2>/dev/null \ + || printf '%s' "$trimmed" | base64 --decode 2>/dev/null); then + # Non-decodable annotation is not treated as an empty payload sentinel. + return 1 + fi + if ! parsed=$(printf '%s' "$decoded" | jq -c . 2>/dev/null); then + return 1 + fi + if [[ "$parsed" == "[]" || "$parsed" == "{}" || "$parsed" == "null" ]]; then + return 0 + fi + return 1 +} + +# Pre-OCI export check: rhdh-cli writes dist-dynamic/ (package + dist entry). +# Prints reason to stderr and returns non-zero on failure. +assert_dist_dynamic_payload() { + local dist_dynamic_dir="${1:-}" + local pkg_path pkg_name main candidate + + if [[ -z "$dist_dynamic_dir" ]]; then + echo "dist-dynamic path is required" >&2 + return 1 + fi + pkg_path="${dist_dynamic_dir}/package.json" + if [[ ! -f "$pkg_path" ]]; then + echo "missing ${pkg_path}" >&2 + return 1 + fi + if ! pkg_name=$(jq -r '.name // empty' "$pkg_path" 2>/dev/null); then + echo "unreadable ${pkg_path}" >&2 + return 1 + fi + if [[ -z "$pkg_name" || "$pkg_name" != *-dynamic ]]; then + echo "package name must end with -dynamic (got ${pkg_name:-'(empty)'})" >&2 + return 1 + fi + + main=$(jq -r '.main // empty' "$pkg_path" 2>/dev/null | sed 's|^\./||') + for candidate in \ + "${dist_dynamic_dir}/dist/index.cjs.js" \ + "${dist_dynamic_dir}/dist/index.esm.js" \ + "${dist_dynamic_dir}/dist/index.js" + do + if [[ -f "$candidate" ]]; then + return 0 + fi + done + if [[ -n "$main" && "$main" == dist/* && -f "${dist_dynamic_dir}/${main}" ]]; then + return 0 + fi + echo "missing dist-dynamic entry (index.cjs.js / index.esm.js / index.js) — hollow export" >&2 + return 1 +} + +# Read io.backstage.dynamic-packages from a local image via skopeo --raw. +# Prints annotation value to stdout; returns non-zero if unreadable. +_read_dynamic_packages_annotation() { + local image_ref="$1" + local transport raw annotation + image_ref="${image_ref#docker://}" + image_ref="${image_ref#oci://}" + + for transport in \ + "containers-storage:${image_ref}" \ + "docker-daemon:${image_ref}" + do + if ! command -v skopeo >/dev/null 2>&1; then + break + fi + if raw=$(skopeo inspect --raw "$transport" 2>/dev/null); then + annotation=$(printf '%s' "$raw" | jq -r '.annotations["io.backstage.dynamic-packages"] // empty' 2>/dev/null) + if [[ -n "$annotation" ]]; then + printf '%s' "$annotation" + return 0 + fi + # Manifest readable but annotation absent — treat as empty string (hollow). + printf '' + return 0 + fi + done + + # Fall back to container-tool image inspect (Labels; may miss OCI annotations). + local tool="${INPUTS_CONTAINER_BUILD_TOOL:-podman}" + if command -v "$tool" >/dev/null 2>&1; then + annotation=$("$tool" image inspect \ + --format '{{index .Annotations "io.backstage.dynamic-packages"}}' \ + "$image_ref" 2>/dev/null || true) + if [[ -z "$annotation" || "$annotation" == "" ]]; then + annotation=$("$tool" image inspect \ + --format '{{index .Config.Labels "io.backstage.dynamic-packages"}}' \ + "$image_ref" 2>/dev/null || true) + fi + if [[ -n "$annotation" && "$annotation" != "" ]]; then + printf '%s' "$annotation" + return 0 + fi + fi + return 1 +} + +# Unpack local image layer and require non-empty index.json. +_assert_local_image_index_json() { + local image_ref="$1" + local tool="${INPUTS_CONTAINER_BUILD_TOOL:-podman}" + local tmpdir archive extract_root layer_hash blob index_path payload_len oci_archive + + tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/rhdh-export-oci-verify.XXXXXX") || return 1 + archive="${tmpdir}/img.tar" + extract_root="${tmpdir}/extract" + + if command -v skopeo >/dev/null 2>&1; then + if ! skopeo copy "containers-storage:${image_ref}" "oci-archive:${archive}" >/dev/null 2>&1 \ + && ! skopeo copy "docker-daemon:${image_ref}" "oci-archive:${archive}" >/dev/null 2>&1; then + if ! "$tool" save -o "$archive" "$image_ref" >/dev/null 2>&1; then + echo "failed to copy local image ${image_ref} for deep check" >&2 + rm -rf "$tmpdir" + return 1 + fi + oci_archive="${tmpdir}/img.oci.tar" + if skopeo copy "docker-archive:${archive}" "oci-archive:${oci_archive}" >/dev/null 2>&1; then + archive="$oci_archive" + fi + fi + else + if ! "$tool" save -o "$archive" "$image_ref" >/dev/null 2>&1; then + echo "failed to save local image ${image_ref} for deep check" >&2 + rm -rf "$tmpdir" + return 1 + fi + fi + + if command -v skopeo >/dev/null 2>&1 && skopeo inspect "oci-archive:${archive}" >/dev/null 2>&1; then + layer_hash=$(skopeo inspect "oci-archive:${archive}" | jq -r '.Layers[0] // empty' | sed 's/^sha256://') + if [[ -z "$layer_hash" ]]; then + echo "no layers in OCI artifact ${image_ref}" >&2 + rm -rf "$tmpdir" + return 1 + fi + mkdir -p "$extract_root" + tar xf "$archive" -C "$extract_root" + blob="${extract_root}/blobs/sha256/${layer_hash}" + if [[ ! -f "$blob" ]]; then + echo "missing layer blob ${layer_hash}" >&2 + rm -rf "$tmpdir" + return 1 + fi + mkdir -p "${tmpdir}/layer" + if ! tar xzf "$blob" -C "${tmpdir}/layer" 2>/dev/null && ! tar xf "$blob" -C "${tmpdir}/layer" 2>/dev/null; then + echo "failed to unpack plugin layer" >&2 + rm -rf "$tmpdir" + return 1 + fi + index_path="${tmpdir}/layer/index.json" + else + # docker-archive layout: find index.json inside the saved tree + mkdir -p "$extract_root" + tar xf "$archive" -C "$extract_root" + index_path=$(find "$extract_root" -name index.json -print -quit 2>/dev/null || true) + fi + + if [[ -z "$index_path" || ! -f "$index_path" ]]; then + echo "index.json missing in plugin layer" >&2 + rm -rf "$tmpdir" + return 1 + fi + payload_len=$(jq 'length' "$index_path" 2>/dev/null || echo 0) + if [[ ! "$payload_len" =~ ^[0-9]+$ ]] || [[ "$payload_len" -eq 0 ]]; then + echo "index.json is empty in plugin layer" >&2 + rm -rf "$tmpdir" + return 1 + fi + rm -rf "$tmpdir" + return 0 +} + +# After plugin package, before push: require installable payload. +assert_local_plugin_image() { + local image_ref="${1:-}" + local annotation + + if [[ -z "$image_ref" ]]; then + echo "image ref is required" >&2 + return 1 + fi + image_ref="${image_ref#docker://}" + image_ref="${image_ref#oci://}" + + if annotation=$(_read_dynamic_packages_annotation "$image_ref"); then + if is_hollow_dynamic_packages_annotation "$annotation"; then + echo "hollow artifact (empty io.backstage.dynamic-packages annotation) for ${image_ref}" >&2 + return 1 + fi + return 0 + fi + + echo " annotation unreadable for ${image_ref}; falling back to layer index.json check" >&2 + _assert_local_image_index_json "$image_ref" +} diff --git a/export-dynamic/validate-plugin-payload.test.sh b/export-dynamic/validate-plugin-payload.test.sh new file mode 100755 index 0000000..1c555ca --- /dev/null +++ b/export-dynamic/validate-plugin-payload.test.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=export-dynamic/validate-plugin-payload.sh +source "${SCRIPT_DIR}/validate-plugin-payload.sh" + +assert_eq() { + local description="$1" + local expected="$2" + local actual="$3" + if [[ "$expected" == "$actual" ]]; then + echo "PASS: ${description}" + else + echo "FAIL: ${description} (expected '${expected}', got '${actual}')" >&2 + exit 1 + fi +} + +assert_true() { + local description="$1" + shift + if "$@"; then + echo "PASS: ${description}" + else + echo "FAIL: ${description}" >&2 + exit 1 + fi +} + +assert_false() { + local description="$1" + shift + if "$@"; then + echo "FAIL: ${description} (expected failure)" >&2 + exit 1 + else + echo "PASS: ${description}" + fi +} + +WORKDIR=$(mktemp -d /tmp/validate-plugin-payload-test-XXXXXX) +trap 'rm -rf "$WORKDIR"' EXIT + +# --- is_hollow_dynamic_packages_annotation --- +assert_true "null/blank is hollow" is_hollow_dynamic_packages_annotation "" +assert_true "whitespace is hollow" is_hollow_dynamic_packages_annotation " " +assert_true "W10= sentinel is hollow" is_hollow_dynamic_packages_annotation "W10=" +empty_list_b64=$(printf '[]' | base64) +assert_true "base64([]) is hollow" is_hollow_dynamic_packages_annotation "$empty_list_b64" +empty_obj_b64=$(printf '{}' | base64) +assert_true "base64({}) is hollow" is_hollow_dynamic_packages_annotation "$empty_obj_b64" +payload_b64=$(printf '[{"plugin":{"name":"@scope/pkg-dynamic","version":"1.0.0"}}]' | base64) +assert_false "non-empty package list is healthy" is_hollow_dynamic_packages_annotation "$payload_b64" +assert_false "non-base64 junk is not treated as hollow" is_hollow_dynamic_packages_annotation "not-base64!!!" + +# --- assert_dist_dynamic_payload --- +missing_dir="${WORKDIR}/missing" +mkdir -p "$missing_dir" +assert_false "missing package.json fails" assert_dist_dynamic_payload "$missing_dir" + +bad_name="${WORKDIR}/bad-name" +mkdir -p "${bad_name}/dist" +printf '%s\n' '{"name":"@scope/pkg","version":"1.0.0"}' > "${bad_name}/package.json" +printf 'module.exports = {};\n' > "${bad_name}/dist/index.cjs.js" +assert_false "name without -dynamic suffix fails" assert_dist_dynamic_payload "$bad_name" + +no_entry="${WORKDIR}/no-entry" +mkdir -p "${no_entry}/dist" +printf '%s\n' '{"name":"@scope/pkg-dynamic","version":"1.0.0"}' > "${no_entry}/package.json" +assert_false "missing dist entry fails" assert_dist_dynamic_payload "$no_entry" + +good_cjs="${WORKDIR}/good-cjs" +mkdir -p "${good_cjs}/dist" +printf '%s\n' '{"name":"@scope/pkg-dynamic","version":"1.0.0"}' > "${good_cjs}/package.json" +printf 'module.exports = {};\n' > "${good_cjs}/dist/index.cjs.js" +assert_true "good dist-dynamic with index.cjs.js passes" assert_dist_dynamic_payload "$good_cjs" + +good_main="${WORKDIR}/good-main" +mkdir -p "${good_main}/dist" +printf '%s\n' '{"name":"@scope/pkg-dynamic","version":"1.0.0","main":"dist/custom.js"}' > "${good_main}/package.json" +printf 'module.exports = {};\n' > "${good_main}/dist/custom.js" +assert_true "good dist-dynamic with pkg.main under dist/ passes" assert_dist_dynamic_payload "$good_main" + +# --- assert_local_plugin_image (usage / offline only) --- +assert_false "empty image ref fails" assert_local_plugin_image "" + +# --- optional Gate 2 smoke with local podman images (skipped if podman unavailable) --- +run_oci_smoke() { + local smoke_dir good_ann hollow_tag good_tag + if ! command -v podman >/dev/null 2>&1; then + echo "SKIP: OCI smoke (podman not installed)" + return 0 + fi + if ! podman info >/dev/null 2>&1; then + echo "SKIP: OCI smoke (podman not running)" + return 0 + fi + + smoke_dir=$(mktemp -d /tmp/validate-plugin-payload-oci-XXXXXX) + hollow_tag="localhost/rhdh-hollow-smoke:test" + good_tag="localhost/rhdh-good-smoke:test" + # cleanup images + dir even on failure + # shellcheck disable=SC2064 + trap "podman rmi -f '${hollow_tag}' '${good_tag}' >/dev/null 2>&1 || true; rm -rf '${smoke_dir}'; rm -rf '${WORKDIR}'" EXIT + + good_ann=$(printf '[{"plugin":{"name":"@x/y-dynamic","version":"1.0.0"}}]' | base64 | tr -d '\n') + cat > "${smoke_dir}/Containerfile" <<'EOF' +FROM scratch +COPY index.json / +EOF + + printf '[]\n' > "${smoke_dir}/index.json" + podman build --quiet --no-cache \ + --annotation "io.backstage.dynamic-packages=W10=" \ + -t "${hollow_tag}" -f "${smoke_dir}/Containerfile" "${smoke_dir}" >/dev/null + + printf '[{"name":"@x/y-dynamic"}]\n' > "${smoke_dir}/index.json" + podman build --quiet --no-cache \ + --annotation "io.backstage.dynamic-packages=${good_ann}" \ + -t "${good_tag}" -f "${smoke_dir}/Containerfile" "${smoke_dir}" >/dev/null + + assert_false "Gate2 rejects hollow W10= image" assert_local_plugin_image "${hollow_tag}" + assert_true "Gate2 accepts image with non-empty dynamic-packages" assert_local_plugin_image "${good_tag}" +} + +run_oci_smoke + +echo "All validate-plugin-payload tests passed." From 9aa367d7c99ae7709b78aec92e758aa6b0471b47 Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Fri, 9 Oct 2026 15:01:50 +0200 Subject: [PATCH 02/14] fix(export-dynamic): accept frontend remoteEntry.js in payload gate Frontend scalprum exports use dist/remoteEntry.js, not backend index.cjs.js. Verified locally against community-plugins bookmarks via export-dynamic.sh. Co-authored-by: Cursor --- README.md | 2 +- export-dynamic/validate-plugin-payload.sh | 8 +++++++- export-dynamic/validate-plugin-payload.test.sh | 6 ++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4d1f6d9..767e44d 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Exports plugins as dynamic plugin archives. This should be run **after** the `ov Before packaging or publishing, `export-dynamic` validates that each plugin has an installable payload: -1. **After `plugin export`** — `dist-dynamic/package.json` must exist, the package name must end with `-dynamic`, and a dist entry (`index.cjs.js` / `index.esm.js` / `index.js`, or `package.json` `main` under `dist/`) must be present. +1. **After `plugin export`** — `dist-dynamic/package.json` must exist, the package name must end with `-dynamic`, and a dist entry must be present: backend `index.cjs.js` / `index.esm.js` / `index.js` (or `main` under `dist/`), or frontend `remoteEntry.js` / `mf-manifest.json`. 2. **After `plugin package`, before push** — the local image must have a non-empty `io.backstage.dynamic-packages` annotation (or, if the annotation cannot be read, a non-empty layer `index.json`). Failures are recorded in the `failed-exports` output (same path as other export errors). The image is **not** pushed and is **not** listed in `published-exports`. Overlay publish workflows that already fail on non-empty `failed-exports` will block the publish. diff --git a/export-dynamic/validate-plugin-payload.sh b/export-dynamic/validate-plugin-payload.sh index 8eda4dd..ad85270 100755 --- a/export-dynamic/validate-plugin-payload.sh +++ b/export-dynamic/validate-plugin-payload.sh @@ -62,6 +62,7 @@ assert_dist_dynamic_payload() { fi main=$(jq -r '.main // empty' "$pkg_path" 2>/dev/null | sed 's|^\./||') + # Backend dynamic plugins ship a CommonJS/ESM index under dist/. for candidate in \ "${dist_dynamic_dir}/dist/index.cjs.js" \ "${dist_dynamic_dir}/dist/index.esm.js" \ @@ -74,7 +75,12 @@ assert_dist_dynamic_payload() { if [[ -n "$main" && "$main" == dist/* && -f "${dist_dynamic_dir}/${main}" ]]; then return 0 fi - echo "missing dist-dynamic entry (index.cjs.js / index.esm.js / index.js) — hollow export" >&2 + # Frontend dynamic plugins ship a Module Federation remote (scalprum). + if [[ -f "${dist_dynamic_dir}/dist/remoteEntry.js" ]] \ + || [[ -f "${dist_dynamic_dir}/dist/mf-manifest.json" ]]; then + return 0 + fi + echo "missing dist-dynamic entry (backend index.* or frontend remoteEntry.js) — hollow export" >&2 return 1 } diff --git a/export-dynamic/validate-plugin-payload.test.sh b/export-dynamic/validate-plugin-payload.test.sh index 1c555ca..86d834e 100755 --- a/export-dynamic/validate-plugin-payload.test.sh +++ b/export-dynamic/validate-plugin-payload.test.sh @@ -82,6 +82,12 @@ printf '%s\n' '{"name":"@scope/pkg-dynamic","version":"1.0.0","main":"dist/custo printf 'module.exports = {};\n' > "${good_main}/dist/custom.js" assert_true "good dist-dynamic with pkg.main under dist/ passes" assert_dist_dynamic_payload "$good_main" +good_frontend="${WORKDIR}/good-frontend" +mkdir -p "${good_frontend}/dist" +printf '%s\n' '{"name":"@scope/pkg-dynamic","version":"1.0.0","main":"src/index.ts"}' > "${good_frontend}/package.json" +printf '// mf\n' > "${good_frontend}/dist/remoteEntry.js" +assert_true "good frontend dist-dynamic with remoteEntry.js passes" assert_dist_dynamic_payload "$good_frontend" + # --- assert_local_plugin_image (usage / offline only) --- assert_false "empty image ref fails" assert_local_plugin_image "" From 65f5970855b1515f02a3335bf8f08c4e3e92e175 Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Fri, 9 Oct 2026 16:27:27 +0200 Subject: [PATCH 03/14] fix(export-dynamic): fail the action step on hollow or failed exports Stop swallowing export-dynamic.sh exit codes with || true so export-only runs cannot succeed when dist-dynamic/OCI payload validation fails. Keep detailed failed-exports reporting via always() in the workflow. Co-authored-by: Cursor --- .github/workflows/export-dynamic.yaml | 4 +++- README.md | 2 +- export-dynamic/action.yaml | 15 +++++++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/export-dynamic.yaml b/.github/workflows/export-dynamic.yaml index 25d7ebd..19a7e9b 100644 --- a/.github/workflows/export-dynamic.yaml +++ b/.github/workflows/export-dynamic.yaml @@ -403,8 +403,10 @@ jobs: { title: 'Workspace skipped' } ); + # export-dynamic action now fails the step on hollow/failed exports; use always() + # so we still emit the detailed plugin list after that failure. - name: Check export errors - if: ${{ success() && steps.export-dynamic.outputs.failed-exports != '' }} + if: ${{ always() && !cancelled() && steps.export-dynamic.outputs.failed-exports != '' }} uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: INPUT_FAILED_EXPORTS: ${{ steps.export-dynamic.outputs.failed-exports }} diff --git a/README.md b/README.md index 767e44d..35c72f5 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Before packaging or publishing, `export-dynamic` validates that each plugin has 1. **After `plugin export`** — `dist-dynamic/package.json` must exist, the package name must end with `-dynamic`, and a dist entry must be present: backend `index.cjs.js` / `index.esm.js` / `index.js` (or `main` under `dist/`), or frontend `remoteEntry.js` / `mf-manifest.json`. 2. **After `plugin package`, before push** — the local image must have a non-empty `io.backstage.dynamic-packages` annotation (or, if the annotation cannot be read, a non-empty layer `index.json`). -Failures are recorded in the `failed-exports` output (same path as other export errors). The image is **not** pushed and is **not** listed in `published-exports`. Overlay publish workflows that already fail on non-empty `failed-exports` will block the publish. +Failures are recorded in the `failed-exports` output **and the `export-dynamic` action/step fails** (non-zero exit). The image is **not** pushed and is **not** listed in `published-exports`. This applies to export-only runs as well as package/push — a hollow `dist-dynamic` fails before packaging. If a workspace fails these checks, fix the overlay `plugins-list.yaml` (export args, `--embed-package`, missing packages) and re-run publish. Do not rely on midstream or weekly registry scans as the primary gate. diff --git a/export-dynamic/action.yaml b/export-dynamic/action.yaml index 47cf3de..2680f48 100644 --- a/export-dynamic/action.yaml +++ b/export-dynamic/action.yaml @@ -108,5 +108,16 @@ runs: INPUTS_IMAGE_TAG_PREFIX: "${{ inputs.image-tag-prefix }}" INPUTS_PUSH_CONTAINER_IMAGE: "true" INPUTS_LAST_PUBLISH_COMMIT: "${{ inputs.last-publish-commit }}" - # for now always pass this step so we can get all the followup logging steps to still run even if the export fails - run: ${{ github.action_path }}/export-dynamic.sh || true + # Run export + payload gates. Outputs are written before the script exits. + # Non-zero exit (CLI failure, hollow dist-dynamic, or hollow OCI) fails this step + # so callers cannot treat a bad export as success. Workflow follow-ups that must + # still run should use always() / outputs.failed-exports (see export-dynamic.yaml). + run: | + set +e + "${{ github.action_path }}/export-dynamic.sh" + rc=$? + set -e + if [[ "$rc" -ne 0 ]]; then + echo "::error::One or more plugins failed export or payload validation (see failed-exports output)" + exit "$rc" + fi From f1f6b8252ae0c29ff0cf0c9aaeedd0e716bceb75 Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Mon, 12 Oct 2026 13:57:54 +0200 Subject: [PATCH 04/14] fix(export-dynamic): address hollow-OCI review feedback Drop the redundant Gate 1 before npm pack, and inspect the OCI archive once with skopeo --no-tags before reading Layers[0]. Co-authored-by: Cursor --- export-dynamic/export-dynamic.sh | 9 --------- export-dynamic/validate-plugin-payload.sh | 7 ++++--- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/export-dynamic/export-dynamic.sh b/export-dynamic/export-dynamic.sh index dc72f58..40953a6 100755 --- a/export-dynamic/export-dynamic.sh +++ b/export-dynamic/export-dynamic.sh @@ -227,15 +227,6 @@ else packDestination=${INPUTS_DESTINATION} mkdir -pv "${packDestination}" - # Gate 1 again for archive-only clarity (already passed above after export) - if ! assert_dist_dynamic_payload "$(pwd)/dist-dynamic"; then - echo " Error: hollow or incomplete dist-dynamic export for ${pluginPath}" - errors+=("${pluginPath}") - set -e - popd > /dev/null - continue - fi - echo " running npm pack on a hardlink-free copy of './dist-dynamic'" if ! json=$(pack_dist_dynamic "$(pwd)/dist-dynamic" "$packDestination"); then errors+=("${pluginPath}") diff --git a/export-dynamic/validate-plugin-payload.sh b/export-dynamic/validate-plugin-payload.sh index ad85270..1754097 100755 --- a/export-dynamic/validate-plugin-payload.sh +++ b/export-dynamic/validate-plugin-payload.sh @@ -134,7 +134,7 @@ _read_dynamic_packages_annotation() { _assert_local_image_index_json() { local image_ref="$1" local tool="${INPUTS_CONTAINER_BUILD_TOOL:-podman}" - local tmpdir archive extract_root layer_hash blob index_path payload_len oci_archive + local tmpdir archive extract_root layer_hash blob index_path payload_len oci_archive inspect_json tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/rhdh-export-oci-verify.XXXXXX") || return 1 archive="${tmpdir}/img.tar" @@ -161,8 +161,9 @@ _assert_local_image_index_json() { fi fi - if command -v skopeo >/dev/null 2>&1 && skopeo inspect "oci-archive:${archive}" >/dev/null 2>&1; then - layer_hash=$(skopeo inspect "oci-archive:${archive}" | jq -r '.Layers[0] // empty' | sed 's/^sha256://') + if command -v skopeo >/dev/null 2>&1 \ + && inspect_json=$(skopeo inspect --no-tags "oci-archive:${archive}" 2>/dev/null); then + layer_hash=$(printf '%s' "$inspect_json" | jq -r '.Layers[0] // empty' | sed 's/^sha256://') if [[ -z "$layer_hash" ]]; then echo "no layers in OCI artifact ${image_ref}" >&2 rm -rf "$tmpdir" From 4bc36682d58cf705d823621f6a1c91d994232bc4 Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Mon, 12 Oct 2026 16:58:44 +0200 Subject: [PATCH 05/14] fix(export-dynamic): require skopeo for OCI layer payload check Hard-fail the deep index.json fallback when skopeo is missing instead of using a weak docker-archive find path. Co-authored-by: Cursor --- README.md | 2 +- export-dynamic/validate-plugin-payload.sh | 82 +++++++++---------- .../validate-plugin-payload.test.sh | 10 +++ 3 files changed, 51 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 35c72f5..b8bba78 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Failures are recorded in the `failed-exports` output **and the `export-dynamic` If a workspace fails these checks, fix the overlay `plugins-list.yaml` (export args, `--embed-package`, missing packages) and re-run publish. Do not rely on midstream or weekly registry scans as the primary gate. -Helpers live in [`export-dynamic/validate-plugin-payload.sh`](export-dynamic/validate-plugin-payload.sh); unit tests: `bash export-dynamic/validate-plugin-payload.test.sh`. +Helpers live in [`export-dynamic/validate-plugin-payload.sh`](export-dynamic/validate-plugin-payload.sh); unit tests: `bash export-dynamic/validate-plugin-payload.test.sh`. The layer `index.json` fallback (when the annotation is unreadable) requires **skopeo**; without it the check fails with an install message. ### override-sources diff --git a/export-dynamic/validate-plugin-payload.sh b/export-dynamic/validate-plugin-payload.sh index 1754097..ee419ee 100755 --- a/export-dynamic/validate-plugin-payload.sh +++ b/export-dynamic/validate-plugin-payload.sh @@ -131,65 +131,63 @@ _read_dynamic_packages_annotation() { } # Unpack local image layer and require non-empty index.json. +# Requires skopeo (no docker-archive find fallback). _assert_local_image_index_json() { local image_ref="$1" local tool="${INPUTS_CONTAINER_BUILD_TOOL:-podman}" local tmpdir archive extract_root layer_hash blob index_path payload_len oci_archive inspect_json + if ! command -v skopeo >/dev/null 2>&1; then + echo "skopeo is required for OCI layer payload check; install skopeo and retry" >&2 + return 1 + fi + tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/rhdh-export-oci-verify.XXXXXX") || return 1 archive="${tmpdir}/img.tar" extract_root="${tmpdir}/extract" - if command -v skopeo >/dev/null 2>&1; then - if ! skopeo copy "containers-storage:${image_ref}" "oci-archive:${archive}" >/dev/null 2>&1 \ - && ! skopeo copy "docker-daemon:${image_ref}" "oci-archive:${archive}" >/dev/null 2>&1; then - if ! "$tool" save -o "$archive" "$image_ref" >/dev/null 2>&1; then - echo "failed to copy local image ${image_ref} for deep check" >&2 - rm -rf "$tmpdir" - return 1 - fi - oci_archive="${tmpdir}/img.oci.tar" - if skopeo copy "docker-archive:${archive}" "oci-archive:${oci_archive}" >/dev/null 2>&1; then - archive="$oci_archive" - fi - fi - else + if ! skopeo copy "containers-storage:${image_ref}" "oci-archive:${archive}" >/dev/null 2>&1 \ + && ! skopeo copy "docker-daemon:${image_ref}" "oci-archive:${archive}" >/dev/null 2>&1; then if ! "$tool" save -o "$archive" "$image_ref" >/dev/null 2>&1; then - echo "failed to save local image ${image_ref} for deep check" >&2 - rm -rf "$tmpdir" - return 1 - fi - fi - - if command -v skopeo >/dev/null 2>&1 \ - && inspect_json=$(skopeo inspect --no-tags "oci-archive:${archive}" 2>/dev/null); then - layer_hash=$(printf '%s' "$inspect_json" | jq -r '.Layers[0] // empty' | sed 's/^sha256://') - if [[ -z "$layer_hash" ]]; then - echo "no layers in OCI artifact ${image_ref}" >&2 - rm -rf "$tmpdir" - return 1 - fi - mkdir -p "$extract_root" - tar xf "$archive" -C "$extract_root" - blob="${extract_root}/blobs/sha256/${layer_hash}" - if [[ ! -f "$blob" ]]; then - echo "missing layer blob ${layer_hash}" >&2 + echo "failed to copy local image ${image_ref} for deep check" >&2 rm -rf "$tmpdir" return 1 fi - mkdir -p "${tmpdir}/layer" - if ! tar xzf "$blob" -C "${tmpdir}/layer" 2>/dev/null && ! tar xf "$blob" -C "${tmpdir}/layer" 2>/dev/null; then - echo "failed to unpack plugin layer" >&2 + oci_archive="${tmpdir}/img.oci.tar" + if ! skopeo copy "docker-archive:${archive}" "oci-archive:${oci_archive}" >/dev/null 2>&1; then + echo "failed to convert image ${image_ref} to oci-archive for deep check" >&2 rm -rf "$tmpdir" return 1 fi - index_path="${tmpdir}/layer/index.json" - else - # docker-archive layout: find index.json inside the saved tree - mkdir -p "$extract_root" - tar xf "$archive" -C "$extract_root" - index_path=$(find "$extract_root" -name index.json -print -quit 2>/dev/null || true) + archive="$oci_archive" + fi + + if ! inspect_json=$(skopeo inspect --no-tags "oci-archive:${archive}" 2>/dev/null); then + echo "skopeo inspect failed for ${image_ref}" >&2 + rm -rf "$tmpdir" + return 1 + fi + layer_hash=$(printf '%s' "$inspect_json" | jq -r '.Layers[0] // empty' | sed 's/^sha256://') + if [[ -z "$layer_hash" ]]; then + echo "no layers in OCI artifact ${image_ref}" >&2 + rm -rf "$tmpdir" + return 1 + fi + mkdir -p "$extract_root" + tar xf "$archive" -C "$extract_root" + blob="${extract_root}/blobs/sha256/${layer_hash}" + if [[ ! -f "$blob" ]]; then + echo "missing layer blob ${layer_hash}" >&2 + rm -rf "$tmpdir" + return 1 + fi + mkdir -p "${tmpdir}/layer" + if ! tar xzf "$blob" -C "${tmpdir}/layer" 2>/dev/null && ! tar xf "$blob" -C "${tmpdir}/layer" 2>/dev/null; then + echo "failed to unpack plugin layer" >&2 + rm -rf "$tmpdir" + return 1 fi + index_path="${tmpdir}/layer/index.json" if [[ -z "$index_path" || ! -f "$index_path" ]]; then echo "index.json missing in plugin layer" >&2 diff --git a/export-dynamic/validate-plugin-payload.test.sh b/export-dynamic/validate-plugin-payload.test.sh index 86d834e..c729c19 100755 --- a/export-dynamic/validate-plugin-payload.test.sh +++ b/export-dynamic/validate-plugin-payload.test.sh @@ -91,6 +91,16 @@ assert_true "good frontend dist-dynamic with remoteEntry.js passes" assert_dist_ # --- assert_local_plugin_image (usage / offline only) --- assert_false "empty image ref fails" assert_local_plugin_image "" +# Deep check hard-fails when skopeo is missing (hide real skopeo from PATH). +_no_skopeo_bin="${WORKDIR}/no-skopeo-bin" +mkdir -p "${_no_skopeo_bin}" +# Prefer a PATH that cannot resolve skopeo even if installed system-wide. +_assert_no_skopeo() { + PATH="${_no_skopeo_bin}" command -v skopeo >/dev/null 2>&1 && return 1 + PATH="${_no_skopeo_bin}" _assert_local_image_index_json "localhost/does-not-matter:test" +} +assert_false "deep check fails when skopeo is not installed" _assert_no_skopeo + # --- optional Gate 2 smoke with local podman images (skipped if podman unavailable) --- run_oci_smoke() { local smoke_dir good_ann hollow_tag good_tag From a670bac599d2e7a45e692546789cd40c0252f129 Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Mon, 12 Oct 2026 17:11:30 +0200 Subject: [PATCH 06/14] fix(export-dynamic): exit when skopeo is missing for deep check Missing skopeo is an environment failure; abort the process instead of returning per plugin. Co-authored-by: Cursor --- export-dynamic/validate-plugin-payload.sh | 2 +- export-dynamic/validate-plugin-payload.test.sh | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/export-dynamic/validate-plugin-payload.sh b/export-dynamic/validate-plugin-payload.sh index ee419ee..7f91030 100755 --- a/export-dynamic/validate-plugin-payload.sh +++ b/export-dynamic/validate-plugin-payload.sh @@ -139,7 +139,7 @@ _assert_local_image_index_json() { if ! command -v skopeo >/dev/null 2>&1; then echo "skopeo is required for OCI layer payload check; install skopeo and retry" >&2 - return 1 + exit 1 fi tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/rhdh-export-oci-verify.XXXXXX") || return 1 diff --git a/export-dynamic/validate-plugin-payload.test.sh b/export-dynamic/validate-plugin-payload.test.sh index c729c19..2ec0da7 100755 --- a/export-dynamic/validate-plugin-payload.test.sh +++ b/export-dynamic/validate-plugin-payload.test.sh @@ -91,15 +91,19 @@ assert_true "good frontend dist-dynamic with remoteEntry.js passes" assert_dist_ # --- assert_local_plugin_image (usage / offline only) --- assert_false "empty image ref fails" assert_local_plugin_image "" -# Deep check hard-fails when skopeo is missing (hide real skopeo from PATH). +# Deep check exits the process when skopeo is missing (hide real skopeo from PATH). _no_skopeo_bin="${WORKDIR}/no-skopeo-bin" mkdir -p "${_no_skopeo_bin}" -# Prefer a PATH that cannot resolve skopeo even if installed system-wide. -_assert_no_skopeo() { - PATH="${_no_skopeo_bin}" command -v skopeo >/dev/null 2>&1 && return 1 - PATH="${_no_skopeo_bin}" _assert_local_image_index_json "localhost/does-not-matter:test" +_assert_no_skopeo_exits() { + PATH="${_no_skopeo_bin}" bash -c ' + set -euo pipefail + # shellcheck source=/dev/null + source "'"${SCRIPT_DIR}"'/validate-plugin-payload.sh" + _assert_local_image_index_json "localhost/does-not-matter:test" + ' && return 1 + return 0 } -assert_false "deep check fails when skopeo is not installed" _assert_no_skopeo +assert_true "deep check exits when skopeo is not installed" _assert_no_skopeo_exits # --- optional Gate 2 smoke with local podman images (skipped if podman unavailable) --- run_oci_smoke() { From 2d520ca0a5b87caa853f248b5aa472dbdc52419e Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Mon, 12 Oct 2026 17:11:44 +0200 Subject: [PATCH 07/14] test(export-dynamic): fix no-skopeo exit assertion PATH Invoke bash by absolute path so the restricted PATH only hides skopeo. Co-authored-by: Cursor --- export-dynamic/validate-plugin-payload.test.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/export-dynamic/validate-plugin-payload.test.sh b/export-dynamic/validate-plugin-payload.test.sh index 2ec0da7..157fdd9 100755 --- a/export-dynamic/validate-plugin-payload.test.sh +++ b/export-dynamic/validate-plugin-payload.test.sh @@ -95,12 +95,17 @@ assert_false "empty image ref fails" assert_local_plugin_image "" _no_skopeo_bin="${WORKDIR}/no-skopeo-bin" mkdir -p "${_no_skopeo_bin}" _assert_no_skopeo_exits() { - PATH="${_no_skopeo_bin}" bash -c ' + local bash_bin + bash_bin=$(command -v bash) + # Absolute bash so restricted PATH cannot resolve skopeo (or miss bash itself). + if PATH="${_no_skopeo_bin}" "$bash_bin" -c ' set -euo pipefail # shellcheck source=/dev/null source "'"${SCRIPT_DIR}"'/validate-plugin-payload.sh" _assert_local_image_index_json "localhost/does-not-matter:test" - ' && return 1 + '; then + return 1 + fi return 0 } assert_true "deep check exits when skopeo is not installed" _assert_no_skopeo_exits From bab49fc3314ce2cce508b5068f5739db517fabab Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Tue, 13 Oct 2026 08:34:03 +0200 Subject: [PATCH 08/14] ci(export-dynamic): install skopeo when publishing containers Needed for the Gate 2 layer index.json fallback on ubuntu-latest runners. Co-authored-by: Cursor --- .github/workflows/export-dynamic.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/export-dynamic.yaml b/.github/workflows/export-dynamic.yaml index 19a7e9b..fbf3d3a 100644 --- a/.github/workflows/export-dynamic.yaml +++ b/.github/workflows/export-dynamic.yaml @@ -293,6 +293,16 @@ jobs: - name: Run Typescript type checking run: yarn tsc + # Gate 2 deep check (layer index.json) requires skopeo when the + # io.backstage.dynamic-packages annotation is unreadable. + - name: Install skopeo + if: ${{ inputs.publish-container }} + working-directory: ${{ github.workspace }} + run: | + sudo apt-get update + sudo apt-get install -y skopeo + skopeo --version + - name: Log in to container registry if: ${{ inputs.publish-container }} uses: redhat-actions/podman-login@4934294ad0449894bcd1e9f191899d7292469603 # v1.7 From 081f4b3726a6b5003c5a72d86952f3a101f387c6 Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Tue, 13 Oct 2026 08:43:46 +0200 Subject: [PATCH 09/14] fix(export-dynamic): harden Gate 2 skopeo/JSON checks for review Require skopeo once at Gate 2 entry (exit outside $()), validate skopeo inspect output is JSON before jq, and align helper comments. Co-authored-by: Cursor --- .../test-validate-plugin-payload.yaml | 6 +++ README.md | 2 +- export-dynamic/validate-plugin-payload.sh | 51 ++++++++++++------- .../validate-plugin-payload.test.sh | 12 +++-- 4 files changed, 48 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test-validate-plugin-payload.yaml b/.github/workflows/test-validate-plugin-payload.yaml index 47958ea..201b44c 100644 --- a/.github/workflows/test-validate-plugin-payload.yaml +++ b/.github/workflows/test-validate-plugin-payload.yaml @@ -16,5 +16,11 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install skopeo + run: | + sudo apt-get update + sudo apt-get install -y skopeo + skopeo --version + - name: Run validate-plugin-payload tests run: bash export-dynamic/validate-plugin-payload.test.sh diff --git a/README.md b/README.md index b8bba78..96e6529 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Failures are recorded in the `failed-exports` output **and the `export-dynamic` If a workspace fails these checks, fix the overlay `plugins-list.yaml` (export args, `--embed-package`, missing packages) and re-run publish. Do not rely on midstream or weekly registry scans as the primary gate. -Helpers live in [`export-dynamic/validate-plugin-payload.sh`](export-dynamic/validate-plugin-payload.sh); unit tests: `bash export-dynamic/validate-plugin-payload.test.sh`. The layer `index.json` fallback (when the annotation is unreadable) requires **skopeo**; without it the check fails with an install message. +Helpers live in [`export-dynamic/validate-plugin-payload.sh`](export-dynamic/validate-plugin-payload.sh); unit tests: `bash export-dynamic/validate-plugin-payload.test.sh`. Gate 2 OCI checks require **skopeo** (installed in the publish workflow); without it the process exits with an install message. ### override-sources diff --git a/export-dynamic/validate-plugin-payload.sh b/export-dynamic/validate-plugin-payload.sh index 7f91030..d4be168 100755 --- a/export-dynamic/validate-plugin-payload.sh +++ b/export-dynamic/validate-plugin-payload.sh @@ -8,6 +8,18 @@ EMPTY_DYNAMIC_PACKAGES_ANNOTATION='W10=' # base64("[]") +_require_skopeo() { + if ! command -v skopeo >/dev/null 2>&1; then + echo "skopeo is required for OCI payload checks; install skopeo and retry" >&2 + exit 1 + fi +} + +# Exit 0 when stdin is valid JSON. +_is_json() { + jq -e . >/dev/null 2>&1 +} + # Exit 0 = hollow/empty, 1 = has payload. is_hollow_dynamic_packages_annotation() { local value="${1:-}" @@ -84,7 +96,7 @@ assert_dist_dynamic_payload() { return 1 } -# Read io.backstage.dynamic-packages from a local image via skopeo --raw. +# Requires skopeo. # Prints annotation value to stdout; returns non-zero if unreadable. _read_dynamic_packages_annotation() { local image_ref="$1" @@ -92,23 +104,24 @@ _read_dynamic_packages_annotation() { image_ref="${image_ref#docker://}" image_ref="${image_ref#oci://}" + _require_skopeo + for transport in \ "containers-storage:${image_ref}" \ "docker-daemon:${image_ref}" do - if ! command -v skopeo >/dev/null 2>&1; then - break + raw=$(skopeo inspect --raw "$transport" 2>/dev/null || true) + if [[ -z "$raw" ]] || ! printf '%s' "$raw" | _is_json; then + continue fi - if raw=$(skopeo inspect --raw "$transport" 2>/dev/null); then - annotation=$(printf '%s' "$raw" | jq -r '.annotations["io.backstage.dynamic-packages"] // empty' 2>/dev/null) - if [[ -n "$annotation" ]]; then - printf '%s' "$annotation" - return 0 - fi - # Manifest readable but annotation absent — treat as empty string (hollow). - printf '' + annotation=$(printf '%s' "$raw" | jq -r '.annotations["io.backstage.dynamic-packages"] // empty' 2>/dev/null) + if [[ -n "$annotation" ]]; then + printf '%s' "$annotation" return 0 fi + # Manifest readable but annotation absent — treat as empty string (hollow). + printf '' + return 0 done # Fall back to container-tool image inspect (Labels; may miss OCI annotations). @@ -130,17 +143,14 @@ _read_dynamic_packages_annotation() { return 1 } +# Requires skopeo. # Unpack local image layer and require non-empty index.json. -# Requires skopeo (no docker-archive find fallback). _assert_local_image_index_json() { local image_ref="$1" local tool="${INPUTS_CONTAINER_BUILD_TOOL:-podman}" local tmpdir archive extract_root layer_hash blob index_path payload_len oci_archive inspect_json - if ! command -v skopeo >/dev/null 2>&1; then - echo "skopeo is required for OCI layer payload check; install skopeo and retry" >&2 - exit 1 - fi + _require_skopeo tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/rhdh-export-oci-verify.XXXXXX") || return 1 archive="${tmpdir}/img.tar" @@ -162,8 +172,9 @@ _assert_local_image_index_json() { archive="$oci_archive" fi - if ! inspect_json=$(skopeo inspect --no-tags "oci-archive:${archive}" 2>/dev/null); then - echo "skopeo inspect failed for ${image_ref}" >&2 + inspect_json=$(skopeo inspect --no-tags "oci-archive:${archive}" 2>/dev/null || true) + if [[ -z "$inspect_json" ]] || ! printf '%s' "$inspect_json" | _is_json; then + echo "skopeo inspect failed or returned non-JSON for ${image_ref}" >&2 rm -rf "$tmpdir" return 1 fi @@ -204,6 +215,7 @@ _assert_local_image_index_json() { return 0 } +# Requires skopeo. # After plugin package, before push: require installable payload. assert_local_plugin_image() { local image_ref="${1:-}" @@ -216,6 +228,9 @@ assert_local_plugin_image() { image_ref="${image_ref#docker://}" image_ref="${image_ref#oci://}" + # Must run outside command substitution so exit 1 is not trapped in a $() subshell. + _require_skopeo + if annotation=$(_read_dynamic_packages_annotation "$image_ref"); then if is_hollow_dynamic_packages_annotation "$annotation"; then echo "hollow artifact (empty io.backstage.dynamic-packages annotation) for ${image_ref}" >&2 diff --git a/export-dynamic/validate-plugin-payload.test.sh b/export-dynamic/validate-plugin-payload.test.sh index 157fdd9..77097e0 100755 --- a/export-dynamic/validate-plugin-payload.test.sh +++ b/export-dynamic/validate-plugin-payload.test.sh @@ -91,7 +91,7 @@ assert_true "good frontend dist-dynamic with remoteEntry.js passes" assert_dist_ # --- assert_local_plugin_image (usage / offline only) --- assert_false "empty image ref fails" assert_local_plugin_image "" -# Deep check exits the process when skopeo is missing (hide real skopeo from PATH). +# Gate 2 exits the process when skopeo is missing (hide real skopeo from PATH). _no_skopeo_bin="${WORKDIR}/no-skopeo-bin" mkdir -p "${_no_skopeo_bin}" _assert_no_skopeo_exits() { @@ -102,17 +102,21 @@ _assert_no_skopeo_exits() { set -euo pipefail # shellcheck source=/dev/null source "'"${SCRIPT_DIR}"'/validate-plugin-payload.sh" - _assert_local_image_index_json "localhost/does-not-matter:test" + assert_local_plugin_image "localhost/does-not-matter:test" '; then return 1 fi return 0 } -assert_true "deep check exits when skopeo is not installed" _assert_no_skopeo_exits +assert_true "Gate2 exits when skopeo is not installed" _assert_no_skopeo_exits -# --- optional Gate 2 smoke with local podman images (skipped if podman unavailable) --- +# --- optional Gate 2 smoke with local podman images (skipped if tools unavailable) --- run_oci_smoke() { local smoke_dir good_ann hollow_tag good_tag + if ! command -v skopeo >/dev/null 2>&1; then + echo "SKIP: OCI smoke (skopeo not installed)" + return 0 + fi if ! command -v podman >/dev/null 2>&1; then echo "SKIP: OCI smoke (podman not installed)" return 0 From 537ae4fc16396bafff2ee4f38eaf42a8fb5cc357 Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Tue, 13 Oct 2026 08:49:26 +0200 Subject: [PATCH 10/14] fix(export-dynamic): align Requires skopeo comment style Co-authored-by: Cursor --- export-dynamic/validate-plugin-payload.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/export-dynamic/validate-plugin-payload.sh b/export-dynamic/validate-plugin-payload.sh index d4be168..3c0cd27 100755 --- a/export-dynamic/validate-plugin-payload.sh +++ b/export-dynamic/validate-plugin-payload.sh @@ -96,7 +96,7 @@ assert_dist_dynamic_payload() { return 1 } -# Requires skopeo. +# Requires skopeo # Prints annotation value to stdout; returns non-zero if unreadable. _read_dynamic_packages_annotation() { local image_ref="$1" @@ -143,7 +143,7 @@ _read_dynamic_packages_annotation() { return 1 } -# Requires skopeo. +# Requires skopeo # Unpack local image layer and require non-empty index.json. _assert_local_image_index_json() { local image_ref="$1" @@ -215,7 +215,7 @@ _assert_local_image_index_json() { return 0 } -# Requires skopeo. +# Requires skopeo # After plugin package, before push: require installable payload. assert_local_plugin_image() { local image_ref="${1:-}" @@ -228,7 +228,7 @@ assert_local_plugin_image() { image_ref="${image_ref#docker://}" image_ref="${image_ref#oci://}" - # Must run outside command substitution so exit 1 is not trapped in a $() subshell. + # Outside $() so exit 1 is not trapped in a command-substitution subshell. _require_skopeo if annotation=$(_read_dynamic_packages_annotation "$image_ref"); then From 337e64031675987714410aeba35d6d222db39335 Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Tue, 13 Oct 2026 08:57:11 +0200 Subject: [PATCH 11/14] refactor(export-dynamic): clean up hollow-OCI validation helper Drop redundant skopeo checks and noisy comments; keep JSON validation and a single hard fail at Gate 2 entry. Co-authored-by: Cursor --- .github/workflows/export-dynamic.yaml | 2 -- README.md | 2 +- export-dynamic/validate-plugin-payload.sh | 19 +++++-------------- .../validate-plugin-payload.test.sh | 17 +---------------- 4 files changed, 7 insertions(+), 33 deletions(-) diff --git a/.github/workflows/export-dynamic.yaml b/.github/workflows/export-dynamic.yaml index fbf3d3a..cf27de4 100644 --- a/.github/workflows/export-dynamic.yaml +++ b/.github/workflows/export-dynamic.yaml @@ -293,8 +293,6 @@ jobs: - name: Run Typescript type checking run: yarn tsc - # Gate 2 deep check (layer index.json) requires skopeo when the - # io.backstage.dynamic-packages annotation is unreadable. - name: Install skopeo if: ${{ inputs.publish-container }} working-directory: ${{ github.workspace }} diff --git a/README.md b/README.md index 96e6529..7a49862 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Failures are recorded in the `failed-exports` output **and the `export-dynamic` If a workspace fails these checks, fix the overlay `plugins-list.yaml` (export args, `--embed-package`, missing packages) and re-run publish. Do not rely on midstream or weekly registry scans as the primary gate. -Helpers live in [`export-dynamic/validate-plugin-payload.sh`](export-dynamic/validate-plugin-payload.sh); unit tests: `bash export-dynamic/validate-plugin-payload.test.sh`. Gate 2 OCI checks require **skopeo** (installed in the publish workflow); without it the process exits with an install message. +Helpers live in [`export-dynamic/validate-plugin-payload.sh`](export-dynamic/validate-plugin-payload.sh); unit tests: `bash export-dynamic/validate-plugin-payload.test.sh`. OCI image checks require **skopeo** (installed when `publish-container` is enabled). ### override-sources diff --git a/export-dynamic/validate-plugin-payload.sh b/export-dynamic/validate-plugin-payload.sh index 3c0cd27..5ef4446 100755 --- a/export-dynamic/validate-plugin-payload.sh +++ b/export-dynamic/validate-plugin-payload.sh @@ -37,7 +37,6 @@ is_hollow_dynamic_packages_annotation() { if ! decoded=$(printf '%s' "$trimmed" | base64 -d 2>/dev/null \ || printf '%s' "$trimmed" | base64 -D 2>/dev/null \ || printf '%s' "$trimmed" | base64 --decode 2>/dev/null); then - # Non-decodable annotation is not treated as an empty payload sentinel. return 1 fi if ! parsed=$(printf '%s' "$decoded" | jq -c . 2>/dev/null); then @@ -74,7 +73,6 @@ assert_dist_dynamic_payload() { fi main=$(jq -r '.main // empty' "$pkg_path" 2>/dev/null | sed 's|^\./||') - # Backend dynamic plugins ship a CommonJS/ESM index under dist/. for candidate in \ "${dist_dynamic_dir}/dist/index.cjs.js" \ "${dist_dynamic_dir}/dist/index.esm.js" \ @@ -87,25 +85,23 @@ assert_dist_dynamic_payload() { if [[ -n "$main" && "$main" == dist/* && -f "${dist_dynamic_dir}/${main}" ]]; then return 0 fi - # Frontend dynamic plugins ship a Module Federation remote (scalprum). if [[ -f "${dist_dynamic_dir}/dist/remoteEntry.js" ]] \ || [[ -f "${dist_dynamic_dir}/dist/mf-manifest.json" ]]; then return 0 fi - echo "missing dist-dynamic entry (backend index.* or frontend remoteEntry.js) — hollow export" >&2 + echo "missing dist-dynamic entry (backend index.* or frontend remoteEntry.js) - hollow export" >&2 return 1 } -# Requires skopeo # Prints annotation value to stdout; returns non-zero if unreadable. _read_dynamic_packages_annotation() { local image_ref="$1" local transport raw annotation + local tool="${INPUTS_CONTAINER_BUILD_TOOL:-podman}" + image_ref="${image_ref#docker://}" image_ref="${image_ref#oci://}" - _require_skopeo - for transport in \ "containers-storage:${image_ref}" \ "docker-daemon:${image_ref}" @@ -119,13 +115,11 @@ _read_dynamic_packages_annotation() { printf '%s' "$annotation" return 0 fi - # Manifest readable but annotation absent — treat as empty string (hollow). + # Annotation key absent: empty payload (hollow). printf '' return 0 done - # Fall back to container-tool image inspect (Labels; may miss OCI annotations). - local tool="${INPUTS_CONTAINER_BUILD_TOOL:-podman}" if command -v "$tool" >/dev/null 2>&1; then annotation=$("$tool" image inspect \ --format '{{index .Annotations "io.backstage.dynamic-packages"}}' \ @@ -144,7 +138,6 @@ _read_dynamic_packages_annotation() { } # Requires skopeo -# Unpack local image layer and require non-empty index.json. _assert_local_image_index_json() { local image_ref="$1" local tool="${INPUTS_CONTAINER_BUILD_TOOL:-podman}" @@ -200,7 +193,7 @@ _assert_local_image_index_json() { fi index_path="${tmpdir}/layer/index.json" - if [[ -z "$index_path" || ! -f "$index_path" ]]; then + if [[ ! -f "$index_path" ]]; then echo "index.json missing in plugin layer" >&2 rm -rf "$tmpdir" return 1 @@ -216,7 +209,6 @@ _assert_local_image_index_json() { } # Requires skopeo -# After plugin package, before push: require installable payload. assert_local_plugin_image() { local image_ref="${1:-}" local annotation @@ -228,7 +220,6 @@ assert_local_plugin_image() { image_ref="${image_ref#docker://}" image_ref="${image_ref#oci://}" - # Outside $() so exit 1 is not trapped in a command-substitution subshell. _require_skopeo if annotation=$(_read_dynamic_packages_annotation "$image_ref"); then diff --git a/export-dynamic/validate-plugin-payload.test.sh b/export-dynamic/validate-plugin-payload.test.sh index 77097e0..f421217 100755 --- a/export-dynamic/validate-plugin-payload.test.sh +++ b/export-dynamic/validate-plugin-payload.test.sh @@ -5,18 +5,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=export-dynamic/validate-plugin-payload.sh source "${SCRIPT_DIR}/validate-plugin-payload.sh" -assert_eq() { - local description="$1" - local expected="$2" - local actual="$3" - if [[ "$expected" == "$actual" ]]; then - echo "PASS: ${description}" - else - echo "FAIL: ${description} (expected '${expected}', got '${actual}')" >&2 - exit 1 - fi -} - assert_true() { local description="$1" shift @@ -91,13 +79,11 @@ assert_true "good frontend dist-dynamic with remoteEntry.js passes" assert_dist_ # --- assert_local_plugin_image (usage / offline only) --- assert_false "empty image ref fails" assert_local_plugin_image "" -# Gate 2 exits the process when skopeo is missing (hide real skopeo from PATH). _no_skopeo_bin="${WORKDIR}/no-skopeo-bin" mkdir -p "${_no_skopeo_bin}" _assert_no_skopeo_exits() { local bash_bin bash_bin=$(command -v bash) - # Absolute bash so restricted PATH cannot resolve skopeo (or miss bash itself). if PATH="${_no_skopeo_bin}" "$bash_bin" -c ' set -euo pipefail # shellcheck source=/dev/null @@ -108,9 +94,8 @@ _assert_no_skopeo_exits() { fi return 0 } -assert_true "Gate2 exits when skopeo is not installed" _assert_no_skopeo_exits +assert_true "exits when skopeo is not installed" _assert_no_skopeo_exits -# --- optional Gate 2 smoke with local podman images (skipped if tools unavailable) --- run_oci_smoke() { local smoke_dir good_ann hollow_tag good_tag if ! command -v skopeo >/dev/null 2>&1; then From fba03c207de73c49f3b5e7b3694d905c9f4ba688 Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Tue, 13 Oct 2026 09:08:13 +0200 Subject: [PATCH 12/14] fix(export-dynamic): require skopeo before packaging images Fail immediately when an image prefix is set and skopeo is missing, and install skopeo from the export action as well as the publish workflow. Co-authored-by: Cursor --- .github/workflows/export-dynamic.yaml | 6 ++++-- README.md | 2 +- export-dynamic/action.yaml | 10 ++++++++++ export-dynamic/export-dynamic.sh | 4 ++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/export-dynamic.yaml b/.github/workflows/export-dynamic.yaml index cf27de4..525794e 100644 --- a/.github/workflows/export-dynamic.yaml +++ b/.github/workflows/export-dynamic.yaml @@ -297,8 +297,10 @@ jobs: if: ${{ inputs.publish-container }} working-directory: ${{ github.workspace }} run: | - sudo apt-get update - sudo apt-get install -y skopeo + if ! command -v skopeo >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y skopeo + fi skopeo --version - name: Log in to container registry diff --git a/README.md b/README.md index 7a49862..0458a2d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Failures are recorded in the `failed-exports` output **and the `export-dynamic` If a workspace fails these checks, fix the overlay `plugins-list.yaml` (export args, `--embed-package`, missing packages) and re-run publish. Do not rely on midstream or weekly registry scans as the primary gate. -Helpers live in [`export-dynamic/validate-plugin-payload.sh`](export-dynamic/validate-plugin-payload.sh); unit tests: `bash export-dynamic/validate-plugin-payload.test.sh`. OCI image checks require **skopeo** (installed when `publish-container` is enabled). +Helpers live in [`export-dynamic/validate-plugin-payload.sh`](export-dynamic/validate-plugin-payload.sh); unit tests: `bash export-dynamic/validate-plugin-payload.test.sh`. Image checks require **skopeo** and exit if it is missing. The export action and the publish workflow install it when an image repository prefix is set. ### override-sources diff --git a/export-dynamic/action.yaml b/export-dynamic/action.yaml index 2680f48..e323b0b 100644 --- a/export-dynamic/action.yaml +++ b/export-dynamic/action.yaml @@ -89,6 +89,16 @@ runs: core.setFailed(`Invalid plugins root: ${pluginsRoot}`); } + - name: Install skopeo + if: ${{ inputs.image-repository-prefix != '' }} + shell: bash + run: | + if ! command -v skopeo >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y skopeo + fi + skopeo --version + - name: Run ExportDynamic id: run-export-dynamic shell: bash diff --git a/export-dynamic/export-dynamic.sh b/export-dynamic/export-dynamic.sh index 40953a6..f650a1d 100755 --- a/export-dynamic/export-dynamic.sh +++ b/export-dynamic/export-dynamic.sh @@ -104,6 +104,10 @@ else fi fi + if [[ -n "${INPUTS_IMAGE_REPOSITORY_PREFIX}" ]]; then + _require_skopeo + fi + # We use '|| [[ -n "$plugin" ]]' to catch the last line even if it lacks a newline. while IFS= read -r plugin || [[ -n "$plugin" ]] do From e92da096b8bd1b9f0c1752eb8b0fed562b969e38 Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Tue, 13 Oct 2026 09:52:17 +0200 Subject: [PATCH 13/14] fix(export-dynamic): require skopeo in annotation reader too Match the deep-check hard-fail and the Requires skopeo comment on _read_dynamic_packages_annotation. Co-authored-by: Cursor --- export-dynamic/validate-plugin-payload.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/export-dynamic/validate-plugin-payload.sh b/export-dynamic/validate-plugin-payload.sh index 5ef4446..ef6c93d 100755 --- a/export-dynamic/validate-plugin-payload.sh +++ b/export-dynamic/validate-plugin-payload.sh @@ -93,6 +93,7 @@ assert_dist_dynamic_payload() { return 1 } +# Requires skopeo # Prints annotation value to stdout; returns non-zero if unreadable. _read_dynamic_packages_annotation() { local image_ref="$1" @@ -102,6 +103,8 @@ _read_dynamic_packages_annotation() { image_ref="${image_ref#docker://}" image_ref="${image_ref#oci://}" + _require_skopeo + for transport in \ "containers-storage:${image_ref}" \ "docker-daemon:${image_ref}" From f2a9e17966a64cb4e8d4fba3336ab0dd191248c6 Mon Sep 17 00:00:00 2001 From: Martin Polasko Date: Tue, 13 Oct 2026 09:58:58 +0200 Subject: [PATCH 14/14] chore(export-dynamic): strip noisy comments from hollow-OCI gate Keep Requires skopeo markers and shellcheck directives; drop restated Gate 1/2 narration. Co-authored-by: Cursor --- .github/workflows/export-dynamic.yaml | 2 -- export-dynamic/action.yaml | 5 +---- export-dynamic/export-dynamic.sh | 3 --- export-dynamic/validate-plugin-payload.sh | 14 ++------------ export-dynamic/validate-plugin-payload.test.sh | 4 ---- 5 files changed, 3 insertions(+), 25 deletions(-) diff --git a/.github/workflows/export-dynamic.yaml b/.github/workflows/export-dynamic.yaml index 525794e..30ce0a8 100644 --- a/.github/workflows/export-dynamic.yaml +++ b/.github/workflows/export-dynamic.yaml @@ -413,8 +413,6 @@ jobs: { title: 'Workspace skipped' } ); - # export-dynamic action now fails the step on hollow/failed exports; use always() - # so we still emit the detailed plugin list after that failure. - name: Check export errors if: ${{ always() && !cancelled() && steps.export-dynamic.outputs.failed-exports != '' }} uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 diff --git a/export-dynamic/action.yaml b/export-dynamic/action.yaml index e323b0b..4c615a6 100644 --- a/export-dynamic/action.yaml +++ b/export-dynamic/action.yaml @@ -118,10 +118,7 @@ runs: INPUTS_IMAGE_TAG_PREFIX: "${{ inputs.image-tag-prefix }}" INPUTS_PUSH_CONTAINER_IMAGE: "true" INPUTS_LAST_PUBLISH_COMMIT: "${{ inputs.last-publish-commit }}" - # Run export + payload gates. Outputs are written before the script exits. - # Non-zero exit (CLI failure, hollow dist-dynamic, or hollow OCI) fails this step - # so callers cannot treat a bad export as success. Workflow follow-ups that must - # still run should use always() / outputs.failed-exports (see export-dynamic.yaml). + # Fail the step on hollow/failed exports; callers that still need failed-exports use always(). run: | set +e "${{ github.action_path }}/export-dynamic.sh" diff --git a/export-dynamic/export-dynamic.sh b/export-dynamic/export-dynamic.sh index f650a1d..0ab6a1a 100755 --- a/export-dynamic/export-dynamic.sh +++ b/export-dynamic/export-dynamic.sh @@ -182,7 +182,6 @@ else fi fi - # Gate 1: refuse hollow/incomplete dist-dynamic before package or npm pack if ! assert_dist_dynamic_payload "$(pwd)/dist-dynamic"; then echo " Error: hollow or incomplete dist-dynamic export for ${pluginPath}" errors+=("${pluginPath}") @@ -192,7 +191,6 @@ else fi echo - # package the dynamic plugin in a container image if [[ "${INPUTS_IMAGE_REPOSITORY_PREFIX}" != "" ]] then PLUGIN_NAME=$(jq -r '.name | sub("^@"; "") | sub("[/@]"; "-")' package.json) @@ -201,7 +199,6 @@ else echo "========== Packaging Container ${PLUGIN_CONTAINER_TAG} ==========" if run_cli "${PACKAGE_COMMAND[@]}" --tag "${PLUGIN_CONTAINER_TAG}"; then - # Gate 2: refuse hollow OCI before push / published-exports if ! assert_local_plugin_image "${PLUGIN_CONTAINER_TAG}"; then echo " Error: packaged image has no installable payload for ${pluginPath}" errors+=("${pluginPath}") diff --git a/export-dynamic/validate-plugin-payload.sh b/export-dynamic/validate-plugin-payload.sh index ef6c93d..0e76771 100755 --- a/export-dynamic/validate-plugin-payload.sh +++ b/export-dynamic/validate-plugin-payload.sh @@ -1,10 +1,5 @@ #!/usr/bin/env bash -# Validate dynamic-plugin export / OCI payload before publish. -# Hollow images (empty io.backstage.dynamic-packages or empty layer index.json) -# must not be pushed to the registry. -# -# Sourced by export-dynamic.sh. Functions return 0 on success / healthy, -# non-zero on failure / hollow. +# Hollow OCI / dist-dynamic payload checks. Sourced by export-dynamic.sh. EMPTY_DYNAMIC_PACKAGES_ANNOTATION='W10=' # base64("[]") @@ -15,12 +10,11 @@ _require_skopeo() { fi } -# Exit 0 when stdin is valid JSON. _is_json() { jq -e . >/dev/null 2>&1 } -# Exit 0 = hollow/empty, 1 = has payload. +# Exit 0 = hollow, 1 = has payload. is_hollow_dynamic_packages_annotation() { local value="${1:-}" local trimmed decoded parsed @@ -48,8 +42,6 @@ is_hollow_dynamic_packages_annotation() { return 1 } -# Pre-OCI export check: rhdh-cli writes dist-dynamic/ (package + dist entry). -# Prints reason to stderr and returns non-zero on failure. assert_dist_dynamic_payload() { local dist_dynamic_dir="${1:-}" local pkg_path pkg_name main candidate @@ -94,7 +86,6 @@ assert_dist_dynamic_payload() { } # Requires skopeo -# Prints annotation value to stdout; returns non-zero if unreadable. _read_dynamic_packages_annotation() { local image_ref="$1" local transport raw annotation @@ -118,7 +109,6 @@ _read_dynamic_packages_annotation() { printf '%s' "$annotation" return 0 fi - # Annotation key absent: empty payload (hollow). printf '' return 0 done diff --git a/export-dynamic/validate-plugin-payload.test.sh b/export-dynamic/validate-plugin-payload.test.sh index f421217..c929162 100755 --- a/export-dynamic/validate-plugin-payload.test.sh +++ b/export-dynamic/validate-plugin-payload.test.sh @@ -30,7 +30,6 @@ assert_false() { WORKDIR=$(mktemp -d /tmp/validate-plugin-payload-test-XXXXXX) trap 'rm -rf "$WORKDIR"' EXIT -# --- is_hollow_dynamic_packages_annotation --- assert_true "null/blank is hollow" is_hollow_dynamic_packages_annotation "" assert_true "whitespace is hollow" is_hollow_dynamic_packages_annotation " " assert_true "W10= sentinel is hollow" is_hollow_dynamic_packages_annotation "W10=" @@ -42,7 +41,6 @@ payload_b64=$(printf '[{"plugin":{"name":"@scope/pkg-dynamic","version":"1.0.0"} assert_false "non-empty package list is healthy" is_hollow_dynamic_packages_annotation "$payload_b64" assert_false "non-base64 junk is not treated as hollow" is_hollow_dynamic_packages_annotation "not-base64!!!" -# --- assert_dist_dynamic_payload --- missing_dir="${WORKDIR}/missing" mkdir -p "$missing_dir" assert_false "missing package.json fails" assert_dist_dynamic_payload "$missing_dir" @@ -76,7 +74,6 @@ printf '%s\n' '{"name":"@scope/pkg-dynamic","version":"1.0.0","main":"src/index. printf '// mf\n' > "${good_frontend}/dist/remoteEntry.js" assert_true "good frontend dist-dynamic with remoteEntry.js passes" assert_dist_dynamic_payload "$good_frontend" -# --- assert_local_plugin_image (usage / offline only) --- assert_false "empty image ref fails" assert_local_plugin_image "" _no_skopeo_bin="${WORKDIR}/no-skopeo-bin" @@ -114,7 +111,6 @@ run_oci_smoke() { smoke_dir=$(mktemp -d /tmp/validate-plugin-payload-oci-XXXXXX) hollow_tag="localhost/rhdh-hollow-smoke:test" good_tag="localhost/rhdh-good-smoke:test" - # cleanup images + dir even on failure # shellcheck disable=SC2064 trap "podman rmi -f '${hollow_tag}' '${good_tag}' >/dev/null 2>&1 || true; rm -rf '${smoke_dir}'; rm -rf '${WORKDIR}'" EXIT