fix(deps): update toolhive (#814) #695
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build Skill Artifacts | |
| on: | |
| push: | |
| branches: [ main ] | |
| paths: | |
| - 'skills/**/*.yaml' | |
| - 'cmd/dockhand/**' | |
| - 'internal/skills/**' | |
| - 'scripts/skill-scan/**' | |
| - '.github/workflows/build-skills.yml' | |
| - '.github/workflows/skill-scan-report.yml' | |
| - 'go.mod' | |
| - 'go.sum' | |
| pull_request: | |
| branches: [ main ] | |
| paths: | |
| - 'skills/**/*.yaml' | |
| - 'cmd/dockhand/**' | |
| - 'internal/skills/**' | |
| - 'scripts/skill-scan/**' | |
| - '.github/workflows/build-skills.yml' | |
| - '.github/workflows/skill-scan-report.yml' | |
| - 'go.mod' | |
| - 'go.sum' | |
| workflow_dispatch: | |
| permissions: {} | |
| # Never cancel a run outright — each push or PR update publishes/signs/ | |
| # attests artifacts for that specific commit, and cancelling one would | |
| # silently skip its release. queue: max keeps every queued run pending | |
| # instead of just the latest, since the default queue depth of 1 would | |
| # otherwise drop a run sandwiched between two others. GitHub rejects | |
| # queue: max combined with a cancel-in-progress that can resolve to true, | |
| # so this can no longer cancel superseded PR runs the way it used to. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: false | |
| queue: max | |
| env: | |
| REGISTRY: ghcr.io | |
| jobs: | |
| discover-skill-configs: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| changed-configs: ${{ steps.find-configs.outputs.changed-configs }} | |
| scan-configs: ${{ steps.find-configs.outputs.scan-configs }} | |
| smoke: ${{ steps.find-configs.outputs.smoke }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| - name: Find skill configurations to build | |
| id: find-configs | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| BASE_REF: ${{ github.base_ref }} | |
| run: | | |
| # Find all skill spec.yaml files | |
| all_configs=$(find skills -name "spec.yaml" -type f 2>/dev/null | sort) | |
| if [ -z "$all_configs" ]; then | |
| echo "No skill configurations found" | |
| echo "changed-configs=[]" >> $GITHUB_OUTPUT | |
| echo "scan-configs=[]" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| # Decide whether a change to core build inputs warrants rebuilding | |
| # every skill. Only inputs that alter the built artifact do: dockhand | |
| # source and the skills packaging library. go.mod/go.sum count only | |
| # when the toolhive modules changed — they drive the OCI packaging | |
| # (internal/skills, cmd/dockhand), so a bump there can alter every | |
| # artifact. Unrelated dependency bumps must not rebuild all ~160 | |
| # skills (it floods GHCR and hits its rate limit). Changes to the | |
| # scan scripts or this workflow do not alter artifacts, so they are | |
| # handled as a smoke test below rather than a full rebuild. | |
| # Args: $1 = git diff range, $2 = newline-separated changed files. | |
| core_rebuild_needed() { | |
| local range="$1" changed="$2" | |
| if echo "$changed" | grep -qE "(cmd/dockhand/|internal/skills/)"; then | |
| return 0 | |
| fi | |
| if echo "$changed" | grep -qE "^go\.(mod|sum)$"; then | |
| if git diff "$range" -- go.mod go.sum | grep -qE '^[+-].*stacklok/toolhive'; then | |
| return 0 | |
| fi | |
| fi | |
| return 1 | |
| } | |
| # Smoke mode: set when the only relevant change is to this workflow | |
| # or the scan scripts. See the block after the event dispatch below. | |
| smoke=false | |
| if [ "$EVENT_NAME" == "workflow_dispatch" ]; then | |
| configs_to_build="$all_configs" | |
| configs_to_scan="$all_configs" | |
| echo "Manual trigger - building all skill configurations" | |
| elif [ "$EVENT_NAME" == "pull_request" ]; then | |
| diff_range="origin/${BASE_REF}...HEAD" | |
| changed_files=$(git diff --name-only "$diff_range") | |
| configs_to_build="" | |
| configs_to_scan="" | |
| # Scan only specs that actually changed; build the superset below. | |
| for config in $all_configs; do | |
| config_dir=$(dirname "$config") | |
| if echo "$changed_files" | grep -q "^$config$" || echo "$changed_files" | grep -q "^$config_dir/"; then | |
| configs_to_build="$configs_to_build$config"$'\n' | |
| configs_to_scan="$configs_to_scan$config"$'\n' | |
| fi | |
| done | |
| # If core build inputs changed, rebuild all skills — but only scan specs that truly changed. | |
| if core_rebuild_needed "$diff_range" "$changed_files"; then | |
| echo "Core build inputs changed - rebuilding all skill configurations (scan only changed specs)" | |
| configs_to_build="$all_configs" | |
| fi | |
| else | |
| diff_range="HEAD~1..HEAD" | |
| changed_files=$(git diff --name-only "$diff_range") | |
| configs_to_build="" | |
| configs_to_scan="" | |
| for config in $all_configs; do | |
| config_dir=$(dirname "$config") | |
| if echo "$changed_files" | grep -q "^$config$" || echo "$changed_files" | grep -q "^$config_dir/"; then | |
| configs_to_build="$configs_to_build$config"$'\n' | |
| configs_to_scan="$configs_to_scan$config"$'\n' | |
| fi | |
| done | |
| if core_rebuild_needed "$diff_range" "$changed_files"; then | |
| echo "Core build inputs changed - rebuilding all skill configurations (scan only changed specs)" | |
| configs_to_build="$all_configs" | |
| fi | |
| fi | |
| # If the only relevant change was to this workflow or the scan | |
| # scripts (no spec changed, no full rebuild triggered), run a single | |
| # representative skill through the validate-skills and | |
| # skill-security-scan legs. This exercises the scan/validate | |
| # toolchain (e.g. a Python or scanner bump) without rebuilding or | |
| # republishing every skill. build-skill-artifacts is skipped for | |
| # smoke runs via the `smoke` output. | |
| if [ "$EVENT_NAME" != "workflow_dispatch" ] \ | |
| && [ -z "$configs_to_build" ] \ | |
| && echo "$changed_files" | grep -qE "(^\.github/workflows/build-skills\.yml$|^\.github/workflows/skill-scan-report\.yml$|^scripts/skill-scan/)"; then | |
| echo "Only workflow/scan-script changed - smoke testing a representative skill (no build/push)" | |
| sample=$(find skills -name "spec.yaml" -type f 2>/dev/null | sort | head -1) | |
| configs_to_build="$sample" | |
| configs_to_scan="$sample" | |
| smoke=true | |
| fi | |
| configs_json=$(echo "$configs_to_build" | grep -v '^$' | jq -R -s -c 'split("\n")[:-1]') | |
| scan_configs_json=$(echo "$configs_to_scan" | grep -v '^$' | jq -R -s -c 'split("\n")[:-1]') | |
| echo "changed-configs=$configs_json" >> $GITHUB_OUTPUT | |
| echo "scan-configs=$scan_configs_json" >> $GITHUB_OUTPUT | |
| echo "smoke=$smoke" >> $GITHUB_OUTPUT | |
| echo "Skill configurations to build: $configs_json" | |
| echo "Skill configurations to scan: $scan_configs_json" | |
| validate-skills: | |
| needs: discover-skill-configs | |
| runs-on: ubuntu-latest | |
| if: ${{ needs.discover-skill-configs.outputs.changed-configs != '[]' }} | |
| strategy: | |
| matrix: | |
| config: ${{ fromJson(needs.discover-skill-configs.outputs.changed-configs) }} | |
| fail-fast: false | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Set up Go | |
| uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 | |
| with: | |
| go-version-file: 'go.mod' | |
| - name: Build dockhand | |
| run: go build -o /tmp/dockhand ./cmd/dockhand | |
| - name: Validate skill | |
| env: | |
| CONFIG_FILE: ${{ matrix.config }} | |
| run: | | |
| echo "Validating skill: $CONFIG_FILE" | |
| /tmp/dockhand validate-skill --config "$CONFIG_FILE" | |
| skill-security-scan: | |
| needs: discover-skill-configs | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| if: ${{ needs.discover-skill-configs.outputs.scan-configs != '[]' }} | |
| strategy: | |
| matrix: | |
| config: ${{ fromJson(needs.discover-skill-configs.outputs.scan-configs) }} | |
| fail-fast: false | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Install yq | |
| uses: mikefarah/yq@1b9b4ac5187171d2e5e3129be0cfa827c7f9d53d # v4.53.3 | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: '3.13' | |
| - name: Set up uv | |
| uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 | |
| with: | |
| enable-cache: true | |
| - name: Install dependencies | |
| id: install-deps | |
| run: | | |
| uv pip install --system -r scripts/skill-scan/requirements.txt | |
| skill-scanner --version || true | |
| SCANNER_VERSION=$(uv pip show cisco-ai-skill-scanner | grep "^Version:" | cut -d' ' -f2) | |
| echo "scanner_version=$SCANNER_VERSION" >> $GITHUB_OUTPUT | |
| echo "Installed skill-scanner version: $SCANNER_VERSION" | |
| - name: Extract metadata from config | |
| id: meta | |
| env: | |
| CONFIG_FILE: ${{ matrix.config }} | |
| run: | | |
| skill_name=$(echo "$CONFIG_FILE" | cut -d'/' -f2) | |
| echo "skill_name=$skill_name" >> $GITHUB_OUTPUT | |
| echo "config_file=$CONFIG_FILE" >> $GITHUB_OUTPUT | |
| repository=$(yq '.spec.repository' "$CONFIG_FILE") | |
| ref=$(yq '.spec.ref' "$CONFIG_FILE") | |
| skill_path=$(yq '.spec.path // ""' "$CONFIG_FILE") | |
| echo "repository=$repository" >> $GITHUB_OUTPUT | |
| echo "ref=$ref" >> $GITHUB_OUTPUT | |
| echo "skill_path=$skill_path" >> $GITHUB_OUTPUT | |
| - name: Check out skill source | |
| id: skill-src | |
| env: | |
| SKILL_REPO: ${{ steps.meta.outputs.repository }} | |
| SKILL_REF: ${{ steps.meta.outputs.ref }} | |
| SKILL_PATH: ${{ steps.meta.outputs.skill_path }} | |
| SKILL_NAME: ${{ steps.meta.outputs.skill_name }} | |
| run: | | |
| workdir="/tmp/skill-scan-${SKILL_NAME}" | |
| rm -rf "$workdir" | |
| mkdir -p "$workdir" | |
| git clone --filter=tree:0 --no-checkout --quiet "$SKILL_REPO" "$workdir/repo" | |
| git -C "$workdir/repo" checkout --quiet "$SKILL_REF" | |
| if [ -n "$SKILL_PATH" ]; then | |
| src_dir="$workdir/repo/$SKILL_PATH" | |
| else | |
| src_dir="$workdir/repo" | |
| fi | |
| if [ ! -d "$src_dir" ]; then | |
| echo "Error: skill source directory not found at $src_dir" >&2 | |
| exit 1 | |
| fi | |
| echo "source_dir=$src_dir" >> $GITHUB_OUTPUT | |
| - name: Run skill security scan | |
| id: scan | |
| env: | |
| # LLM analyzer (LiteLLM-driven). Reuses the same provider key as the | |
| # MCP scanner — single secret across both pipelines, single rotation. | |
| # Toggle via repo variable SKILL_SCANNER_USE_LLM=false to disable in a pinch. | |
| SKILL_SCANNER_USE_LLM: ${{ vars.SKILL_SCANNER_USE_LLM || 'true' }} | |
| SKILL_SCANNER_LLM_API_KEY: ${{ secrets.MCP_SCANNER_LLM_API_KEY }} | |
| # Dedicated model knob — set to e.g. anthropic/claude-sonnet-4. If unset, | |
| # skill-scanner falls back to its built-in default (claude-3-5-sonnet-...). | |
| SKILL_SCANNER_LLM_MODEL: ${{ vars.SKILL_SCANNER_LLM_MODEL }} | |
| # Optional consensus voting — N>1 multiplies LLM cost per scan. | |
| SKILL_SCANNER_LLM_CONSENSUS_RUNS: ${{ vars.SKILL_SCANNER_LLM_CONSENSUS_RUNS }} | |
| # Severity threshold for blocking. Findings below this severity are | |
| # surfaced as warnings but do not fail the job. One of: INFO, LOW, | |
| # MEDIUM, HIGH, CRITICAL. Tune via PR. | |
| SKILL_SCANNER_BLOCK_SEVERITY: HIGH | |
| SKILL_NAME: ${{ steps.meta.outputs.skill_name }} | |
| SOURCE_DIR: ${{ steps.skill-src.outputs.source_dir }} | |
| CONFIG_FILE: ${{ matrix.config }} | |
| SCANNER_VERSION: ${{ steps.install-deps.outputs.scanner_version }} | |
| run: | | |
| scan_output="skill-scan-${SKILL_NAME}.json" | |
| scan_stderr="skill-scan-${SKILL_NAME}.stderr" | |
| python3 scripts/skill-scan/run_scan.py \ | |
| --source "$SOURCE_DIR" \ | |
| --output "$scan_output" \ | |
| 2> "$scan_stderr" || true | |
| if [ -s "$scan_stderr" ]; then | |
| echo "Scanner stderr output:" | |
| cat "$scan_stderr" | |
| fi | |
| # process_scan_results.py exits 1 on unallowlisted findings — that fails the job. | |
| python3 scripts/skill-scan/process_scan_results.py \ | |
| "$scan_output" \ | |
| "$SKILL_NAME" \ | |
| "$CONFIG_FILE" \ | |
| > scan-summary.json | |
| echo "$SCANNER_VERSION" > scanner-version.txt | |
| - name: Upload scan results | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: skill-scan-${{ steps.meta.outputs.skill_name }} | |
| path: | | |
| skill-scan-${{ steps.meta.outputs.skill_name }}.json | |
| scan-summary.json | |
| scanner-version.txt | |
| retention-days: 30 | |
| build-skill-artifacts: | |
| needs: [discover-skill-configs, validate-skills, skill-security-scan] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| # Aggregate scan result is intentionally not gated here. A failure in one | |
| # skill's scan must not block publishing the rest. The per-cell | |
| # `Pre-flight scan gate` step below preserves the security gate at the | |
| # individual skill level by reading scan-summary.json from the artifact | |
| # produced by skill-security-scan (uploaded with `if: always()`). | |
| # | |
| # On smoke runs (workflow/scan-script-only changes) this builds the | |
| # representative skill on PRs to exercise the build path (dockhand | |
| # build-skill) but never pushes — the push/sign/SBOM/attest steps are | |
| # already gated on `github.event_name != 'pull_request'`. On pushes to | |
| # main, smoke skips the build entirely so a workflow edit does not | |
| # republish, sign, or attest a sample artifact. | |
| if: ${{ !cancelled() && needs.discover-skill-configs.outputs.changed-configs != '[]' && needs.validate-skills.result == 'success' && (needs.discover-skill-configs.outputs.smoke != 'true' || github.event_name == 'pull_request') }} | |
| strategy: | |
| matrix: | |
| config: ${{ fromJson(needs.discover-skill-configs.outputs.changed-configs) }} | |
| fail-fast: false | |
| # Cap concurrency so a full rebuild (toolhive bump) doesn't fan out to | |
| # ~160 cells that each push a manifest plus signatures and attestations | |
| # at once — that bursts past GHCR's manifest write limit (2000/minute) | |
| # and fails signing/attestation. Only bites when there are more cells | |
| # than this; single-spec changes are unaffected. | |
| max-parallel: 20 | |
| permissions: | |
| contents: read | |
| packages: write | |
| id-token: write # Needed for OIDC token (cosign keyless signing, attestations) | |
| attestations: write # Needed for actions/attest-* to publish attestations | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Set up Go | |
| uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 | |
| with: | |
| go-version-file: 'go.mod' | |
| - name: Install Cosign | |
| if: github.event_name != 'pull_request' | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| - name: Install yq | |
| uses: mikefarah/yq@1b9b4ac5187171d2e5e3129be0cfa827c7f9d53d # v4.53.3 | |
| - name: Log in to Container Registry | |
| if: github.event_name != 'pull_request' | |
| uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Extract metadata from config | |
| id: meta | |
| env: | |
| CONFIG_FILE: ${{ matrix.config }} | |
| run: | | |
| echo "config_file=$CONFIG_FILE" >> $GITHUB_OUTPUT | |
| skill_name=$(echo "$CONFIG_FILE" | cut -d'/' -f2) | |
| echo "skill_name=$skill_name" >> $GITHUB_OUTPUT | |
| version=$(yq '.spec.version' "$CONFIG_FILE" 2>/dev/null || echo "latest") | |
| echo "version=$version" >> $GITHUB_OUTPUT | |
| ref=$(yq '.spec.ref' "$CONFIG_FILE" 2>/dev/null || echo "") | |
| echo "ref=$ref" >> $GITHUB_OUTPUT | |
| repository=$(yq '.spec.repository' "$CONFIG_FILE" 2>/dev/null || echo "") | |
| echo "repository=$repository" >> $GITHUB_OUTPUT | |
| skill_path=$(yq '.spec.path // ""' "$CONFIG_FILE" 2>/dev/null || echo "") | |
| echo "skill_path=$skill_path" >> $GITHUB_OUTPUT | |
| image_name="${REGISTRY}/${{ github.repository }}/skills/${skill_name}" | |
| echo "image_name=$image_name" >> $GITHUB_OUTPUT | |
| # Pull the per-skill security scan artifact early, before we spend time | |
| # building. `continue-on-error: true` so this step succeeds when the | |
| # artifact does not exist (i.e. this skill was not in scan-configs for | |
| # this run); the next step decides whether that is acceptable. | |
| - name: Download skill security scan results | |
| id: download-scan | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: skill-scan-${{ steps.meta.outputs.skill_name }} | |
| path: /tmp/skill-scan-results | |
| continue-on-error: true | |
| # Per-cell scan gate. Replaces the old aggregate | |
| # `needs.skill-security-scan.result == 'success'` check at the job | |
| # level so that one skill's scan failure cannot starve out the rest. | |
| # - If this skill was in scan-configs for this run, require a fresh | |
| # scan-summary.json with status in {passed, warning}. Anything else | |
| # fails the cell, preserving the security gate per skill. | |
| # - If this skill was NOT in scan-configs (e.g. go.mod-only push that | |
| # rebuilds everything but only re-scans changed specs), proceed on | |
| # prior trust: the previous run on main must have been clean for the | |
| # pinned ref to land here. | |
| - name: Pre-flight scan gate | |
| env: | |
| CONFIG: ${{ matrix.config }} | |
| SCAN_CONFIGS: ${{ needs.discover-skill-configs.outputs.scan-configs }} | |
| SKILL_NAME: ${{ steps.meta.outputs.skill_name }} | |
| run: | | |
| set -euo pipefail | |
| summary=/tmp/skill-scan-results/scan-summary.json | |
| expected=$(jq -r --arg c "$CONFIG" 'index($c) // "missing"' <<<"$SCAN_CONFIGS") | |
| if [ ! -f "$summary" ]; then | |
| if [ "$expected" = "missing" ]; then | |
| echo "No scan ran for ${SKILL_NAME} in this workflow (not in scan-configs); proceeding on prior trust." | |
| exit 0 | |
| fi | |
| echo "::error title=Scan artifact missing::scan-summary.json absent for ${SKILL_NAME} but the skill was in scan-configs; refusing to publish." | |
| exit 1 | |
| fi | |
| status=$(jq -r '.status // "missing"' "$summary") | |
| case "$status" in | |
| passed|warning) | |
| echo "Skill scan status for ${SKILL_NAME}: ${status} - publish allowed." | |
| ;; | |
| *) | |
| echo "::error title=Scan blocked publish::Skill ${SKILL_NAME} scan status is '${status}'; refusing to publish." | |
| jq -r '.blocking_issues[]? | " - [\(.code)] (\(.severity)) \(.message)"' "$summary" 2>/dev/null || true | |
| exit 1 | |
| ;; | |
| esac | |
| - name: Build dockhand | |
| run: go build -o /tmp/dockhand ./cmd/dockhand | |
| - name: Build skill artifact | |
| id: build | |
| env: | |
| CONFIG_FILE: ${{ steps.meta.outputs.config_file }} | |
| IMAGE_REF: ${{ steps.meta.outputs.image_name }}:${{ steps.meta.outputs.version }} | |
| PUSH: ${{ github.event_name != 'pull_request' }} | |
| run: | | |
| # pipefail so a dockhand failure propagates through `tee` and fails | |
| # the step. Without it, `set -e` alone would not fail on a non-zero | |
| # left-hand side of the pipeline. | |
| set -o pipefail | |
| echo "Building skill artifact for $CONFIG_FILE" | |
| build_args="--config $CONFIG_FILE --tag $IMAGE_REF" | |
| if [ "$PUSH" = "true" ]; then | |
| build_args="$build_args --push" | |
| fi | |
| log_file=$(mktemp) | |
| # Retry dockhand on non-zero exit. Matrix jobs concurrently pushing | |
| # brand-new GHCR packages regularly hit transient 5xx from the | |
| # registry (observed "500 unknown: unknown error" on manifest PUT). | |
| # A linear backoff is enough — a successful retry typically lands | |
| # within the first extra attempt. | |
| max_attempts=3 | |
| attempt=1 | |
| while :; do | |
| echo "=== dockhand build-skill attempt ${attempt}/${max_attempts} ===" | |
| if /tmp/dockhand build-skill $build_args 2>&1 | tee "$log_file"; then | |
| break | |
| fi | |
| if [ "$attempt" -ge "$max_attempts" ]; then | |
| echo "dockhand build-skill failed after ${attempt} attempt(s)" >&2 | |
| exit 1 | |
| fi | |
| backoff=$(( 10 * attempt )) | |
| echo "dockhand build-skill failed; retrying in ${backoff}s..." >&2 | |
| sleep "$backoff" | |
| attempt=$(( attempt + 1 )) | |
| done | |
| # Extract digest from captured output. `|| true` so an absent | |
| # "Digest:" line yields digest="" without failing the step; downstream | |
| # steps already gate on `steps.build.outputs.digest != ''`. | |
| digest=$(grep "^Digest:" "$log_file" | awk '{print $2}' || true) | |
| echo "digest=$digest" >> $GITHUB_OUTPUT | |
| rm -f "$log_file" | |
| - name: Sign skill artifact with Cosign | |
| if: github.event_name != 'pull_request' | |
| env: | |
| DIGEST: ${{ steps.build.outputs.digest }} | |
| IMAGE_NAME: ${{ steps.meta.outputs.image_name }} | |
| VERSION: ${{ steps.meta.outputs.version }} | |
| run: | | |
| if [ -z "$DIGEST" ]; then | |
| echo "No digest available, skipping signing" | |
| exit 0 | |
| fi | |
| # Retry cosign on non-zero exit. During a full rebuild the concurrent | |
| # matrix cells push signatures fast enough to trip GHCR's manifest | |
| # rate limit (TOOMANYREQUESTS); the retry-after is tiny, so a short | |
| # linear backoff clears it. cosign does not retry these itself. | |
| retry() { | |
| local max_attempts=5 attempt=1 backoff | |
| while :; do | |
| if "$@"; then | |
| return 0 | |
| fi | |
| if [ "$attempt" -ge "$max_attempts" ]; then | |
| echo "cosign failed after ${attempt} attempt(s): $*" >&2 | |
| return 1 | |
| fi | |
| backoff=$(( 5 * attempt )) | |
| echo "cosign failed; retrying in ${backoff}s: $*" >&2 | |
| sleep "$backoff" | |
| attempt=$(( attempt + 1 )) | |
| done | |
| } | |
| echo "Signing skill artifact ${IMAGE_NAME}@${DIGEST}" | |
| retry cosign sign --yes "${IMAGE_NAME}@${DIGEST}" | |
| retry cosign sign --yes "${IMAGE_NAME}:${VERSION}" | |
| echo "Signed: ${IMAGE_NAME}@${DIGEST} and ${IMAGE_NAME}:${VERSION}" >> $GITHUB_STEP_SUMMARY | |
| - name: Check out skill source for SBOM | |
| if: github.event_name != 'pull_request' && steps.build.outputs.digest != '' | |
| id: skill-src | |
| env: | |
| SKILL_REPO: ${{ steps.meta.outputs.repository }} | |
| SKILL_REF: ${{ steps.meta.outputs.ref }} | |
| SKILL_PATH: ${{ steps.meta.outputs.skill_path }} | |
| run: | | |
| rm -rf /tmp/skill-src | |
| mkdir -p /tmp/skill-src | |
| git clone --filter=tree:0 --no-checkout "$SKILL_REPO" /tmp/skill-src/repo | |
| git -C /tmp/skill-src/repo checkout "$SKILL_REF" | |
| if [ -n "$SKILL_PATH" ]; then | |
| src_dir="/tmp/skill-src/repo/$SKILL_PATH" | |
| else | |
| src_dir="/tmp/skill-src/repo" | |
| fi | |
| echo "source_dir=$src_dir" >> $GITHUB_OUTPUT | |
| - name: Generate SBOM from skill source | |
| if: github.event_name != 'pull_request' && steps.build.outputs.digest != '' | |
| uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 | |
| with: | |
| path: ${{ steps.skill-src.outputs.source_dir }} | |
| format: spdx-json | |
| output-file: /tmp/skill-sbom.spdx.json | |
| upload-artifact: false | |
| upload-release-assets: false | |
| - name: Attest SBOM for skill artifact | |
| if: github.event_name != 'pull_request' && steps.build.outputs.digest != '' | |
| uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 | |
| with: | |
| subject-name: ${{ steps.meta.outputs.image_name }} | |
| subject-digest: ${{ steps.build.outputs.digest }} | |
| sbom-path: /tmp/skill-sbom.spdx.json | |
| push-to-registry: true | |
| - name: Attest build provenance for skill artifact | |
| if: github.event_name != 'pull_request' && steps.build.outputs.digest != '' | |
| uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 | |
| with: | |
| subject-name: ${{ steps.meta.outputs.image_name }} | |
| subject-digest: ${{ steps.build.outputs.digest }} | |
| push-to-registry: true | |
| - name: Create security scan attestation (SCAI format) | |
| if: github.event_name != 'pull_request' && steps.build.outputs.digest != '' | |
| env: | |
| DIGEST: ${{ steps.build.outputs.digest }} | |
| IMAGE_NAME: ${{ steps.meta.outputs.image_name }} | |
| CONFIG_FILE: ${{ matrix.config }} | |
| COMMIT_SHA: ${{ github.sha }} | |
| RUN_ID: ${{ github.run_id }} | |
| SERVER_URL: ${{ github.server_url }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| # Prior-trust path: this skill was not in scan-configs for this run | |
| # (e.g. go.mod-only push that rebuilds everything but only re-scans | |
| # changed specs). The previous SCAI attestation on the registry | |
| # remains authoritative; skipping here is safe and intentional. | |
| if [ ! -f /tmp/skill-scan-results/scan-summary.json ]; then | |
| echo "No fresh scan summary for this run; skipping SCAI attestation (skill not in this run's scan-configs)." | |
| exit 0 | |
| fi | |
| SCANNER_VERSION="" | |
| if [ -f /tmp/skill-scan-results/scanner-version.txt ]; then | |
| SCANNER_VERSION=$(tr -d '\n' < /tmp/skill-scan-results/scanner-version.txt) | |
| echo "Scanner version: $SCANNER_VERSION" | |
| fi | |
| python3 scripts/skill-scan/generate_scai_attestation.py \ | |
| /tmp/skill-scan-results/scan-summary.json \ | |
| "$IMAGE_NAME" \ | |
| "$DIGEST" \ | |
| --config-file "$CONFIG_FILE" \ | |
| --commit-sha "$COMMIT_SHA" \ | |
| --run-id "$RUN_ID" \ | |
| --run-url "${SERVER_URL}/${REPO}/actions/runs/${RUN_ID}" \ | |
| --producer-uri "${SERVER_URL}/${REPO}" \ | |
| --scanner-version "$SCANNER_VERSION" \ | |
| --scanner-uri "https://github.com/cisco-ai-defense/skill-scanner" \ | |
| --validate \ | |
| --output /tmp/skill-scai.json | |
| echo "Generated SCAI attestation:" | |
| cat /tmp/skill-scai.json | |
| # Retry cosign attest against GHCR rate limiting (see the signing | |
| # step). cosign does not retry TOOMANYREQUESTS itself. | |
| retry() { | |
| local max_attempts=5 attempt=1 backoff | |
| while :; do | |
| if "$@"; then | |
| return 0 | |
| fi | |
| if [ "$attempt" -ge "$max_attempts" ]; then | |
| echo "cosign attest failed after ${attempt} attempt(s)" >&2 | |
| return 1 | |
| fi | |
| backoff=$(( 5 * attempt )) | |
| echo "cosign attest failed; retrying in ${backoff}s..." >&2 | |
| sleep "$backoff" | |
| attempt=$(( attempt + 1 )) | |
| done | |
| } | |
| retry cosign attest --yes \ | |
| --predicate /tmp/skill-scai.json \ | |
| --type https://in-toto.io/attestation/scai/v0.3 \ | |
| "${IMAGE_NAME}@${DIGEST}" | |
| ATTRIBUTE=$(python3 -c "import json,sys;print(json.load(open('/tmp/skill-scai.json'))['predicate']['attributes'][0]['attribute'])") | |
| echo "SCAI security scan attestation created (${ATTRIBUTE})" >> $GITHUB_STEP_SUMMARY | |
| rm -f /tmp/skill-scai.json | |
| - name: Build summary | |
| env: | |
| SKILL_NAME: ${{ steps.meta.outputs.skill_name }} | |
| VERSION: ${{ steps.meta.outputs.version }} | |
| IMAGE_NAME: ${{ steps.meta.outputs.image_name }} | |
| DIGEST: ${{ steps.build.outputs.digest }} | |
| run: | | |
| echo "## Skill Build: ${SKILL_NAME}" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY | |
| echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY | |
| echo "| Skill | ${SKILL_NAME} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Version | ${VERSION} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Image | \`${IMAGE_NAME}:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY | |
| if [ -n "$DIGEST" ]; then | |
| echo "| Digest | \`${DIGEST}\` |" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| echo "| Status | ${{ github.event_name != 'pull_request' && 'Published' || 'Built (dry run)' }} |" >> $GITHUB_STEP_SUMMARY | |
| if [ "${{ github.event_name }}" != "pull_request" ] && [ -n "$DIGEST" ]; then | |
| echo "| SBOM | Attested (SPDX) |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Provenance | Attested (SLSA) |" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| save-pr-number: | |
| runs-on: ubuntu-latest | |
| permissions: {} | |
| if: github.event_name == 'pull_request' | |
| steps: | |
| - name: Save PR number | |
| env: | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: echo "$PR_NUMBER" > pr-number.txt | |
| - name: Upload PR number | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: skill-pr-number | |
| path: pr-number.txt | |
| retention-days: 5 | |
| summary: | |
| needs: [discover-skill-configs, validate-skills, skill-security-scan, build-skill-artifacts] | |
| runs-on: ubuntu-latest | |
| if: always() | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Build summary | |
| env: | |
| CONFIGS: ${{ needs.discover-skill-configs.outputs.changed-configs }} | |
| SCAN_CONFIGS: ${{ needs.discover-skill-configs.outputs.scan-configs }} | |
| VALIDATE_RESULT: ${{ needs.validate-skills.result }} | |
| SCAN_RESULT: ${{ needs.skill-security-scan.result }} | |
| BUILD_RESULT: ${{ needs.build-skill-artifacts.result }} | |
| run: | | |
| echo "## Skill Build Pipeline Summary" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "| Stage | Result |" >> $GITHUB_STEP_SUMMARY | |
| echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY | |
| echo "| Trigger | ${{ github.event_name }} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Skills to build | $(echo "$CONFIGS" | jq '. | length') |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Skills to scan | $(echo "$SCAN_CONFIGS" | jq '. | length') |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Validation | ${VALIDATE_RESULT} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Security scan | ${SCAN_RESULT} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Build | ${BUILD_RESULT} |" >> $GITHUB_STEP_SUMMARY |