Merge pull request #146 from jackby03/test/add-atomicwrite-tests-1157… #80
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: Quality Gates | |
| permissions: | |
| contents: read | |
| on: | |
| push: | |
| branches: [ main, develop ] | |
| pull_request: | |
| branches: [ main ] | |
| jobs: | |
| build-and-test: | |
| name: Build and Test | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Go | |
| uses: actions/setup-go@v5 | |
| with: | |
| go-version: "1.22" | |
| cache: true | |
| - name: Download dependencies | |
| run: go mod download | |
| - name: Vet | |
| run: go vet ./... | |
| - name: Build | |
| run: go build -v ./... | |
| - name: Test | |
| run: go test ./... -race -coverprofile=coverage.out | |
| - name: Upload coverage | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage | |
| path: coverage.out | |
| lint: | |
| name: Lint | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Go | |
| uses: actions/setup-go@v5 | |
| with: | |
| go-version: "1.22" | |
| - name: golangci-lint | |
| uses: golangci/golangci-lint-action@v6 | |
| with: | |
| version: latest | |
| hardbox-audit: | |
| name: Self-Audit | |
| runs-on: ubuntu-latest | |
| needs: build-and-test | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Go | |
| uses: actions/setup-go@v5 | |
| with: | |
| go-version: "1.22" | |
| - name: Build hardbox | |
| run: go build -o hardbox ./cmd/hardbox | |
| - name: Run audit (dry run, non-interactive) | |
| run: | | |
| ./hardbox audit --profile cis-level1 --format json --output audit-report.json || true | |
| - name: Validate audit report JSON structure | |
| run: | | |
| jq -e ' | |
| has("session_id") and | |
| has("timestamp") and | |
| has("profile") and | |
| has("overall_score") and | |
| (.modules | type == "array" and length > 0) and | |
| all(.modules[]; has("name") and has("score") and has("findings") and (.findings | type == "array")) and | |
| all(.modules[].findings[]?; has("check_id") and has("title") and has("status") and has("severity")) | |
| ' audit-report.json > /dev/null | |
| - name: Assert audit returned findings | |
| run: | | |
| jq -e '[.modules[].findings[]] | length > 0' audit-report.json > /dev/null | |
| - name: Assert audit score is non-zero | |
| run: | | |
| jq -e '.overall_score > 0' audit-report.json > /dev/null | |
| - name: Upload audit report | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: audit-report | |
| path: audit-report.json | |
| markdown-links: | |
| name: Documentation Links | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Check internal file links in README.md | |
| run: | | |
| FAILED=0 | |
| LINKS=$(grep -oP '\]\(\K[^)]+' README.md | grep -v '^https\?://' | grep -v '^#') | |
| for link in $LINKS; do | |
| filepath="${link%%#*}" | |
| [ -z "$filepath" ] && continue | |
| if [ ! -e "$filepath" ]; then | |
| echo "::error file=README.md::Broken link — '$filepath' does not exist" | |
| FAILED=1 | |
| else | |
| echo "✓ $filepath" | |
| fi | |
| done | |
| exit $FAILED | |
| - name: Check internal file links in CONTRIBUTING.md | |
| run: | | |
| FAILED=0 | |
| LINKS=$(grep -oP '\]\(\K[^)]+' CONTRIBUTING.md | grep -v '^https\?://' | grep -v '^#') | |
| for link in $LINKS; do | |
| filepath="${link%%#*}" | |
| [ -z "$filepath" ] && continue | |
| if [ ! -e "$filepath" ]; then | |
| echo "::error file=CONTRIBUTING.md::Broken link — '$filepath' does not exist" | |
| FAILED=1 | |
| else | |
| echo "✓ $filepath" | |
| fi | |
| done | |
| exit $FAILED | |
| profile-consistency: | |
| name: Profile Documentation | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Collect shipped profile names | |
| id: shipped | |
| run: | | |
| PROFILES=$(ls configs/profiles/*.yaml 2>/dev/null \ | |
| | xargs -I{} basename {} .yaml \ | |
| | sort \ | |
| | tr '\n' ' ') | |
| echo "list=${PROFILES}" >> "$GITHUB_OUTPUT" | |
| echo "Shipped profiles: ${PROFILES}" | |
| - name: Assert every shipped profile appears in README | |
| run: | | |
| FAILED=0 | |
| for profile in ${{ steps.shipped.outputs.list }}; do | |
| if ! grep -q "\`${profile}\`" README.md; then | |
| echo "::error file=README.md::Shipped profile '${profile}' is not documented in README.md" | |
| FAILED=1 | |
| else | |
| echo "✓ ${profile} found in README.md" | |
| fi | |
| done | |
| exit $FAILED | |
| - name: Assert every shipped profile appears in COMPLIANCE.md | |
| run: | | |
| FAILED=0 | |
| for profile in ${{ steps.shipped.outputs.list }}; do | |
| if ! grep -q "\`${profile}\`" docs/COMPLIANCE.md; then | |
| echo "::error file=docs/COMPLIANCE.md::Shipped profile '${profile}' is not documented in docs/COMPLIANCE.md" | |
| FAILED=1 | |
| else | |
| echo "✓ ${profile} found in COMPLIANCE.md" | |
| fi | |
| done | |
| exit $FAILED | |
| - name: Warn about profiles listed in README that are not yet shipped | |
| run: | | |
| DOCS_PROFILES=$(grep -oP '`[a-z][a-z0-9-]+`' README.md \ | |
| | tr -d '`' \ | |
| | grep -v '^hardbox$\|^cis$\|^json$\|^text$\|^markdown$\|^html$' \ | |
| | sort -u) | |
| for doc_profile in ${DOCS_PROFILES}; do | |
| if [ ! -f "configs/profiles/${doc_profile}.yaml" ]; then | |
| echo "::notice file=README.md::Profile '${doc_profile}' is documented in README but not yet shipped (roadmap item)" | |
| fi | |
| done | |
| # ── Distro parity — RHEL / Rocky Linux ────────────────────────────────────── | |
| # Runs build + unit tests + smoke audit inside RPM-based containers to catch | |
| # path assumptions, syscall differences, or package-name drift that only | |
| # surface on non-Debian systems. | |
| # | |
| # Container limitations (documented in docs/DEVSECOPS.md): | |
| # - systemd / systemctl is not available inside GitHub Actions containers. | |
| # Modules that shell out to systemctl will return "inactive" gracefully; | |
| # the audit is expected to complete and produce valid JSON regardless. | |
| # - redhat/ubi9 is the freely available RHEL 9 Universal Base Image. | |
| # It is ABI-compatible with RHEL 9 for our build/test/audit purposes. | |
| distro-parity: | |
| name: Distro Parity / ${{ matrix.distro }} | |
| runs-on: ubuntu-latest | |
| needs: build-and-test | |
| container: | |
| image: ${{ matrix.image }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - distro: Rocky Linux 9 | |
| image: rockylinux:9 | |
| slug: rocky-linux-9 | |
| - distro: RHEL UBI 9 | |
| image: redhat/ubi9 | |
| slug: rhel-ubi-9 | |
| steps: | |
| - name: Install system dependencies | |
| run: | | |
| dnf install -y --nodocs git tar gzip ca-certificates jq | |
| # Mark any directory as safe so Go's VCS stamping doesn't fail when | |
| # the workspace UID (runner) differs from the container UID (root). | |
| git config --global --add safe.directory '*' | |
| - uses: actions/checkout@v4 | |
| - name: Set up Go | |
| uses: actions/setup-go@v5 | |
| with: | |
| go-version: "1.22" | |
| cache: true | |
| - name: Download dependencies | |
| run: go mod download | |
| - name: Vet | |
| run: go vet ./... | |
| - name: Build | |
| run: go build -v ./... | |
| - name: Test | |
| run: go test ./... -count=1 -timeout=120s | |
| - name: Build hardbox binary | |
| run: go build -o hardbox ./cmd/hardbox | |
| - name: Prepare system stubs for container audit | |
| # Containers lack the OS files hardbox reads during audit. | |
| # Create the minimum stubs so every module can open its config | |
| # files and produce findings rather than aborting with ENOENT. | |
| run: | | |
| mkdir -p /etc/ssh && touch /etc/ssh/sshd_config | |
| mkdir -p /etc/audit/rules.d | |
| mkdir -p /etc/sysctl.d | |
| mkdir -p /etc/pam.d && touch /etc/pam.d/common-auth /etc/pam.d/common-password | |
| mkdir -p /etc/chrony && touch /etc/chrony/chrony.conf | |
| mkdir -p /etc/systemd/system | |
| touch /etc/login.defs | |
| touch /etc/rsyslog.conf | |
| - name: Smoke audit (cis-level1) | |
| run: | | |
| ./hardbox audit --profile cis-level1 --format json \ | |
| --output audit-${{ matrix.slug }}.json || true | |
| - name: Validate audit report structure | |
| run: | | |
| jq -e ' | |
| has("session_id") and | |
| has("overall_score") and | |
| (.modules | type == "array" and length > 0) and | |
| all(.modules[]; has("name") and has("findings")) | |
| ' "audit-${{ matrix.slug }}.json" > /dev/null | |
| echo "✓ Audit report structure valid on ${{ matrix.distro }}" | |
| - name: Assert audit produced findings | |
| run: | | |
| COUNT=$(jq '[.modules[].findings[]] | length' "audit-${{ matrix.slug }}.json") | |
| echo "Findings on ${{ matrix.distro }}: ${COUNT}" | |
| if [ "${COUNT}" -eq 0 ]; then | |
| echo "::error::Audit returned 0 findings on ${{ matrix.distro }} — distro detection may be broken" | |
| exit 1 | |
| fi | |
| - name: Upload audit report | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: audit-${{ matrix.slug }} | |
| path: audit-${{ matrix.slug }}.json | |
| # Single gate job that requires all matrix legs — add this check name to | |
| # branch protection so the merge button blocks until every distro passes. | |
| distro-parity-gate: | |
| name: Distro Parity Gate | |
| runs-on: ubuntu-latest | |
| needs: distro-parity | |
| if: always() | |
| steps: | |
| - name: Assert all parity legs passed | |
| run: | | |
| RESULT="${{ needs.distro-parity.result }}" | |
| if [ "${RESULT}" != "success" ]; then | |
| echo "::error::One or more distro parity checks failed (result: ${RESULT})" | |
| exit 1 | |
| fi | |
| echo "✓ All distro parity checks passed" | |
| goreleaser-config: | |
| name: Release Configuration | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Extract GoReleaser release target | |
| id: goreleaser | |
| run: | | |
| OWNER=$(grep -A3 'release:' .goreleaser.yaml \ | |
| | grep -A2 'github:' \ | |
| | grep 'owner:' \ | |
| | awk '{print $2}') | |
| REPO=$(grep -A3 'release:' .goreleaser.yaml \ | |
| | grep -A2 'github:' \ | |
| | grep 'name:' \ | |
| | awk '{print $2}') | |
| echo "owner=${OWNER}" >> "$GITHUB_OUTPUT" | |
| echo "repo=${REPO}" >> "$GITHUB_OUTPUT" | |
| echo "Configured release target: ${OWNER}/${REPO}" | |
| - name: Derive canonical owner/name from GITHUB_REPOSITORY | |
| id: canonical | |
| run: | | |
| CANONICAL_OWNER="${GITHUB_REPOSITORY_OWNER}" | |
| CANONICAL_REPO="${GITHUB_REPOSITORY#*/}" | |
| echo "owner=${CANONICAL_OWNER}" >> "$GITHUB_OUTPUT" | |
| echo "repo=${CANONICAL_REPO}" >> "$GITHUB_OUTPUT" | |
| echo "Canonical repository: ${CANONICAL_OWNER}/${CANONICAL_REPO}" | |
| - name: Assert owner matches | |
| run: | | |
| CONFIGURED="${{ steps.goreleaser.outputs.owner }}" | |
| CANONICAL="${{ steps.canonical.outputs.owner }}" | |
| if [ "${CONFIGURED}" != "${CANONICAL}" ]; then | |
| echo "::error file=.goreleaser.yaml::release.github.owner is '${CONFIGURED}' but canonical owner is '${CANONICAL}'. Update .goreleaser.yaml." | |
| exit 1 | |
| fi | |
| echo "✓ owner matches: ${CONFIGURED}" | |
| - name: Assert repo name matches | |
| run: | | |
| CONFIGURED="${{ steps.goreleaser.outputs.repo }}" | |
| CANONICAL="${{ steps.canonical.outputs.repo }}" | |
| if [ "${CONFIGURED}" != "${CANONICAL}" ]; then | |
| echo "::error file=.goreleaser.yaml::release.github.name is '${CONFIGURED}' but canonical repo name is '${CANONICAL}'. Update .goreleaser.yaml." | |
| exit 1 | |
| fi | |
| echo "✓ repo name matches: ${CONFIGURED}" | |
| - name: Install GoReleaser | |
| uses: goreleaser/goreleaser-action@v6 | |
| with: | |
| install-only: true | |
| version: "~> v2" | |
| - name: Check GoReleaser config | |
| run: goreleaser check --config .goreleaser.yaml |