diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d885e3..14a3f96 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -117,9 +117,61 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + # Generate and attach tool version manifest to GitHub release + version-manifest: + needs: [build-and-push] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + packages: read + steps: + - name: Determine version + id: version + run: | + # Strip v prefix: v1.4.2 → 1.4.2 (metadata-action tags images without v) + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#v}" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull published image + run: docker pull ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + + - name: Generate tool version manifest + run: | + docker run --rm \ + ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} \ + bash /opt/devrail/scripts/report-tool-versions.sh \ + > tool-versions.json + + - name: Upload manifest to GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + TAG="${{ steps.version.outputs.tag }}" + for attempt in 1 2 3 4 5; do + if gh release upload "${TAG}" tool-versions.json --clobber; then + echo "Uploaded tool-versions.json to release ${TAG}" + exit 0 + fi + echo "Attempt ${attempt}/5 failed — retrying in 10s" + sleep 10 + done + echo "Failed to upload after 5 attempts" + exit 1 + # Notify on failure notify-failure: - needs: [build-and-push] + needs: [build-and-push, version-manifest] if: failure() runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 279f188..d3ce5e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,14 @@ jobs: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} \ make _check + # Phase 2b: Validate tool version manifest + - name: Validate tool version manifest + run: | + docker run --rm \ + ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} \ + bash /opt/devrail/scripts/report-tool-versions.sh \ + | jq . + # Phase 3: Security scans # Blocking scan: OS packages only. We control the base image and can act on # these. ignore-unfixed skips CVEs with no Debian patch available yet. diff --git a/scripts/report-tool-versions.sh b/scripts/report-tool-versions.sh new file mode 100755 index 0000000..eaa0c73 --- /dev/null +++ b/scripts/report-tool-versions.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# scripts/report-tool-versions.sh — Report all tool versions as JSON +# +# Purpose: Generates a JSON manifest of every tool installed in the +# dev-toolchain container. Unlike the Makefile _docs target, +# this script reports ALL tools unconditionally (no HAS_* gates). +# Usage: bash scripts/report-tool-versions.sh [OUTPUT_FILE] +# If OUTPUT_FILE is given, JSON is written to that file. +# If omitted, JSON is written to stdout. +# Dependencies: lib/log.sh +# +# Tool ecosystems: +# Python — ruff, bandit, mypy, pytest, semgrep +# Bash — shellcheck, shfmt, bats +# Terraform — terraform, tflint, tfsec, checkov, terraform-docs +# Ansible — ansible-lint, molecule +# Ruby — rubocop, reek, brakeman, bundler-audit, rspec, srb +# Go — go, golangci-lint, gofumpt, govulncheck +# JS/TS — node, npm, eslint, prettier, tsc, vitest +# Rust — rustc, cargo, clippy, rustfmt, cargo-audit, cargo-deny +# Universal — trivy, gitleaks, git-cliff + +set -euo pipefail + +# --- Resolve library path --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEVRAIL_LIB="${DEVRAIL_LIB:-${SCRIPT_DIR}/../lib}" + +# shellcheck source=../lib/log.sh +source "${DEVRAIL_LIB}/log.sh" + +# --- Help --- +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + log_info "report-tool-versions.sh — Report all tool versions as JSON" + log_info "Usage: bash scripts/report-tool-versions.sh [OUTPUT_FILE]" + log_info " OUTPUT_FILE Write JSON to file (default: stdout)" + exit 0 +fi + +# --- Output target --- +OUTPUT_FILE="${1:-}" + +# --- Version extraction helper --- +# _tv NAME VERSION_CMD [BINARY] +# NAME — tool key in the JSON output +# VERSION_CMD — command string to extract version (eval'd) +# BINARY — binary to check with command -v (defaults to NAME) +# +# Outputs: +# Tool found + version parsed → "1.2.3" +# Tool found + parse fails → "unknown" +# Tool not on PATH → "not installed" +_sep="" +_tv() { + local name="$1" + local version_cmd="$2" + local binary="${3:-$1}" + local version + + if ! command -v "${binary}" &>/dev/null; then + log_warn "${name}: not installed (${binary} not on PATH)" + version="not installed" + else + local raw + raw=$(eval "${version_cmd}" 2>&1) || true + version=$(printf '%s' "${raw}" | grep -oE '[0-9]+\.[0-9]+[^ ]*' | head -1 || true) + # Reject versions parsed from error messages (e.g. broken rustup components) + if printf '%s' "${raw}" | grep -qi 'error\|not installed'; then + log_warn "${name}: command reported an error" + version="unknown" + elif [[ -z "${version}" ]]; then + log_warn "${name}: could not parse version from '${version_cmd}'" + version="unknown" + else + log_debug "${name}: ${version}" + fi + fi + + printf '%s"%s":"%s"' "${_sep}" "${name}" "${version}" + _sep="," +} + +# --- Generate JSON --- +log_info "Generating tool version manifest" + +_json() { + printf '{"generated_at":"%s","tools":{' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + # Python + _tv ruff "ruff --version" + _tv bandit "bandit --version" + _tv mypy "mypy --version" + _tv pytest "pytest --version" + _tv semgrep "semgrep --version" + + # Bash + _tv shellcheck "shellcheck --version" + _tv shfmt "shfmt --version" + _tv bats "bats --version" + + # Terraform + _tv terraform "terraform version" + _tv tflint "tflint --version" + _tv tfsec "tfsec --version" + _tv checkov "checkov --version" + _tv terraform-docs "terraform-docs --version" + + # Ansible + _tv ansible-lint "ansible-lint --version" + _tv molecule "molecule --version" + + # Ruby + _tv rubocop "rubocop --version" + _tv reek "reek --version" + _tv brakeman "brakeman --version" + _tv bundler-audit "bundler-audit --version" + _tv rspec "rspec --version" + _tv srb "srb --version" + + # Go + _tv go "go version" + _tv golangci-lint "golangci-lint version" + _tv gofumpt "gofumpt --version" + _tv govulncheck "govulncheck -version" + + # JavaScript/TypeScript + _tv node "node --version" + _tv npm "npm --version" + _tv eslint "eslint --version" + _tv prettier "prettier --version" + _tv tsc "tsc --version" + _tv vitest "vitest --version" + + # Rust + _tv rustc "rustc --version" + _tv cargo "cargo --version" + _tv clippy "cargo clippy --version" cargo-clippy + _tv rustfmt "rustfmt --version" + _tv cargo-audit "cargo audit --version" cargo-audit + _tv cargo-deny "cargo deny --version" cargo-deny + + # Universal + _tv trivy "trivy --version" + _tv gitleaks "gitleaks version" + _tv git-cliff "git-cliff --version" + + printf '}}\n' +} + +if [[ -n "${OUTPUT_FILE}" ]]; then + _json >"${OUTPUT_FILE}" + log_info "Tool version manifest written to ${OUTPUT_FILE}" +else + _json +fi