Skip to content
Merged
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
31 changes: 31 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,34 @@ jobs:
fetch-depth: 1
persist-credentials: false
- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
- name: Test release build-output materialization
run: |
./tests/release_build_output_descriptors_test.sh
./tests/release_build_output_prepare_test.sh
./tests/release_build_output_test.sh
- name: Prepare release build-output dispatch smoke test
run: |
mkdir -p release-build-output-smoke
printf '%s\n' smoke >release-build-output-smoke/package.tar.gz
- name: Run release build-output dispatch smoke test
uses: ./release-build-output-dispatch
env:
SHARED_ACTIONS_REPO: ${{ github.event.pull_request.head.repo.full_name }}
SHARED_ACTIONS_REF: ${{ github.event.pull_request.head.sha }}
with:
artifact-type: custom
output-directory: release-build-output-smoke
release-artifacts: '[{"path":"package.tar.gz"}]'
release-package: '{"ecosystem":"archive","name":"smoke","version":"1.0"}'
release-unit: archive:smoke
source-artifact-name: release-build-output-dispatch-smoke
source-sha: ${{ github.event.pull_request.head.sha }}
- name: Verify release build-output dispatch smoke test
run: |
jq -e '
.artifacts[0].unit_id == "archive:smoke"
and .artifacts[0].path == "package.tar.gz"
' release-build-output-smoke/release-build-output.json >/dev/null
jq -e '
.metadata.artifacts == [{path: "package.tar.gz", sbom_kind: "generated-identity"}]
' release-build-output-smoke/release-build-metadata.json >/dev/null
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,41 @@ A dispatch action is one that:
* clones the shared-actions repository (repo/ref changeable using env vars)
* runs (dispatches to) another action within the clone, using a relative path

## Release build-output companions

`release-build-output-dispatch` validates a producer's local build artifact
directory and uploads a companion artifact named
`release-build-output-<source-artifact-name>`. The companion contains
`release-build-output.json`, `release-build-metadata.json`, provenance, and an
SBOM record for every primary artifact.

Conda and wheel jobs can set `artifact-type` to `conda` or `wheel` and omit
`release-artifacts`; the implementation reads package metadata from the built
files. Custom bundles provide explicit artifact descriptors and either inline
package identity or a producer-created package JSON file.

```yaml
- name: Create release build-output companion
uses: rapidsai/shared-actions/release-build-output-dispatch@main
with:
artifact-type: wheel
output-directory: ${{ steps.package-name.outputs.WHEEL_OUTPUT_DIR }}
release-unit: wheel:example
source-artifact-name: ${{ steps.package-name.outputs.RAPIDS_PACKAGE_NAME }}
source-sha: ${{ github.sha }}
```

A descriptor-selected producer SBOM is classified as `producer-dependency`.
When no SBOM is supplied, the action generates an SPDX artifact-identity
envelope and classifies it as `generated-identity`. The generated envelope
contains the primary artifact's identity and SHA-256 but no dependency
inventory; it must not be treated as dependency coverage.

The dispatch wrapper honors `SHARED_ACTIONS_REPO` and `SHARED_ACTIONS_REF`.
When neither is set, it checks out the same repository and ref used to invoke
the wrapper, which allows a feature-branch wrapper to dispatch to its matching
implementation during canary testing.

There can be more complicated arrangements of more actions, but the idea is to
have the local clone of the shared-actions repository be the first step of an action.

Expand Down
72 changes: 72 additions & 0 deletions release-build-output-dispatch/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Dispatch release build output
description: Check out the selected shared-actions revision and create a release build-output companion.

inputs:
artifact-type:
description: One of conda, wheel, or custom.
required: true
release-unit:
description: Release-platform unit ID for every primary artifact in this bundle.
required: true
release-package:
description: JSON package fields shared by the bundle.
required: false
release-package-file:
description: Relative path to producer-created package JSON inside output-directory.
required: false
release-artifacts:
description: JSON artifact and evidence descriptors relative to output-directory.
required: false
output-directory:
description: Directory containing the primary files and any producer-supplied evidence.
required: true
manifest-name:
description: Filename to write inside output-directory.
required: false
default: release-build-output.json
metadata-name:
description: Filename for the build metadata envelope.
required: false
default: release-build-metadata.json
source-artifact-name:
description: Name of the GitHub Actions artifact bundle containing this output.
required: true
source-sha:
description: Source revision built by the producing job.
required: false

outputs:
manifest-path:
description: Absolute path to the generated manifest.
value: ${{ steps.release-build-output.outputs.manifest-path }}
metadata-path:
description: Absolute path to the build metadata envelope.
value: ${{ steps.release-build-output.outputs.metadata-path }}
manifest-artifact-name:
description: Name of the uploaded GitHub Actions companion artifact.
value: ${{ steps.release-build-output.outputs.manifest-artifact-name }}

