---
name: Release
"on":
push:
tags:
- "v*.*.*"
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
version:
name: Resolve Version
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- name: Get version from tag or Cargo.toml
id: get_version
run: |
set -euo pipefail
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
else
# Dry-run (workflow_dispatch): version from Cargo.toml, marked -dev
v=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)
echo "version=${v}-dev" >> "$GITHUB_OUTPUT"
fi
test:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- name: Setup Rust with caching
uses: ./.github/actions/setup-rust-cached
with:
toolchain: stable
cache-key: release-test
- name: Run tests
run: cargo test --all-features --locked
audit:
name: Cargo Audit
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- name: Install cargo-audit
uses: ./.github/actions/install-cargo-tool
with:
tool: cargo-audit
- name: Audit dependencies for known vulnerabilities
run: cargo audit --deny warnings --ignore RUSTSEC-2023-0071
build:
name: Build (${{ matrix.platform }})
needs: [version]
runs-on: ${{ matrix.os }}
timeout-minutes: 45
permissions:
contents: read
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_name: nsip
platform: linux-amd64
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
artifact_name: nsip
platform: linux-arm64
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: nsip
platform: macos-amd64
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: nsip
platform: macos-arm64
- os: windows-2022
target: x86_64-pc-windows-msvc
artifact_name: nsip.exe
platform: windows-amd64.exe
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- name: Setup Rust with caching
uses: ./.github/actions/setup-rust-cached
with:
toolchain: stable
targets: ${{ matrix.target }}
cache-key: release-${{ matrix.target }}
- name: Build release binary
run: >-
cargo build --release --locked
--target ${{ matrix.target }}
- name: Strip binary (Unix)
if: runner.os != 'Windows'
run: >-
strip
"target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
- name: Stage named artifact
shell: bash
env:
VERSION: ${{ needs.version.outputs.version }}
PLATFORM: ${{ matrix.platform }}
run: |
mkdir -p dist
cp "target/${{ matrix.target }}/release/${{ matrix.artifact_name }}" \
"dist/nsip-${VERSION}-${PLATFORM}"
- name: Attest build provenance
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 with:
subject-path: dist/*
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: nsip-${{ needs.version.outputs.version }}-${{ matrix.platform }}
path: dist/*
if-no-files-found: error
extras:
name: Completions & man pages
needs: [version]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- name: Setup Rust with caching
uses: ./.github/actions/setup-rust-cached
with:
toolchain: stable
cache-key: release-extras
- name: Build release binary
run: cargo build --release --locked
- name: Generate shell completions
run: |
mkdir -p completions
./target/release/nsip completions bash \
> completions/nsip.bash
./target/release/nsip completions zsh \
> completions/_nsip
./target/release/nsip completions fish \
> completions/nsip.fish
./target/release/nsip completions powershell \
> completions/_nsip.ps1
- name: Generate man pages
run: |
mkdir -p man
./target/release/nsip man-pages --out-dir man
- name: Stage versioned archives
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
mkdir -p dist
tar czf "dist/nsip-${VERSION}-completions.tar.gz" -C completions .
tar czf "dist/nsip-${VERSION}-man-pages.tar.gz" -C man .
- name: Attest build provenance
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 with:
subject-path: dist/*
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: nsip-${{ needs.version.outputs.version }}-extras
path: dist/*
if-no-files-found: error
mcpb:
name: Package MCPB Bundle
needs: [version, build]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- name: Download platform binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: nsip-${{ needs.version.outputs.version }}-*
path: binaries
merge-multiple: true
- name: Stage binaries for bundling
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
mkdir -p server
# manifest.json references unversioned server/nsip-<platform>
# filenames; rename the versioned artifacts back to the names
# the MCPB bundle expects.
for platform in linux-amd64 linux-arm64 macos-amd64 \
macos-arm64 windows-amd64.exe; do
mv "binaries/nsip-${VERSION}-${platform}" \
"server/nsip-${platform}"
done
chmod +x server/nsip-*
- name: Inject version into manifest
env:
VERSION: ${{ needs.version.outputs.version }}
run: >-
sed -i
"s/\"version\": \".*\"/\"version\": \"${VERSION}\"/"
manifest.json
- name: Package MCPB bundle
id: mcpb
uses: zircote/mcp-bundle@902cd0e159b26226a136dc12a2a226457e3bb691 with:
upload-artifact: 'false'
create-release-asset: 'false'
- name: Stage bundle
env:
MCPB_FILE: ${{ steps.mcpb.outputs.bundle-path }}
run: |
mkdir -p dist
cp "$MCPB_FILE" dist/
- name: Attest build provenance
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 with:
subject-path: dist/*
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: nsip-${{ needs.version.outputs.version }}-mcpb
path: dist/*
if-no-files-found: error
sbom:
name: SBOM (generate + attest)
needs: [version, build, extras, mcpb, source]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: nsip-${{ needs.version.outputs.version }}-*
path: dist
merge-multiple: true
- name: Generate CycloneDX SBOM
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 with:
path: .
format: cyclonedx-json
output-file: nsip-${{ needs.version.outputs.version }}-sbom.cdx.json
upload-artifact: false
upload-release-assets: false
- name: Attest SBOM (binds every artifact to the SBOM)
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e with:
subject-path: dist/*
sbom-path: nsip-${{ needs.version.outputs.version }}-sbom.cdx.json
- name: Upload SBOM artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: nsip-${{ needs.version.outputs.version }}-sbom
path: nsip-${{ needs.version.outputs.version }}-sbom.cdx.json
if-no-files-found: error
source:
name: Source Snapshot
needs: [version]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
id-token: write
attestations: write
outputs:
subject-name: ${{ steps.pack.outputs.name }}
subject-digest: ${{ steps.pack.outputs.digest }}
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- name: Pack source snapshot
id: pack
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
mkdir -p dist
NAME="nsip-${VERSION}-source.tar.gz"
git archive --format=tar.gz --prefix="nsip-${VERSION}/" \
-o "dist/${NAME}" "${GITHUB_SHA}"
DIGEST=$(sha256sum "dist/${NAME}" | cut -d' ' -f1)
echo "name=${NAME}" >> "$GITHUB_OUTPUT"
echo "digest=sha256:${DIGEST}" >> "$GITHUB_OUTPUT"
- name: Attest build provenance
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 with:
subject-path: dist/${{ steps.pack.outputs.name }}
- name: Upload source artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: nsip-${{ needs.version.outputs.version }}-source
path: dist/${{ steps.pack.outputs.name }}
if-no-files-found: error
gate-sca:
name: Gate — SCA (OSV)
needs: [version]
permissions:
actions: read
contents: read
security-events: write
pull-requests: write
uses: >-
zircote/.github/.github/workflows/reusable-sca-osv.yml@77a87549a65c6c978a0e87efe0168ed3517f7ca4
with:
fail-on-severity: high
scan-args: |-
--config=osv-scanner.toml
--lockfile=Cargo.lock
gate-trivy:
name: Gate — Trivy (IaC/license)
needs: [version]
permissions:
contents: read
security-events: write
actions: read
packages: read
uses: >-
zircote/.github/.github/workflows/reusable-trivy.yml@77a87549a65c6c978a0e87efe0168ed3517f7ca4
with:
scan-iac: true
attest-sca:
name: Attest — SCA
needs: [version, source, gate-sca]
permissions:
id-token: write
attestations: write
contents: read
uses: >-
zircote/.github/.github/workflows/reusable-attest-scan.yml@740cb8efb57af0187f88e9b4f939355b871a5895
with:
subject-name: ${{ needs.source.outputs.subject-name }}
subject-digest: ${{ needs.source.outputs.subject-digest }}
predicate-type: https://zircote.github.io/attestations/sca/v1
predicate-artifact: ${{ needs.gate-sca.outputs.sarif-artifact }}
predicate-filename: ${{ needs.gate-sca.outputs.sarif-filename }}
attest-iac-license:
name: Attest — IaC/license
needs: [version, source, gate-trivy]
permissions:
id-token: write
attestations: write
contents: read
uses: >-
zircote/.github/.github/workflows/reusable-attest-scan.yml@740cb8efb57af0187f88e9b4f939355b871a5895
with:
subject-name: ${{ needs.source.outputs.subject-name }}
subject-digest: ${{ needs.source.outputs.subject-digest }}
predicate-type: https://zircote.github.io/attestations/iac-license/v1
predicate-artifact: ${{ needs.gate-trivy.outputs.sarif-artifact }}
predicate-filename: ${{ needs.gate-trivy.outputs.sarif-filename }}
verify:
name: Verify Attestations
needs:
- version
- build
- extras
- mcpb
- sbom
- source
- attest-sca
- attest-iac-license
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
attestations: read
steps:
- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: nsip-${{ needs.version.outputs.version }}-*
path: dist
merge-multiple: true
- name: Fail-closed attestation verification
env:
GH_TOKEN: ${{ github.token }}
OWNER: ${{ github.repository_owner }}
SEAM: zircote/.github/.github/workflows/reusable-attest-scan.yml
run: |
set -euo pipefail
shopt -s nullglob
files=(dist/*)
# 5 platform binaries + completions + man pages + MCPB bundle +
# SBOM + source snapshot. A partial set must never reach the release.
if [ "${#files[@]}" -ne 10 ]; then
echo "::error::expected 10 artifacts, found ${#files[@]}"
printf '%s\n' "${files[@]}"
exit 1
fi
for f in "${files[@]}"; do
case "$f" in
*-sbom.cdx.json) continue ;;
esac
echo "Verifying provenance: ${f}"
gh attestation verify "$f" --repo "$GITHUB_REPOSITORY"
echo "Verifying SBOM attestation: ${f}"
gh attestation verify "$f" --repo "$GITHUB_REPOSITORY" \
--predicate-type https://cyclonedx.org/bom
case "$f" in
*-source.tar.gz)
# Source snapshot also carries the seam-signed gate verdicts
# (SCA + IaC/license), signed by the central attest-scan
# reusable. Fail closed if either is missing.
for pt in sca iac-license; do
echo "Verifying gate (${pt}): ${f}"
gh attestation verify "$f" --owner "$OWNER" \
--signer-workflow "$SEAM" \
--predicate-type \
"https://zircote.github.io/attestations/${pt}/v1"
done ;;
esac
done
release:
name: Create Release
if: startsWith(github.ref, 'refs/tags/')
needs: [version, verify, audit, test]
runs-on: ubuntu-latest
timeout-minutes: 15
environment: copilot
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with:
fetch-depth: 0
- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: nsip-${{ needs.version.outputs.version }}-*
path: dist
merge-multiple: true
- name: Generate checksums
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
cd dist
sha256sum -- * > "nsip-${VERSION}-checksums.txt"
- name: Generate changelog
id: changelog
uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 with:
config: cliff.toml
args: --latest --strip all
- name: Create GitHub Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda with:
tag_name: ${{ github.ref }}
name: Release ${{ needs.version.outputs.version }}
body: ${{ steps.changelog.outputs.content }}
draft: false
prerelease: ${{ contains(needs.version.outputs.version, '-alpha') || contains(needs.version.outputs.version, '-beta') || contains(needs.version.outputs.version, '-rc') }}
files: dist/*
env:
GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}