Skip to content

chore: bump version to 0.4.1 #4

chore: bump version to 0.4.1

chore: bump version to 0.4.1 #4

Workflow file for this run

---
name: Release
# Attested delivery for this plugin/marketplace repo: reproducible tarball,
# SLSA build provenance, CycloneDX SBOM, six quality-gate verdicts
# (SAST/SCA/Trivy/Semgrep/Secrets/Manifest) each seam-attested as a signed
# custom predicate bound to the tarball's digest, an OpenVEX vulnerability
# disposition, and a cosign-signed marketplace.json catalog — all fail-closed
# verified before the tag-gated publish step.
#
# Unlike a multi-repo org, this repo has no separate central signer: every
# attestation and the catalog signature are produced by THIS workflow running
# in THIS repo. Verification still pins the signer workflow explicitly
# (`--signer-workflow zircote/cdc-error-plugin/.github/workflows/release.yml`
# for gh attestation verify; the cosign certificate-identity-regexp is
# anchored to this same workflow path) — `--repo` alone only proves "some
# workflow in this repo signed it," not specifically this one. See
# SECURITY.md and docs/how-to/verify-release.md.
#
# Flow: build tarball -> attest-build-provenance
# -> [gate-* -> attest-*] x6 (parallel), sbom (generate + attest),
# sign-catalog (cosign), vex (normalize + attest)
# -> verify (fail-closed) -> publish (tag push only).
#
# Dry-run: workflow_dispatch exercises the full chain through verify; publish
# only runs on an actual tag push.
"on":
push:
tags:
- "v*.*.*"
workflow_dispatch:
permissions:
contents: read
jobs:
meta:
name: Resolve Metadata
runs-on: ubuntu-latest
outputs:
name: ${{ steps.meta.outputs.name }}
version: ${{ steps.meta.outputs.version }}
steps:
- name: Resolve artifact name and version
id: meta
run: |
set -euo pipefail
NAME="${GITHUB_REPOSITORY##*/}"
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/v}"
else
VERSION="0.0.0-dev"
fi
echo "name=${NAME}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved: ${NAME} ${VERSION}"
build:
name: Build Artifact
needs: [meta]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
attestations: write
outputs:
subject-name: ${{ steps.subject.outputs.name }}
subject-digest: ${{ steps.subject.outputs.digest }}
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Build reproducible tarball
env:
NAME: ${{ needs.meta.outputs.name }}
VERSION: ${{ needs.meta.outputs.version }}
run: |
set -euo pipefail
mkdir -p dist
git archive --format=tar.gz --prefix="${NAME}-${VERSION}/" \
-o "dist/${NAME}-${VERSION}.tar.gz" "${GITHUB_SHA}"
sha256sum dist/*
- name: Record subject name + digest (for gate attestations)
id: subject
env:
NAME: ${{ needs.meta.outputs.name }}
VERSION: ${{ needs.meta.outputs.version }}
run: |
set -euo pipefail
FILE="${NAME}-${VERSION}.tar.gz"
DIGEST=$(sha256sum "dist/${FILE}" | cut -d' ' -f1)
echo "name=${FILE}" >> "$GITHUB_OUTPUT"
echo "digest=sha256:${DIGEST}" >> "$GITHUB_OUTPUT"
- name: Attest build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: dist/*
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-dist
path: dist/*
if-no-files-found: error
# --- quality gates: each produces a SARIF/JSON evidence file -----------
gate-sast:
name: gate-sast
needs: [meta]
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: actions
build-mode: none
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: /language:actions
output: codeql-sarif
- name: Normalize SARIF filename
run: |
set -euo pipefail
shopt -s nullglob
files=(codeql-sarif/*.sarif)
if [ "${#files[@]}" -eq 0 ]; then echo "::error::CodeQL produced no SARIF"; exit 1; fi
jq -s '{"$schema": .[0]."$schema", "version": .[0].version, "runs": (map(.runs) | add)}' \
"${files[@]}" > results.sarif
# No separate "upload to code scanning" step here: codeql-action/analyze
# already uploads to the Security tab itself (default upload: always).
# A second upload-sarif call with the same category fails with
# "only one run of the codeql/analyze or codeql/upload-sarif actions is
# allowed per job per tool/category" — this is the one gate whose tool
# self-uploads; every other gate below needs an explicit upload step
# because its scanner doesn't.
- name: Upload SARIF artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sast-sarif
path: results.sarif
if-no-files-found: error
gate-sca:
name: gate-sca
needs: [meta]
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run OSV-Scanner
uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
continue-on-error: true
with:
scan-args: |-
--output=results.json
--format=json
--recursive
./
- name: Generate SARIF report
uses: google/osv-scanner-action/osv-reporter-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
scan-args: |-
--output=results.sarif
--new=results.json
--gh-annotations=false
--fail-on-vuln=true
- name: Upload to code scanning
if: always()
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: results.sarif
category: osv-scanner
- name: Upload SARIF artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sca-sarif
path: results.sarif
if-no-files-found: error
gate-trivy:
name: gate-trivy
needs: [meta]
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# SECURITY: aquasecurity/trivy-action is pinned to a full commit SHA.
# In March 2026 (CVE-2026-33634) an attacker force-pushed 76 of 77
# trivy-action tags to credential-stealing malware. Never replace this
# SHA with a tag.
- name: Trivy IaC + license scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: misconfig,license
format: sarif
output: results.sarif
severity: HIGH,CRITICAL
exit-code: '0'
- name: Upload to code scanning
if: always()
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: results.sarif
category: trivy-iac-license
- name: Upload SARIF artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: trivy-sarif
path: results.sarif
if-no-files-found: error
gate-semgrep:
name: gate-semgrep
needs: [meta]
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Semgrep (pinned)
run: |
set -euo pipefail
python3 -m venv "${RUNNER_TEMP}/semgrep-venv"
"${RUNNER_TEMP}/semgrep-venv/bin/pip" install --no-cache-dir --disable-pip-version-check \
"semgrep==1.168.0"
echo "${RUNNER_TEMP}/semgrep-venv/bin" >> "$GITHUB_PATH"
- name: Semgrep -> SARIF
run: |
set -euo pipefail
semgrep scan --config p/security-audit --config p/secrets --config p/command-injection \
--sarif --output results.sarif \
--metrics off --disable-version-check --no-error . || true
if [ ! -f results.sarif ]; then echo "::error::Semgrep produced no SARIF"; exit 1; fi
- name: Upload to code scanning
if: always()
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: results.sarif
category: semgrep
- name: Upload SARIF artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: semgrep-sarif
path: results.sarif
if-no-files-found: error
gate-secrets:
name: gate-secrets
needs: [meta]
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Gitleaks + TruffleHog (checksum-verified)
run: |
set -euo pipefail
dest="${RUNNER_TEMP}/secrets-bin"; mkdir -p "${dest}"
dl() { curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 -o "$1" "$2"; }
GL_VER=8.30.1
glbase="https://github.com/gitleaks/gitleaks/releases/download/v${GL_VER}"
glzip="gitleaks_${GL_VER}_linux_x64.tar.gz"
dl "${dest}/${glzip}" "${glbase}/${glzip}"
dl "${dest}/gl.sums" "${glbase}/gitleaks_${GL_VER}_checksums.txt"
( cd "${dest}" && grep " ${glzip}\$" gl.sums | sha256sum --check --strict - )
tar -xzf "${dest}/${glzip}" -C "${dest}" gitleaks
TH_VER=3.95.8
thbase="https://github.com/trufflesecurity/trufflehog/releases/download/v${TH_VER}"
thzip="trufflehog_${TH_VER}_linux_amd64.tar.gz"
dl "${dest}/${thzip}" "${thbase}/${thzip}"
dl "${dest}/th.sums" "${thbase}/trufflehog_${TH_VER}_checksums.txt"
( cd "${dest}" && grep " ${thzip}\$" th.sums | sha256sum --check --strict - )
tar -xzf "${dest}/${thzip}" -C "${dest}" trufflehog
chmod +x "${dest}/gitleaks" "${dest}/trufflehog"
echo "${dest}" >> "$GITHUB_PATH"
- name: Gitleaks -> SARIF
run: |
set -euo pipefail
gitleaks dir . --report-format sarif --report-path results.sarif --exit-code 0 --no-banner
if [ ! -f results.sarif ]; then
echo "::error::Gitleaks produced no SARIF (--exit-code 0 always exits 0 on findings, so a missing file means the scan itself failed to run, not that it found nothing)"
exit 1
fi
- name: TruffleHog (verified-only, hard-fail)
run: |
set -euo pipefail
trufflehog filesystem . --results=verified --json --no-update > trufflehog.json
n="$(grep -c . trufflehog.json || true)"
echo "TruffleHog verified findings: ${n:-0}"
if [ "${n:-0}" -gt 0 ]; then
echo "::error::TruffleHog confirmed ${n} VERIFIED live secret(s), failing closed."
exit 1
fi
- name: Upload to code scanning
if: always()
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: results.sarif
category: gitleaks
- name: Upload SARIF artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: secrets-sarif
path: results.sarif
if-no-files-found: error
gate-manifest:
name: gate-manifest
needs: [meta]
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Review manifests -> SARIF
run: |
set -euo pipefail
python3 - . > results.sarif <<'PY'
import json, os, re, sys
root = sys.argv[1]
SHA = re.compile(r'^[0-9a-f]{40}$')
RESERVED = {
"claude-code-marketplace", "claude-code-plugins", "claude-plugins-official",
"claude-plugins-community", "claude-community", "anthropic-marketplace",
"anthropic-plugins", "agent-skills", "anthropic-agent-skills",
"knowledge-work-plugins", "life-sciences", "claude-for-legal",
"claude-for-financial-services", "financial-services-plugins",
}
results = []
def add(rule, level, text, path, line=1):
results.append({"ruleId": rule, "level": level,
"message": {"text": text},
"locations": [{"physicalLocation": {
"artifactLocation": {"uri": path},
"region": {"startLine": line}}}]})
def rel(p):
return os.path.relpath(p, root).replace(os.sep, "/")
for dirpath, _dirs, files in os.walk(root):
if ".git" in dirpath.split(os.sep):
continue
for fn in files:
full = os.path.join(dirpath, fn)
if fn == "marketplace.json" and ".claude-plugin" in dirpath:
try:
with open(full, encoding="utf-8") as fh:
mp = json.load(fh)
except Exception as e:
add("MR001", "error", "marketplace.json is not valid JSON: %s" % e, rel(full)); continue
if not mp.get("name"):
add("MR002", "error", "marketplace.json missing required 'name'", rel(full))
elif mp["name"] in RESERVED:
add("MR003", "error", "marketplace name '%s' is Anthropic-reserved" % mp["name"], rel(full))
if not (mp.get("owner") or {}).get("name"):
add("MR004", "error", "marketplace.json missing required 'owner.name'", rel(full))
for i, p in enumerate(mp.get("plugins", [])):
src = p.get("source")
name = p.get("name", "plugins[%d]" % i)
if not name or not src:
add("MR005", "error", "plugin entry '%s' missing required name/source" % name, rel(full))
if isinstance(src, dict) and src.get("source") in ("github", "url", "git-subdir"):
sha = src.get("sha", "")
if not SHA.match(str(sha)):
add("MR006", "error",
"external plugin '%s' source is not pinned to a 40-char sha (got %r)" % (name, sha),
rel(full))
elif fn == "plugin.json" and ".claude-plugin" in dirpath:
try:
with open(full, encoding="utf-8") as fh:
pl = json.load(fh)
except Exception as e:
add("MR007", "error", "plugin.json is not valid JSON: %s" % e, rel(full)); continue
for req in ("name", "description"):
if not pl.get(req):
add("MR008", "error", "plugin.json missing required '%s'" % req, rel(full))
if not (pl.get("author") or {}).get("name"):
add("MR009", "error", "plugin.json missing required 'author.name'", rel(full))
sarif = {"version": "2.1.0",
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"runs": [{"tool": {"driver": {"name": "manifest-review",
"rules": [{"id": r} for r in sorted({x["ruleId"] for x in results})]}},
"results": results}]}
print(json.dumps(sarif))
PY
- name: Upload to code scanning
if: always()
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: results.sarif
category: manifest-review
- name: Upload SARIF artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: manifest-sarif
path: results.sarif
if-no-files-found: error
# --- seam: bind each gate's evidence to the tarball digest as a signed
# custom-predicate attestation. This repo is both the artifact repo AND
# the signer (no separate central org), so verification only needs
# --repo, not --signer-workflow. ---------------------------------------
attest-sast:
name: attest-sast
needs: [build, gate-sast]
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
steps:
- name: Download evidence
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: sast-sarif
path: predicate
- name: Attest
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ needs.build.outputs.subject-name }}
subject-digest: ${{ needs.build.outputs.subject-digest }}
predicate-type: https://zircote.github.io/attestations/sast/v1
predicate-path: predicate/results.sarif
attest-sca:
name: attest-sca
needs: [build, gate-sca]
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
steps:
- name: Download evidence
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: sca-sarif
path: predicate
- name: Attest
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ needs.build.outputs.subject-name }}
subject-digest: ${{ needs.build.outputs.subject-digest }}
predicate-type: https://zircote.github.io/attestations/sca/v1
predicate-path: predicate/results.sarif
attest-trivy:
name: attest-trivy
needs: [build, gate-trivy]
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
steps:
- name: Download evidence
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: trivy-sarif
path: predicate
- name: Attest
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ needs.build.outputs.subject-name }}
subject-digest: ${{ needs.build.outputs.subject-digest }}
predicate-type: https://zircote.github.io/attestations/iac-license/v1
predicate-path: predicate/results.sarif
attest-semgrep:
name: attest-semgrep
needs: [build, gate-semgrep]
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
steps:
- name: Download evidence
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: semgrep-sarif
path: predicate
- name: Attest
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ needs.build.outputs.subject-name }}
subject-digest: ${{ needs.build.outputs.subject-digest }}
predicate-type: https://zircote.github.io/attestations/semgrep/v1
predicate-path: predicate/results.sarif
attest-secrets:
name: attest-secrets
needs: [build, gate-secrets]
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
steps:
- name: Download evidence
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: secrets-sarif
path: predicate
- name: Attest
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ needs.build.outputs.subject-name }}
subject-digest: ${{ needs.build.outputs.subject-digest }}
predicate-type: https://zircote.github.io/attestations/secrets/v1
predicate-path: predicate/results.sarif
attest-manifest:
name: attest-manifest
needs: [build, gate-manifest]
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
steps:
- name: Download evidence
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: manifest-sarif
path: predicate
- name: Attest
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ needs.build.outputs.subject-name }}
subject-digest: ${{ needs.build.outputs.subject-digest }}
predicate-type: https://zircote.github.io/attestations/manifest/v1
predicate-path: predicate/results.sarif
# --- SBOM -------------------------------------------------------------
sbom:
name: SBOM (generate + attest)
needs: [meta, build]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-dist
path: dist
- name: Generate CycloneDX SBOM
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
path: .
format: cyclonedx-json
output-file: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-sbom.cdx.json
upload-artifact: false
upload-release-assets: false
- name: Attest SBOM
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
with:
subject-path: dist/*
sbom-path: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-sbom.cdx.json
- name: Upload SBOM artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-sbom
path: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-sbom.cdx.json
if-no-files-found: error
# --- cosign keyless-sign the marketplace.json catalog blob -------------
sign-catalog:
name: sign-catalog
needs: [meta]
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
outputs:
bundle: ${{ steps.sign.outputs.bundle }}
identity: ${{ steps.sign.outputs.identity }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign blob (keyless) + verify back (fail-closed)
id: sign
env:
# This repo's own workflow is the signer (no separate central seam),
# so the Fulcio cert SAN is this release.yml. Deliberately NOT
# anchored to a specific @ref (e.g. refs/tags/*): this job has no
# tag-only `if:` gate, so it also runs (and self-verifies) on a
# workflow_dispatch dry-run from a branch. Anchoring to
# refs/tags/ here would make the dry-run's own in-run verify-blob
# check fail every time. The residual risk (a signature from a
# non-tag ref passing this same regex) requires push access to
# this repo to exploit, which is already the trust boundary for a
# solo-maintained repo with no other collaborators.
SIGNER_IDENTITY_RE: '^https://github\.com/zircote/cdc-error-plugin/\.github/workflows/release\.yml@'
run: |
set -euo pipefail
BLOB=.claude-plugin/marketplace.json
bundle="marketplace.json.cosign.bundle"
cosign sign-blob --yes "${BLOB}" --bundle "${bundle}"
cosign verify-blob "${BLOB}" \
--bundle "${bundle}" \
--certificate-identity-regexp "${SIGNER_IDENTITY_RE}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
echo "bundle=${bundle}" >> "$GITHUB_OUTPUT"
echo "identity=${SIGNER_IDENTITY_RE}" >> "$GITHUB_OUTPUT"
- name: Upload cosign bundle
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cosign-bundle
path: ${{ steps.sign.outputs.bundle }}
if-no-files-found: error
# --- OpenVEX disposition ----------------------------------------------
vex:
name: vex
needs: [meta, build]
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: 'stable'
- name: Install vexctl
run: go install "github.com/openvex/vexctl@v0.4.4"
- name: Normalize OpenVEX document
run: |
set -euo pipefail
vexctl merge .vex/openvex.json > openvex.normalized.json
jq -e . openvex.normalized.json > /dev/null
- name: Attest OpenVEX document
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ needs.build.outputs.subject-name }}
subject-digest: ${{ needs.build.outputs.subject-digest }}
predicate-type: https://openvex.dev/ns/v0.2.0
predicate-path: openvex.normalized.json
# --- fail-closed verification (MUST pass before publish) ----------------
verify:
name: Verify Attestations (fail-closed)
needs: [meta, build, sbom, attest-sast, attest-sca, attest-trivy,
attest-semgrep, attest-secrets, attest-manifest, sign-catalog, vex]
runs-on: ubuntu-latest
permissions:
contents: read
attestations: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-dist
path: dist
- name: Download cosign bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: cosign-bundle
path: catalog-sig
- name: Fail-closed attestation + catalog-signature verification
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
# Pin the signer workflow, not just the repo: --repo alone accepts
# an attestation from ANY workflow in this repo with
# id-token:write + attestations:write, not specifically this one.
SIGNER: ${{ github.repository }}/.github/workflows/release.yml
SUBJECT_NAME: ${{ needs.build.outputs.subject-name }}
CATALOG_SIGNER_ID: ${{ needs.sign-catalog.outputs.identity }}
run: |
set -euo pipefail
artifact="dist/${SUBJECT_NAME}"
test -f "${artifact}" || { echo "::error::expected artifact not found: ${artifact}"; exit 1; }
echo "=== Verifying: ${artifact} ==="
gh attestation verify "${artifact}" --repo "${REPO}" --signer-workflow "${SIGNER}" \
--predicate-type https://slsa.dev/provenance/v1
gh attestation verify "${artifact}" --repo "${REPO}" --signer-workflow "${SIGNER}" \
--predicate-type https://cyclonedx.org/bom
for pt in sast sca iac-license semgrep secrets manifest; do
gh attestation verify "${artifact}" --repo "${REPO}" --signer-workflow "${SIGNER}" \
--predicate-type "https://zircote.github.io/attestations/${pt}/v1"
echo " PASS: ${pt}"
done
gh attestation verify "${artifact}" --repo "${REPO}" --signer-workflow "${SIGNER}" \
--predicate-type https://openvex.dev/ns/v0.2.0
echo " PASS: VEX"
echo "=== Verifying catalog signature (cosign) ==="
cosign verify-blob .claude-plugin/marketplace.json \
--bundle catalog-sig/marketplace.json.cosign.bundle \
--certificate-identity-regexp "${CATALOG_SIGNER_ID}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
echo "All attestations + catalog signature verified."
publish:
name: Publish Release
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
needs: [meta, verify, sign-catalog]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-dist
path: dist
- name: Download SBOM artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-sbom
path: dist
- name: Download cosign bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: cosign-bundle
path: dist
- name: Generate checksums
env:
NAME: ${{ needs.meta.outputs.name }}
VERSION: ${{ needs.meta.outputs.version }}
run: |
cd dist
sha256sum -- * > "${NAME}-${VERSION}-checksums.txt"
cat "${NAME}-${VERSION}-checksums.txt"
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.meta.outputs.version }}
run: |
set -euo pipefail
PRERELEASE=()
case "${GITHUB_REF_NAME}" in *-*) PRERELEASE=(--prerelease) ;; esac
gh release create "${GITHUB_REF_NAME}" dist/* \
--repo "${GITHUB_REPOSITORY}" --title "Release ${VERSION}" \
--generate-notes "${PRERELEASE[@]}"