runs:
using: composite
steps:
- name: Check out shared-actions implementation
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ${{ env.SHARED_ACTIONS_REPO || github.action_repository || 'rapidsai/shared-actions' }}
ref: ${{ env.SHARED_ACTIONS_REF || github.action_ref || 'main' }}
path: ./shared-actions
persist-credentials: false
- id: release-build-output
name: Create release build-output companion
uses: ./shared-actions/release-build-output
with:
artifact-type: ${{ inputs.artifact-type }}
release-unit: ${{ inputs.release-unit }}
release-package: ${{ inputs.release-package }}
release-package-file: ${{ inputs.release-package-file }}
release-artifacts: ${{ inputs.release-artifacts }}
output-directory: ${{ inputs.output-directory }}
manifest-name: ${{ inputs.manifest-name }}
metadata-name: ${{ inputs.metadata-name }}
source-artifact-name: ${{ inputs.source-artifact-name }}
source-sha: ${{ inputs.source-sha }}
90 changes: 90 additions & 0 deletions release-build-output/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: Create release build output companion
description: Validate a build artifact bundle and upload its release-platform manifest and evidence companion.

inputs:
artifact-type:
description: One of conda, wheel, or custom. Conda and wheel descriptors are derived when release-artifacts is omitted.
required: true
release-unit:
description: Release-platform unit ID for every primary artifact in this bundle.
required: true
release-package:
description: JSON package fields shared by the bundle. Provide this or release-package-file for custom artifacts.
required: false
release-package-file:
description: Relative path to producer-created package JSON inside output-directory.
required: false
release-artifacts:
description: JSON artifact and evidence descriptors relative to output-directory. Required for custom artifacts.
required: false
output-directory:
description: Directory containing the primary files and any producer-supplied evidence.
required: true
manifest-name:
description: Filename to write inside output-directory.
required: false
default: release-build-output.json
metadata-name:
description: Filename for the build-environment and SBOM-classification envelope.
required: false
default: release-build-metadata.json
source-artifact-name:
description: Name of the GitHub Actions artifact bundle containing this output.
required: true
source-sha:
description: Source revision built by the producing job. Defaults to the current workflow SHA.
required: false

outputs:
manifest-path:
description: Absolute path to the generated manifest.
value: ${{ steps.materialize.outputs.manifest-path }}
metadata-path:
description: Absolute path to the build metadata envelope.
value: ${{ steps.materialize.outputs.metadata-path }}
manifest-artifact-name:
description: Name of the uploaded GitHub Actions companion artifact.
value: ${{ steps.companion-name.outputs.name }}

