diff --git a/.github/workflows/export-dynamic.yaml b/.github/workflows/export-dynamic.yaml index 25d7ebd..30ce0a8 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 + - name: Install skopeo + if: ${{ inputs.publish-container }} + working-directory: ${{ github.workspace }} + run: | + 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 if: ${{ inputs.publish-container }} uses: redhat-actions/podman-login@4934294ad0449894bcd1e9f191899d7292469603 # v1.7 @@ -404,7 +414,7 @@ jobs: ); - 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/.github/workflows/test-validate-plugin-payload.yaml b/.github/workflows/test-validate-plugin-payload.yaml new file mode 100644 index 0000000..2cac10b --- /dev/null +++ b/.github/workflows/test-validate-plugin-payload.yaml @@ -0,0 +1,30 @@ +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: Install skopeo + run: | + sudo apt-get update + sudo apt-get install -y skopeo + if ! command -v skopeo >/dev/null 2>&1; then + echo "[ERROR] Skopeo is required to check OCI artifacts - install skopeo to continue. Must exit!" >&2 + exit 1 + fi + 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 7a49131..ef2802c 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 +- **Empty-OCI gates** (see below) so empty exports are not pushed to ghcr.io + +#### 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 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 **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 — an empty `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. + +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 47cf3de..7b75725 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 @@ -108,5 +118,13 @@ 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 + # Fail the step on empty/failed exports; callers that still need failed-exports use always(). + 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 diff --git a/export-dynamic/export-dynamic.sh b/export-dynamic/export-dynamic.sh index ac33f6f..ee9a217 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() { @@ -102,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 @@ -175,9 +181,16 @@ else echo " ✅ backstage.features: $features" fi fi + + if ! assert_dist_dynamic_payload "$(pwd)/dist-dynamic"; then + echo " Error: empty 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 if [[ "${INPUTS_IMAGE_REPOSITORY_PREFIX}" != "" ]] then PLUGIN_NAME=$(jq -r '.name | sub("^@"; "") | sub("[/@]"; "-")' package.json) @@ -186,7 +199,10 @@ else echo "========== Packaging Container ${PLUGIN_CONTAINER_TAG} ==========" if run_cli "${PACKAGE_COMMAND[@]}" --tag "${PLUGIN_CONTAINER_TAG}"; then - if [[ "${INPUTS_PUSH_CONTAINER_IMAGE}" == "true" ]] + 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 diff --git a/export-dynamic/validate-plugin-payload.sh b/export-dynamic/validate-plugin-payload.sh new file mode 100755 index 0000000..1fd7400 --- /dev/null +++ b/export-dynamic/validate-plugin-payload.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# Empty OCI / dist-dynamic payload checks. Sourced by export-dynamic.sh. + +EMPTY_DYNAMIC_PACKAGES_ANNOTATION='W10=' # base64("[]") + +_require_skopeo() { + if ! command -v skopeo >/dev/null 2>&1; then + echo "[ERROR] Skopeo is required to check OCI artifacts - install skopeo to continue. Must exit!" >&2 + exit 1 + fi +} + +_is_json() { + jq -e . >/dev/null 2>&1 +} + +# Exit 0 = empty, 1 = has payload. +is_empty_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 + 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 +} + +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 + 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) - empty export" >&2 + return 1 +} + +# Requires skopeo +_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}" + do + raw=$(skopeo inspect --raw "$transport" 2>/dev/null || true) + if [[ -z "$raw" ]] || ! printf '%s' "$raw" | _is_json; then + continue + fi + 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 + printf '' + return 0 + done + + 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 +} + +# Requires skopeo +_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 + + _require_skopeo + + tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/rhdh-export-oci-verify.XXXXXX") || return 1 + archive="${tmpdir}/img.tar" + extract_root="${tmpdir}/extract" + + 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 + echo "failed to convert image ${image_ref} to oci-archive for deep check" >&2 + rm -rf "$tmpdir" + return 1 + fi + archive="$oci_archive" + fi + + 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 + 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 [[ ! -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 +} + +# Requires skopeo +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://}" + + _require_skopeo + + if annotation=$(_read_dynamic_packages_annotation "$image_ref"); then + if is_empty_dynamic_packages_annotation "$annotation"; then + echo "empty 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..6362a1a --- /dev/null +++ b/export-dynamic/validate-plugin-payload.test.sh @@ -0,0 +1,139 @@ +#!/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_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 + +assert_true "null/blank is empty" is_empty_dynamic_packages_annotation "" +assert_true "whitespace is empty" is_empty_dynamic_packages_annotation " " +assert_true "W10= sentinel is empty" is_empty_dynamic_packages_annotation "W10=" +empty_list_b64=$(printf '[]' | base64) +assert_true "base64([]) is empty" is_empty_dynamic_packages_annotation "$empty_list_b64" +empty_obj_b64=$(printf '{}' | base64) +assert_true "base64({}) is empty" is_empty_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_empty_dynamic_packages_annotation "$payload_b64" +assert_false "non-base64 junk is not treated as empty" is_empty_dynamic_packages_annotation "not-base64!!!" + +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" + +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_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() { + local bash_bin + bash_bin=$(command -v bash) + if PATH="${_no_skopeo_bin}" "$bash_bin" -c ' + set -euo pipefail + # shellcheck source=/dev/null + source "'"${SCRIPT_DIR}"'/validate-plugin-payload.sh" + assert_local_plugin_image "localhost/does-not-matter:test" + '; then + return 1 + fi + return 0 +} +assert_true "exits when skopeo is not installed" _assert_no_skopeo_exits + +run_oci_smoke() { + local smoke_dir good_ann empty_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 + 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) + empty_tag="localhost/rhdh-empty-smoke:test" + good_tag="localhost/rhdh-good-smoke:test" + # shellcheck disable=SC2064 + trap "podman rmi -f '${empty_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 "${empty_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 empty W10= image" assert_local_plugin_image "${empty_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."