Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/export-dynamic.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/test-validate-plugin-payload.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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
skopeo --version

- name: Run validate-plugin-payload tests
run: bash export-dynamic/validate-plugin-payload.test.sh
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 — 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.

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

Expand Down
22 changes: 20 additions & 2 deletions export-dynamic/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 hollow/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
20 changes: 18 additions & 2 deletions export-dynamic/export-dynamic.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -175,9 +181,16 @@ else
echo " ✅ backstage.features: $features"
fi
fi

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
if [[ "${INPUTS_IMAGE_REPOSITORY_PREFIX}" != "" ]]
then
PLUGIN_NAME=$(jq -r '.name | sub("^@"; "") | sub("[/@]"; "-")' package.json)
Expand All @@ -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
Expand Down
228 changes: 228 additions & 0 deletions export-dynamic/validate-plugin-payload.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
# Hollow 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 "skopeo is required for OCI payload checks; install skopeo and retry" >&2
exit 1
fi
}

_is_json() {

Check warning on line 13 in export-dynamic/validate-plugin-payload.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an explicit return statement at the end of the function.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugin-export-utils&issues=AaE0AUX66lgmrGg-46ZF&open=AaE0AUX66lgmrGg-46ZF&pullRequest=182
jq -e . >/dev/null 2>&1
}

# Exit 0 = hollow, 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
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) - hollow export" >&2
return 1
}

# Requires skopeo
_read_dynamic_packages_annotation() {
Comment thread
polasudo marked this conversation as resolved.
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" == "<no value>" ]]; then
annotation=$("$tool" image inspect \
--format '{{index .Config.Labels "io.backstage.dynamic-packages"}}' \
"$image_ref" 2>/dev/null || true)
fi
if [[ -n "$annotation" && "$annotation" != "<no value>" ]]; 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_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"
}
Loading
Loading