runs:
using: composite
steps:
- id: prepare
name: Describe release artifacts
shell: bash
env:
RELEASE_ARTIFACTS: ${{ inputs.release-artifacts }}
RELEASE_ARTIFACT_TYPE: ${{ inputs.artifact-type }}
RELEASE_OUTPUT_DIRECTORY: ${{ inputs.output-directory }}
RELEASE_PACKAGE: ${{ inputs.release-package }}
RELEASE_PACKAGE_FILE: ${{ inputs.release-package-file }}
run: ./shared-actions/release-build-output/prepare.sh
- id: materialize
name: Materialize release build-output records
shell: bash
env:
RELEASE_ARTIFACTS: ${{ steps.prepare.outputs.artifacts }}
RELEASE_MANIFEST_NAME: ${{ inputs.manifest-name }}
RELEASE_METADATA_NAME: ${{ inputs.metadata-name }}
RELEASE_OUTPUT_DIRECTORY: ${{ inputs.output-directory }}
RELEASE_PACKAGE: ${{ steps.prepare.outputs.package }}
RELEASE_PACKAGE_FILE: ${{ inputs.release-package-file }}
RELEASE_SOURCE_ARTIFACT_NAME: ${{ inputs.source-artifact-name }}
RELEASE_SOURCE_SHA: ${{ inputs.source-sha || github.sha }}
RELEASE_UNIT: ${{ inputs.release-unit }}
run: ./shared-actions/release-build-output/materialize.sh
- id: companion-name
name: Set companion artifact name
shell: bash
env:
SOURCE_ARTIFACT_NAME: ${{ inputs.source-artifact-name }}
run: echo "name=release-build-output-${SOURCE_ARTIFACT_NAME}" >>"${GITHUB_OUTPUT}"
- name: Upload release build-output companion
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
if-no-files-found: error
name: ${{ steps.companion-name.outputs.name }}
path: |
${{ inputs.output-directory }}/${{ inputs.manifest-name }}
${{ inputs.output-directory }}/${{ inputs.metadata-name }}
${{ inputs.output-directory }}/release-evidence/**
69 changes: 69 additions & 0 deletions release-build-output/describe-conda.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.

set -euo pipefail

if [[ "$#" -ne 1 || ! -d "$1" ]]; then
echo "usage: $0 CONDA_OUTPUT_DIRECTORY" >&2
exit 1
fi

output_directory="$(realpath "$1")"
descriptors='[]'
package_count=0

while IFS= read -r package_path; do
package_path="$(realpath "${package_path}")"
if [[ "${package_path}" != "${output_directory}"/* ]]; then
echo "Conda package must resolve inside output directory: ${package_path}" >&2
exit 1
fi
relative_path="${package_path#"${output_directory}/"}"

case "${package_path}" in
*.conda)
info_members=()
while IFS= read -r info_member; do
info_members+=("${info_member}")
done < <(unzip -Z1 "${package_path}" | awk '/^info-.*\.tar\.zst$/')
if [[ "${#info_members[@]}" -ne 1 ]]; then
echo ".conda package must contain exactly one info-*.tar.zst member: ${relative_path}" >&2
exit 1
fi
index_json="$(unzip -p "${package_path}" "${info_members[0]}" | zstd -dc | tar -xOf - info/index.json)"
;;
*.tar.bz2)
index_json="$(tar -xOjf "${package_path}" info/index.json)"
;;
*)
echo "unsupported Conda package extension: ${relative_path}" >&2
exit 1
;;
esac

if ! jq -e '
type == "object"
and (.name | type == "string" and length > 0)
and (.version | type == "string" and length > 0)
and (.build | type == "string" and length > 0)
and (.subdir | type == "string" and length > 0)
' <<<"${index_json}" >/dev/null; then
echo "Conda info/index.json must contain exact name, version, build, and subdir fields: ${relative_path}" >&2
exit 1
fi

package="$(jq -c '{ecosystem: "conda", name, version, build, platform: .subdir}' <<<"${index_json}")"
descriptors="$(jq -cn \
--arg path "${relative_path}" \
--argjson package "${package}" \
--argjson current "${descriptors}" \
'$current + [{path: $path, package: $package}]')"
package_count=$((package_count + 1))
done < <(find "${output_directory}" -type f \( -name '*.conda' -o -name '*.tar.bz2' \) -print | sort)

if [[ "${package_count}" -eq 0 ]]; then
echo "Conda output directory contains no .conda or .tar.bz2 packages: ${output_directory}" >&2
exit 1
fi

printf '%s\n' "${descriptors}"
54 changes: 54 additions & 0 deletions release-build-output/describe-wheels.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.

set -euo pipefail

if [[ "$#" -ne 1 || ! -d "$1" ]]; then
echo "usage: $0 WHEEL_OUTPUT_DIRECTORY" >&2
exit 1
fi

output_directory="$(realpath "$1")"
descriptors='[]'
wheel_count=0

while IFS= read -r wheel_path; do
wheel_path="$(realpath "${wheel_path}")"
if [[ "${wheel_path}" != "${output_directory}"/* ]]; then
echo "wheel must resolve inside output directory: ${wheel_path}" >&2
exit 1
fi
relative_path="${wheel_path#"${output_directory}/"}"

metadata_members=()
while IFS= read -r metadata_member; do
metadata_members+=("${metadata_member}")
done < <(unzip -Z1 "${wheel_path}" | awk '/\.dist-info\/METADATA$/')
if [[ "${#metadata_members[@]}" -ne 1 ]]; then
echo "wheel must contain exactly one .dist-info/METADATA file: ${relative_path}" >&2
exit 1
fi

metadata="$(unzip -p "${wheel_path}" "${metadata_members[0]}")"
package_name="$(awk 'tolower($0) ~ /^name:[[:space:]]*/ {sub(/^[^:]*:[[:space:]]*/, ""); sub(/\r$/, ""); print; exit}' <<<"${metadata}")"
package_version="$(awk 'tolower($0) ~ /^version:[[:space:]]*/ {sub(/^[^:]*:[[:space:]]*/, ""); sub(/\r$/, ""); print; exit}' <<<"${metadata}")"
if [[ -z "${package_name}" || -z "${package_version}" ]]; then
echo "wheel metadata must contain non-empty Name and Version fields: ${relative_path}" >&2
exit 1
fi

descriptors="$(jq -cn \
--arg path "${relative_path}" \
--arg name "${package_name}" \
--arg version "${package_version}" \
--argjson current "${descriptors}" \
'$current + [{path: $path, package: {ecosystem: "wheel", name: $name, version: $version}}]')"
wheel_count=$((wheel_count + 1))
done < <(find "${output_directory}" -type f -name '*.whl' -print | sort)

if [[ "${wheel_count}" -eq 0 ]]; then
echo "wheel output directory contains no .whl files: ${output_directory}" >&2
exit 1
fi

printf '%s\n' "${descriptors}"
Loading
Loading