Skip to content

chore(deps): update github-actions (#813) #696

chore(deps): update github-actions (#813)

chore(deps): update github-actions (#813) #696

Workflow file for this run

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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
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