diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1bbb34e802..b95dd7c90b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,25 +1,31 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for more information: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates -# https://containers.dev/guide/dependabot - version: 2 updates: - - package-ecosystem: "devcontainers" - directory: "/" - schedule: - interval: weekly - - package-ecosystem: "gomod" - directory: "/" + - package-ecosystem: gomod + directory: / schedule: interval: weekly + day: monday + open-pull-requests-limit: 10 + groups: + go-dependencies: + patterns: ['*'] - package-ecosystem: github-actions directory: / schedule: interval: weekly + day: monday + open-pull-requests-limit: 5 groups: github-actions: - patterns: - - "*" + patterns: ['*'] + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 3 + groups: + docker: + patterns: ['*'] diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000000..d1c2528b3f --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,12 @@ +documentation: + - changed-files: + - any-glob-to-any-file: ['**/*.md', 'docs/**/*'] +ci: + - changed-files: + - any-glob-to-any-file: ['.github/**/*'] +dependencies: + - changed-files: + - any-glob-to-any-file: ['go.mod', 'go.sum'] +tests: + - changed-files: + - any-glob-to-any-file: ['**/*_test.go', 'testdata/**/*'] diff --git a/.github/template.yaml b/.github/template.yaml new file mode 100644 index 0000000000..402938fe9a --- /dev/null +++ b/.github/template.yaml @@ -0,0 +1,8 @@ +# Managed by netresearch/.github/templates/ +# Drift from the template is blocking CI in this repo (check-template-drift.yml). +# Record explicit exceptions under intentional-drift[] to unblock. +template: go-app +intentional-drift: +- path: .github/workflows/container.yml + reason: "GHCR package name is 'ofelia' (short form), not 'netresearch/ofelia' \u2014\ + \ pre-dates template; renaming would break existing image consumers." diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge-deps.yml similarity index 100% rename from .github/workflows/auto-merge.yml rename to .github/workflows/auto-merge-deps.yml diff --git a/.github/workflows/check-template-drift.yml b/.github/workflows/check-template-drift.yml new file mode 100644 index 0000000000..3c6e635208 --- /dev/null +++ b/.github/workflows/check-template-drift.yml @@ -0,0 +1,17 @@ +name: Template Drift + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: {} + +jobs: + drift: + uses: netresearch/.github/.github/workflows/check-template-drift.yml@main + with: + template: go-app + permissions: + contents: read diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da31223c85..3a6e91fc89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,649 +14,17 @@ on: permissions: contents: read -env: - REGISTRY: ghcr.io - IMAGE_NAME: "${{ github.repository_owner }}/ofelia" - jobs: - unit-smoke: - name: smoke tests (fast feedback) - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - cache: true - - - name: Verify go.mod is tidy - run: | - go mod tidy - git diff --exit-code -- go.mod go.sum - - - name: Smoke tests (core packages only) - run: | - # Run fast smoke tests on core packages first for quick feedback - go test -race -timeout=60s ./core/... ./config/... - - unit-full: - name: unit tests (full suite) - runs-on: ubuntu-latest - needs: unit-smoke - strategy: - matrix: - package: [cli, core, config, metrics, middlewares, web] - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - cache: true - - - name: Unit tests ${{ matrix.package }} - run: | - go test -race -covermode=atomic -coverprofile=coverage-${{ matrix.package }}.out ./${{ matrix.package }}/... - - - name: Upload coverage - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: coverage-${{ matrix.package }} - path: coverage-${{ matrix.package }}.out - - unit-coverage: - name: coverage analysis - runs-on: ubuntu-latest - needs: unit-full - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - - - name: Download coverage artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: coverage-* - merge-multiple: true - - - name: Merge coverage reports - run: | - # Merge all coverage files - echo "mode: atomic" > coverage.out - find . -name "coverage-*.out" -exec tail -n +2 {} \; >> coverage.out - - - name: Coverage summary - run: go tool cover -func=coverage.out | tail -n 1 || true - - - name: Enforce coverage threshold - run: | - THRESHOLD=60.0 - TOTAL=$(go tool cover -func=coverage.out | awk '/^total:/ {gsub("%","",$3); print $3}') - echo "Total coverage: ${TOTAL}% (threshold ${THRESHOLD}%)" - awk -v t="$THRESHOLD" -v a="$TOTAL" 'BEGIN { if (a+0 < t+0) { exit 1 } }' - - - name: Upload merged coverage - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: coverage-merged - path: coverage.out - - - name: Upload to Codecov - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 - with: - files: coverage.out - fail_ci_if_error: false - verbose: true - - # Summary job for branch protection - required status check - unit-tests-summary: - name: unit tests - runs-on: ubuntu-latest - needs: [unit-full, unit-coverage] - steps: - - name: All unit tests passed - run: echo "All unit tests completed successfully" - - lint: - name: golangci-lint (all linters) - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - cache: true - - - name: Download modules - run: go mod download - - - name: Warm build cache - run: go build ./... - - - name: Run golangci-lint - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 - with: - version: v2.9.0 - args: --timeout=5m - only-new-issues: false - - - name: Run golangci-lint (integration tags) - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 - with: - version: v2.9.0 - args: --timeout=5m --build-tags=integration - only-new-issues: false - - - name: Run golangci-lint (e2e tags) - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 - with: - version: v2.9.0 - args: --timeout=5m --build-tags=e2e - only-new-issues: false - - fuzz: - name: fuzz tests - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - - - name: Run fuzz tests - run: | - # Run each fuzz test for 30 seconds - go test -fuzz=FuzzBuildFromString -fuzztime=30s ./cli/... - go test -fuzz=FuzzDockerLabels -fuzztime=30s ./cli/... - - vulncheck: - name: govulncheck - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - - name: Run govulncheck and fail only if fix available - run: | - set +e - TMP_OUT=$(mktemp) - go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... | tee "$TMP_OUT" - set -e - # Fail only if any vulnerability reports a concrete fixed version (not "N/A") - if grep -E "^\s*Fixed in:\s+" "$TMP_OUT" | grep -v "Fixed in: N/A" >/dev/null; then - echo "govulncheck: vulnerabilities with available fixes detected; failing job" - exit 1 - fi - echo "govulncheck: no vulnerabilities with available fixes (or only unfixed advisories)" - exit 0 - - dependency-review: - name: Dependency Review - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - permissions: - contents: read - pull-requests: write - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Dependency Review - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 - with: - fail-on-severity: high - allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, MPL-2.0, CC0-1.0, Unlicense - # Packages with compound SPDX expressions (e.g. "Apache-2.0 AND BSD-3-Clause") - # that dependency-review-action cannot match via allow-licenses - allow-dependencies-licenses: >- - pkg:golang/go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp, - pkg:golang/go.opentelemetry.io/otel, - pkg:golang/go.opentelemetry.io/otel/exporters/otlp/otlptrace, - pkg:golang/go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp, - pkg:golang/go.opentelemetry.io/otel/metric, - pkg:golang/go.opentelemetry.io/otel/trace, - pkg:golang/golang.org/x/crypto, - pkg:golang/golang.org/x/sys, - pkg:golang/golang.org/x/term, - pkg:golang/golang.org/x/text, - pkg:golang/golang.org/x/time - comment-summary-in-pr: always - - license-check: - name: License Compliance - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - - - name: Install go-licenses - # Pin by hash for OpenSSF Scorecard compliance - run: go install github.com/google/go-licenses@5348b744d0983d85713295ea08a20cca1654a45e # v1.6.0 - - - name: Check licenses - run: | - "$(go env GOPATH)/bin/go-licenses" check ./... 2>&1 | tee license-report.txt - # Fail on forbidden licenses (GPL-3.0, AGPL-3.0) - if grep -qE "(GPL-3.0|AGPL-3.0)" license-report.txt; then - echo "::error::Found forbidden license (GPL-3.0 or AGPL-3.0)" - exit 1 - fi - - - name: Generate license report - run: | - "$(go env GOPATH)/bin/go-licenses" report ./... > licenses.csv 2>/dev/null || true - continue-on-error: true - - - name: Upload license report - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: license-report - path: | - license-report.txt - licenses.csv - if: always() - - integration: - name: integration tests - needs: unit-coverage - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - cache: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - - name: Confirm Docker - run: docker info - - - name: Integration tests - run: go test -tags=integration -timeout=5m ./... - - codeql: - # skip merge queue branches as they disappear before the upload step - if: (github.event_name != 'push') || (!startsWith(github.ref, 'refs/heads/gh-readonly-queue/')) - name: CodeQL Analysis - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - - - name: Initialize CodeQL - uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 - with: - languages: go - - - name: Download dependencies - run: go mod download - - - name: Build - uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 - - gosec: - name: gosec - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - - - name: Install gosec - # Pin by hash for OpenSSF Scorecard compliance - run: go install github.com/securego/gosec/v2/cmd/gosec@5d1a18b12ab7a9858baf9b7cffa903028792ed17 # v2.22.0 - - - name: Run gosec - run: | - echo "GOPATH=$(go env GOPATH)" - "$(go env GOPATH)/bin/gosec" ./... - - gitleaks: - name: gitleaks - # gitleaks-action doesn't support merge_group events - # Skip for fork PRs as secrets are not available - if: github.event_name != 'merge_group' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true) - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - - name: Run gitleaks - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }} - - build-binaries: - name: Build ${{ matrix.suffix }} binary - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - strategy: - matrix: - include: - - goos: linux - goarch: "386" - suffix: linux-386 - - goos: linux - goarch: amd64 - suffix: linux-amd64 - - goos: linux - goarch: arm64 - suffix: linux-arm64 - - goos: linux - goarch: arm - goarm: "6" - suffix: linux-armv6 - - goos: linux - goarch: arm - goarm: "7" - suffix: linux-armv7 - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - cache: true - - - name: Build binary - env: - CGO_ENABLED: "0" - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - GOARM: ${{ matrix.goarm }} - run: go build -trimpath -ldflags="-s -w -X 'main.version=edge' -X 'main.build=${{ github.sha }}'" -o ofelia-${{ matrix.suffix }} . - - - name: Upload binary - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: binary-${{ matrix.suffix }} - path: ofelia-${{ matrix.suffix }} - retention-days: 1 - - build_image: - name: Build and push container image - # push container image only for commits on the main branch to keep - # ghcr.io from accumulating untagged images on every branch push - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: [build-binaries] - runs-on: ubuntu-latest - env: - IMAGE_NAME: "${{ github.repository_owner }}/ofelia:edge" - permissions: - contents: read - packages: write - id-token: write - attestations: write - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Download pre-built binaries - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: binary-* - merge-multiple: true - path: bin - - - name: Set up QEMU - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - - name: Log in to the Container registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 - # GHCR reads description from manifest index annotations, not image labels - # See: https://docs.docker.com/build/metadata/annotations/ - env: - DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index - with: - images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/ofelia - # Generate OCI Image annotations - # See: https://github.com/opencontainers/image-spec/blob/main/annotations.md - tags: | - type=edge,branch=main - # Static labels/annotations (dynamic ones like created, revision are auto-generated) - # Note: base.name is set in Dockerfile with full digest for accuracy - labels: | - org.opencontainers.image.title=Ofelia - org.opencontainers.image.description=A docker job scheduler (based on mcuadros/ofelia) - org.opencontainers.image.vendor=Netresearch DTT GmbH - org.opencontainers.image.authors=Netresearch DTT GmbH - org.opencontainers.image.documentation=https://github.com/netresearch/ofelia#readme - - - name: Build and push Docker image - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 - id: push - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - annotations: ${{ steps.meta.outputs.annotations }} - platforms: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 - provenance: true - sbom: true - - - name: Install crane - uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5 - - - name: Tag platform-specific images - run: | - set -euo pipefail - IMAGE="${{ env.REGISTRY }}/${{ github.repository_owner }}/ofelia" - - # Get the manifest and extract platform digests - for platform in linux/386 linux/amd64 linux/arm/v6 linux/arm/v7 linux/arm64; do - # Convert platform to tag suffix (linux/arm/v7 -> arm-v7) - suffix=$(echo "$platform" | sed 's|linux/||; s|/|-|g') - - # Get the digest for this specific platform - digest=$(crane manifest "$IMAGE:edge" | jq -r ".manifests[] | select(.platform.os + \"/\" + .platform.architecture + (if .platform.variant then \"/\" + .platform.variant else \"\" end) == \"$platform\") | .digest") - - if [ -n "$digest" ] && [ "$digest" != "null" ]; then - echo "Tagging $IMAGE:edge-$suffix from $digest" - crane tag "$IMAGE@$digest" "edge-$suffix" - fi - done - - - name: Install Cosign - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - - - name: Sign container image - run: | - set -euo pipefail - cosign sign --yes ${{ env.REGISTRY }}/${{ github.repository_owner }}/ofelia:edge@${{ steps.push.outputs.digest }} - - hadolint: - name: hadolint - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Lint Dockerfile - uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0 - with: - dockerfile: Dockerfile - - workflow-lint: - name: actionlint - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0 - - trivy: - name: trivy scan (fs) - runs-on: ubuntu-latest + go-check: + uses: netresearch/.github/.github/workflows/go-check.yml@main + with: + enable-smoke-fast-feedback: true + enable-fuzz: true + enable-license-check: true + enable-codecov: true + coverage-threshold: 80.0 permissions: contents: read security-events: write - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Run Trivy filesystem scan - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 - with: - scan-type: fs - ignore-unfixed: true - format: sarif - output: trivy-results.sarif - vuln-type: 'os,library' - severity: CRITICAL,HIGH - exit-code: 0 - - name: Upload Trivy SARIF - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 - with: - sarif_file: trivy-results.sarif + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/cleanup-containers.yml b/.github/workflows/cleanup-containers.yml deleted file mode 100644 index 5b7b3a3dca..0000000000 --- a/.github/workflows/cleanup-containers.yml +++ /dev/null @@ -1,340 +0,0 @@ -name: Cleanup Container Images - -# Cleans up old container image versions from ghcr.io -# -# Strategy: -# - Release images (v*, latest*) are kept FOREVER -# - Edge images (edge*) older than 30 days are deleted -# - Orphaned untagged images (not referenced by any manifest) are deleted -# - Untagged images referenced by manifests (attestations) are preserved -# -# Multi-arch builds create multiple images per release: -# - 5 platform images (386, amd64, arm/v6, arm/v7, arm64) -# - 2 attestation images (provenance, sbom) -# Platform images are tagged (e.g., v0.13.2-amd64, edge-amd64) -# Attestation images remain untagged but are referenced by manifests -# -# See: https://github.com/actions/delete-package-versions/issues/162 - -on: - schedule: - # Run weekly on Sundays at 2:00 AM UTC - - cron: "0 2 * * 0" - workflow_dispatch: - inputs: - dry-run: - description: "Dry run (don't delete, just show what would be deleted)" - required: false - default: "true" - type: choice - options: - - "true" - - "false" - edge-retention-days: - description: "Days to keep edge images before deletion" - required: false - default: "30" - type: string - orphan-retention-days: - description: "Days to keep orphaned untagged images before deletion" - required: false - default: "7" - type: string - -permissions: read-all - -env: - PACKAGE_NAME: ofelia - IMAGE: ghcr.io/netresearch/ofelia - # Default retention for edge images (30 days) - EDGE_RETENTION_DAYS: ${{ inputs.edge-retention-days || '30' }} - # Default retention for orphaned untagged images (7 days) - ORPHAN_RETENTION_DAYS: ${{ inputs.orphan-retention-days || '7' }} - -jobs: - cleanup-edge-images: - name: Cleanup Old Edge Images - runs-on: ubuntu-latest - permissions: - packages: write - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Cleanup old edge container versions - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DRY_RUN: ${{ inputs.dry-run || 'false' }} - run: | - set -euo pipefail - - echo "๐Ÿ” Fetching container versions for $PACKAGE_NAME..." - echo "๐Ÿ“… Edge retention: $EDGE_RETENTION_DAYS days" - echo "๐Ÿงช Dry run: $DRY_RUN" - echo "" - - # Calculate cutoff date - CUTOFF_DATE=$(date -d "-${EDGE_RETENTION_DAYS} days" -Iseconds) - echo "๐Ÿ“† Cutoff date: $CUTOFF_DATE" - echo "" - - # Get all versions (paginated) - PAGE=1 - VERSIONS_TO_DELETE=() - - while true; do - VERSIONS=$(gh api \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/orgs/netresearch/packages/container/${PACKAGE_NAME}/versions?per_page=100&page=${PAGE}" 2>/dev/null || echo "[]") - - # Check if we got any versions - COUNT=$(echo "$VERSIONS" | jq 'length') - if [ "$COUNT" -eq 0 ]; then - break - fi - - echo "๐Ÿ“ฆ Processing page $PAGE ($COUNT versions)..." - - # Process each version - while IFS= read -r version; do - VERSION_ID=$(echo "$version" | jq -r '.id') - CREATED_AT=$(echo "$version" | jq -r '.created_at') - TAGS=$(echo "$version" | jq -r '.metadata.container.tags // [] | join(", ")') - - # Skip if no tags (these are likely attestations referenced by manifests) - if [ -z "$TAGS" ]; then - continue - fi - - # Check if ANY tag is a release tag (v*, latest*) - HAS_RELEASE_TAG=$(echo "$version" | jq -r ' - .metadata.container.tags // [] | - any(. | test("^v[0-9]") or test("^latest")) - ') - - if [ "$HAS_RELEASE_TAG" = "true" ]; then - # Skip - this is a release image - continue - fi - - # Check if this is an edge image - IS_EDGE=$(echo "$version" | jq -r ' - .metadata.container.tags // [] | - any(. | test("^edge")) - ') - - if [ "$IS_EDGE" != "true" ]; then - # Skip - not an edge image - continue - fi - - # Check if older than cutoff - if [[ "$CREATED_AT" < "$CUTOFF_DATE" ]]; then - echo " ๐Ÿ—‘๏ธ Version $VERSION_ID (tags: $TAGS) created $CREATED_AT - WILL DELETE" - VERSIONS_TO_DELETE+=("$VERSION_ID") - fi - done < <(echo "$VERSIONS" | jq -c '.[]') - - PAGE=$((PAGE + 1)) - done - - echo "" - echo "๐Ÿ“Š Summary: ${#VERSIONS_TO_DELETE[@]} edge versions to delete" - echo "" - - if [ ${#VERSIONS_TO_DELETE[@]} -eq 0 ]; then - echo "โœ… No old edge images to clean up" - exit 0 - fi - - if [ "$DRY_RUN" = "true" ]; then - echo "๐Ÿงช DRY RUN - Would delete ${#VERSIONS_TO_DELETE[@]} versions" - echo " Run with dry-run=false to actually delete" - exit 0 - fi - - # Delete the versions - DELETED=0 - FAILED=0 - for VERSION_ID in "${VERSIONS_TO_DELETE[@]}"; do - echo "๐Ÿ—‘๏ธ Deleting version $VERSION_ID..." - if gh api \ - --method DELETE \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/orgs/netresearch/packages/container/${PACKAGE_NAME}/versions/${VERSION_ID}" 2>/dev/null; then - DELETED=$((DELETED + 1)) - else - echo " โš ๏ธ Failed to delete version $VERSION_ID" - FAILED=$((FAILED + 1)) - fi - done - - echo "" - echo "โœ… Deleted $DELETED versions" - if [ $FAILED -gt 0 ]; then - echo "โš ๏ธ Failed to delete $FAILED versions" - fi - - cleanup-orphaned-images: - name: Cleanup Orphaned Untagged Images - runs-on: ubuntu-latest - needs: cleanup-edge-images - permissions: - packages: write - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Install crane - uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5 - with: - version: latest - - - name: Log in to Container registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Cleanup orphaned untagged images - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DRY_RUN: ${{ inputs.dry-run || 'false' }} - run: | - set -euo pipefail - - echo "๐Ÿ” Collecting referenced digests from all tagged manifests..." - echo "๐Ÿ“… Orphan retention: $ORPHAN_RETENTION_DAYS days" - echo "๐Ÿงช Dry run: $DRY_RUN" - echo "" - - # Calculate cutoff date - CUTOFF_DATE=$(date -d "-${ORPHAN_RETENTION_DAYS} days" -Iseconds) - echo "๐Ÿ“† Cutoff date: $CUTOFF_DATE" - echo "" - - # Collect all referenced digests from tagged manifests - REFERENCED_DIGESTS=$(mktemp) - TAGS=$(crane ls "$IMAGE" 2>/dev/null || echo "") - - if [ -z "$TAGS" ]; then - echo "โš ๏ธ No tags found, skipping" - exit 0 - fi - - TAG_COUNT=$(echo "$TAGS" | wc -l) - echo "๐Ÿ“ฆ Found $TAG_COUNT tags, collecting referenced digests..." - - for TAG in $TAGS; do - # Get manifest and extract all referenced digests - MANIFEST=$(crane manifest "$IMAGE:$TAG" 2>/dev/null || echo "{}") - - # Extract digests from manifest list (multi-arch) or single manifest - echo "$MANIFEST" | jq -r ' - # From manifest list - (.manifests[]?.digest // empty), - # From config - (.config?.digest // empty), - # From layers - (.layers[]?.digest // empty) - ' 2>/dev/null >> "$REFERENCED_DIGESTS" || true - done - - # Sort and dedupe - sort -u "$REFERENCED_DIGESTS" -o "$REFERENCED_DIGESTS" - REF_COUNT=$(wc -l < "$REFERENCED_DIGESTS") - echo "โœ… Found $REF_COUNT unique referenced digests" - echo "" - - # Now find orphaned untagged versions - echo "๐Ÿ” Scanning for orphaned untagged versions..." - PAGE=1 - VERSIONS_TO_DELETE=() - - while true; do - VERSIONS=$(gh api \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/orgs/netresearch/packages/container/${PACKAGE_NAME}/versions?per_page=100&page=${PAGE}" 2>/dev/null || echo "[]") - - COUNT=$(echo "$VERSIONS" | jq 'length') - if [ "$COUNT" -eq 0 ]; then - break - fi - - echo "๐Ÿ“ฆ Processing page $PAGE ($COUNT versions)..." - - while IFS= read -r version; do - VERSION_ID=$(echo "$version" | jq -r '.id') - CREATED_AT=$(echo "$version" | jq -r '.created_at') - TAGS=$(echo "$version" | jq -r '.metadata.container.tags // [] | join(", ")') - DIGEST=$(echo "$version" | jq -r '.name') - - # Only process untagged versions - if [ -n "$TAGS" ]; then - continue - fi - - # Check if older than cutoff - if [[ ! "$CREATED_AT" < "$CUTOFF_DATE" ]]; then - continue - fi - - # Check if referenced by any manifest - if grep -qF "$DIGEST" "$REFERENCED_DIGESTS" 2>/dev/null; then - continue - fi - - echo " ๐Ÿ—‘๏ธ Orphaned: $VERSION_ID ($DIGEST) created $CREATED_AT" - VERSIONS_TO_DELETE+=("$VERSION_ID") - done < <(echo "$VERSIONS" | jq -c '.[]') - - PAGE=$((PAGE + 1)) - done - - rm -f "$REFERENCED_DIGESTS" - - echo "" - echo "๐Ÿ“Š Summary: ${#VERSIONS_TO_DELETE[@]} orphaned untagged versions to delete" - echo "" - - if [ ${#VERSIONS_TO_DELETE[@]} -eq 0 ]; then - echo "โœ… No orphaned images to clean up" - exit 0 - fi - - if [ "$DRY_RUN" = "true" ]; then - echo "๐Ÿงช DRY RUN - Would delete ${#VERSIONS_TO_DELETE[@]} versions" - echo " Run with dry-run=false to actually delete" - exit 0 - fi - - # Delete the versions - DELETED=0 - FAILED=0 - for VERSION_ID in "${VERSIONS_TO_DELETE[@]}"; do - echo "๐Ÿ—‘๏ธ Deleting version $VERSION_ID..." - if gh api \ - --method DELETE \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/orgs/netresearch/packages/container/${PACKAGE_NAME}/versions/${VERSION_ID}" 2>/dev/null; then - DELETED=$((DELETED + 1)) - else - echo " โš ๏ธ Failed to delete version $VERSION_ID" - FAILED=$((FAILED + 1)) - fi - done - - echo "" - echo "โœ… Deleted $DELETED orphaned versions" - if [ $FAILED -gt 0 ]; then - echo "โš ๏ธ Failed to delete $FAILED versions" - fi diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..46686ac610 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,21 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '30 4 * * 1' + +permissions: {} + +jobs: + codeql: + uses: netresearch/.github/.github/workflows/codeql.yml@main + with: + languages: go + permissions: + contents: read + security-events: write + actions: read diff --git a/.github/workflows/container-retention.yml b/.github/workflows/container-retention.yml new file mode 100644 index 0000000000..3ce5fa5805 --- /dev/null +++ b/.github/workflows/container-retention.yml @@ -0,0 +1,23 @@ +name: Container Retention + +on: + schedule: + - cron: '0 2 * * 0' + workflow_dispatch: + inputs: + dry-run: + description: "List what would be deleted without actually deleting." + type: boolean + default: true + +permissions: {} + +jobs: + retention: + uses: netresearch/.github/.github/workflows/ghcr-retention.yml@main + with: + package-name: ${{ github.event.repository.name }} + dry-run: ${{ inputs.dry-run != false }} + permissions: + packages: write + attestations: read diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 0000000000..dacbe9f818 --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,24 @@ +name: Container + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + container: + uses: netresearch/.github/.github/workflows/build-container.yml@main + with: + image-name: ofelia + platforms: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 + permissions: + contents: read + packages: write + id-token: write + attestations: write diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000000..c6bf2daa94 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,14 @@ +name: Dependency Review + +on: + pull_request: + branches: [main] + +permissions: {} + +jobs: + review: + uses: netresearch/.github/.github/workflows/dependency-review.yml@main + permissions: + contents: read + pull-requests: write diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000000..acaf91af87 --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,15 @@ +name: Gitleaks + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: {} + +jobs: + gitleaks: + uses: netresearch/.github/.github/workflows/gitleaks.yml@main + permissions: + contents: read diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000000..d1a53734bd --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,48 @@ +# Integration tests โ€” ofelia-specific, not part of the go-app template. +# Docker is required to build and run the test environment. + +name: Integration + +on: + push: + branches: [main] + pull_request: + branches: [main] + merge_group: + workflow_dispatch: + +permissions: {} + +jobs: + integration: + name: Integration tests + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@6c3c2f2c1c457b00c10c4848d6f5491db3b629df # v2.18.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Confirm Docker + run: docker info + + - name: Integration tests + run: go test -tags=integration -timeout=5m ./... diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000000..e7d26c03d0 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,14 @@ +name: Labeler + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: {} + +jobs: + labeler: + uses: netresearch/.github/.github/workflows/labeler.yml@main + permissions: + contents: read + pull-requests: write diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index d0ee952201..ba497f1d87 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -1,177 +1,26 @@ name: Mutation Testing on: - # Run weekly to monitor test quality without slowing down regular CI schedule: - - cron: '0 2 * * 0' # Every Sunday at 2 AM UTC - # Allow manual triggering - workflow_dispatch: - inputs: - diff_only: - description: 'Only test mutations in changed files' - required: false - default: 'false' - type: boolean - # Run on PRs for diff-based mutation testing (optional gate) + - cron: '0 2 * * 0' pull_request: - branches: [main] paths: - '**.go' - '.gremlins.yaml' - '.github/workflows/mutation.yml' + workflow_dispatch: -permissions: - contents: read - pull-requests: write +permissions: {} -# Cancel in-progress runs for the same branch concurrency: group: mutation-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: mutation: - name: Mutation Testing - runs-on: ubuntu-latest - # Skip for merge queue to avoid blocking merges - if: github.event_name != 'merge_group' - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 # Full history for diff mode - - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - - - name: Download dependencies - run: go mod download - - - name: Install Gremlins - run: go install github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0 - - - name: Run mutation tests (full) - if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.diff_only != 'true') - run: | - echo "Running full mutation testing..." - gremlins unleash --config=.gremlins.yaml 2>&1 | tee mutation-output.txt - # Extract mutation score (gremlins outputs "Test efficacy: X.XX%") - SCORE=$(grep -oP 'Test efficacy: \K[\d.]+' mutation-output.txt || echo "0") - echo "MUTATION_SCORE=${SCORE}" >> "$GITHUB_ENV" - { - echo "## Mutation Testing Results" - echo "" - echo "**Mutation Score:** ${SCORE}%" - echo "" - echo "See the full report in the artifacts." - } >> "$GITHUB_STEP_SUMMARY" - continue-on-error: true # Don't fail workflow on threshold violations - - - name: Run mutation tests (diff only) - if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.event.inputs.diff_only == 'true') - run: | - echo "Running diff-based mutation testing..." - # Determine the base ref to diff against - if [ "${{ github.event_name }}" = "pull_request" ]; then - BASE_REF="${{ github.event.pull_request.base.sha }}" - else - # For workflow_dispatch, diff against main branch - BASE_REF="origin/main" - fi - echo "Diffing against: $BASE_REF" - # Check if there are any Go source files changed - if ! git diff --name-only "$BASE_REF"...HEAD -- '*.go' | grep -v '_test.go' > /dev/null 2>&1; then - echo "No Go source files changed, skipping mutation testing." - { - echo "## Mutation Testing Results" - echo "No Go source files changed - skipped." - } >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - echo "Testing mutations in changed files..." - gremlins unleash --config=.gremlins.yaml --diff "$BASE_REF" 2>&1 | tee mutation-output.txt - # Extract mutation score (gremlins outputs "Test efficacy: X.XX%") - SCORE=$(grep -oP 'Test efficacy: \K[\d.]+' mutation-output.txt || echo "0") - echo "MUTATION_SCORE=${SCORE}" >> "$GITHUB_ENV" - { - echo "## Mutation Testing Results (Diff Mode)" - echo "" - echo "**Mutation Score:** ${SCORE}%" - echo "" - echo "Testing only changed files for faster feedback." - } >> "$GITHUB_STEP_SUMMARY" - continue-on-error: true - - - name: Upload mutation reports - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: always() - with: - name: mutation-reports - path: | - mutation-report.json - mutation-report.html - mutation-output.txt - retention-days: 30 - - - name: Comment on PR with results - if: >- - github.event_name == 'pull_request' && - env.MUTATION_SCORE != '' && - github.event.pull_request.head.repo.full_name == github.repository - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const score = process.env.MUTATION_SCORE || '0'; - const threshold = 60; - const emoji = parseFloat(score) >= threshold ? ':white_check_mark:' : ':warning:'; - - const body = `## ${emoji} Mutation Testing Results - - **Mutation Score:** ${score}% (threshold: ${threshold}%) - - ${parseFloat(score) < threshold - ? '> :warning: Score is below threshold. Consider improving test coverage or test quality.' - : '> :sparkles: Good job! Mutation score meets the threshold.'} - -
- What is mutation testing? - - Mutation testing measures test quality by introducing small changes (mutations) to the code and checking if tests detect them. A higher score means better test effectiveness. - - - **Killed mutants**: Tests caught the mutation (good!) - - **Survived mutants**: Tests missed the mutation (needs improvement) -
`; - - // Find existing comment - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const botComment = comments.find(c => - c.user.type === 'Bot' && c.body.includes('Mutation Testing Results') - ); - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: body - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: body - }); - } + uses: netresearch/.github/.github/workflows/go-mutation-testing.yml@main + with: + diff-only: ${{ github.event_name == 'pull_request' }} + permissions: + contents: read + pull-requests: write diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index e09cb35f89..56cd320f59 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -5,91 +5,13 @@ on: branches: [main] types: [opened, synchronize, reopened, ready_for_review] -permissions: - contents: read - pull-requests: write +permissions: {} jobs: - quality-gate: - name: Quality Gate - runs-on: ubuntu-latest - if: github.event.pull_request.draft == false - - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: PR Size Check - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const { data: files } = await github.rest.pulls.listFiles({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - }); - const total = files.reduce((sum, f) => sum + f.additions + f.deletions, 0); - console.log(`PR Size: ${total} lines changed`); - if (total > 1000) { - core.warning(`Large PR with ${total} changes. Consider breaking into smaller PRs.`); - } - - auto-approve: - name: Auto-Approve (Solo Maintainer) - runs-on: ubuntu-latest - needs: quality-gate - if: >- - github.event.pull_request.draft == false && - ( - github.event.pull_request.user.login == 'dependabot[bot]' || - github.event.pull_request.user.login == 'renovate[bot]' || - ( - contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) && - github.event.pull_request.head.repo.full_name == github.repository - ) - ) - - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Auto-approve PR - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const author = context.payload.pull_request.user.login; - const knownBots = ['dependabot[bot]', 'renovate[bot]']; - - if (!knownBots.includes(author)) { - // Only call getCollaboratorPermissionLevel for human contributors, not bot accounts - try { - const { data: permLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: author, - }); - const permission = permLevel.permission; - if (!['admin', 'maintain', 'write'].includes(permission)) { - core.info(`Skipping auto-approve: author ${author} has '${permission}' permission (need write+)`); - return; - } - } catch (e) { - core.info(`Skipping auto-approve: could not verify permissions for ${author}: ${e.message}`); - return; - } - } - - await github.rest.pulls.createReview({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - event: 'APPROVE', - body: '**Automated approval for solo maintainer project**\n\nAll CI checks passed. See SECURITY.md for compensating controls.' - }); + quality: + uses: netresearch/.github/.github/workflows/pr-quality.yml@main + with: + auto-approve-maintainers: true + permissions: + contents: read + pull-requests: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffad514584..8b939ee6f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,226 +2,30 @@ name: Release on: push: - tags: - - "v*" + tags: ['v*'] workflow_dispatch: inputs: tag: - description: "Tag to (re)build (e.g. v0.23.2)" + description: "Tag to (re)build." required: true type: string -permissions: - contents: read +permissions: {} jobs: - create-release: - name: Create GitHub Release + create: uses: netresearch/.github/.github/workflows/create-release.yml@main - permissions: - contents: write with: tag: ${{ inputs.tag || '' }} - - binaries: - name: Build ${{ matrix.target }} - needs: create-release - strategy: - fail-fast: false - matrix: - include: - - { target: linux-386, goos: linux, goarch: "386" } - - { target: linux-amd64, goos: linux, goarch: amd64 } - - { target: linux-arm64, goos: linux, goarch: arm64 } - - { target: linux-armv6, goos: linux, goarch: arm, goarm: "6" } - - { target: linux-armv7, goos: linux, goarch: arm, goarm: "7" } - - { target: darwin-amd64, goos: darwin, goarch: amd64 } - - { target: darwin-arm64, goos: darwin, goarch: arm64 } - - { target: windows-amd64, goos: windows, goarch: amd64 } - uses: netresearch/.github/.github/workflows/build-go-attest.yml@main permissions: contents: write - id-token: write - attestations: write - with: - binary-name: ofelia-${{ matrix.target }} - goos: ${{ matrix.goos }} - goarch: ${{ matrix.goarch }} - goarm: ${{ matrix.goarm || '' }} - ldflags: "-s -w -X main.version=${{ needs.create-release.outputs.tag }} -X main.build=${{ needs.create-release.outputs.sha }}" - ref: ${{ needs.create-release.outputs.tag }} - release-tag: ${{ needs.create-release.outputs.tag }} - sbom: true - container: - name: Build container image - needs: [create-release, binaries] - uses: netresearch/.github/.github/workflows/build-container.yml@main - permissions: - contents: read - packages: write - security-events: write - id-token: write - attestations: write + binaries: + needs: create + uses: netresearch/.github/.github/workflows/build-go-attest.yml@main with: - image-name: ofelia - ref: ${{ needs.create-release.outputs.tag }} - platforms: "linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64" - sign: true - attest: true - pre-build-command: | - mkdir -p bin - for suffix in linux-386 linux-amd64 linux-arm64 linux-armv6 linux-armv7; do - gh release download "${{ needs.create-release.outputs.tag }}" \ - --pattern "ofelia-${suffix}" --dir bin - chmod +x "bin/ofelia-${suffix}" - done - - per-arch-tags: - name: Tag per-architecture images - needs: [create-release, container] - runs-on: ubuntu-latest - permissions: - packages: write - env: - IMAGE: ghcr.io/${{ github.repository_owner }}/ofelia - VERSION: ${{ needs.create-release.outputs.version }} - steps: - - name: Harden Runner - uses: step-security/harden-runner@6c3c2f2c1c457b00c10c4848d6f5491db3b629df # v2.18.0 - with: - egress-policy: audit - - - name: Log in to ghcr.io - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Install crane - uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5 - - - name: Tag each platform - run: | - set -euo pipefail - for platform in linux/386 linux/amd64 linux/arm/v6 linux/arm/v7 linux/arm64; do - suffix=$(echo "$platform" | sed 's|linux/||; s|/|-|g') - digest=$(crane manifest "$IMAGE:$VERSION" | jq -r \ - ".manifests[] | select(.platform.os + \"/\" + .platform.architecture + (if .platform.variant then \"/\" + .platform.variant else \"\" end) == \"$platform\") | .digest") - if [ -n "$digest" ] && [ "$digest" != "null" ]; then - echo "Tagging ${IMAGE}:${VERSION}-${suffix} from ${digest}" - crane tag "${IMAGE}@${digest}" "${VERSION}-${suffix}" - crane tag "${IMAGE}@${digest}" "latest-${suffix}" - fi - done - - finalize: - name: Finalize release - needs: [create-release, binaries, container, per-arch-tags] - uses: netresearch/.github/.github/workflows/finalize-release.yml@main + tag: ${{ inputs.tag || github.ref_name }} permissions: contents: write id-token: write attestations: write - with: - tag: ${{ needs.create-release.outputs.tag }} - image-ref: ghcr.io/${{ github.repository_owner }}/ofelia - - notify-released: - name: Notify Released PRs/Issues - needs: [create-release, finalize] - runs-on: ubuntu-latest - permissions: - contents: write - issues: write - pull-requests: write - env: - RELEASE_TAG: ${{ needs.create-release.outputs.tag }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - steps: - - name: Harden Runner - uses: step-security/harden-runner@6c3c2f2c1c457b00c10c4848d6f5491db3b629df # v2.18.0 - with: - egress-policy: audit - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Label and comment on released PRs/Issues - run: | - set -euo pipefail - - # -F for fixed-string match (tags contain dots which are regex metacharacters). - # -x for full-line match, which replaces the old ^...$ anchors. - PREV_TAG=$(gh release list --json tagName --jq '.[].tagName' | grep -Fx -A1 "${RELEASE_TAG}" | tail -1) - if [[ -z "$PREV_TAG" || "$PREV_TAG" == "$RELEASE_TAG" ]]; then - echo "No previous release found, using first commit" - PREV_TAG=$(gh api "repos/${REPO}/commits" --jq '.[0].sha' | head -1) - fi - - echo "Processing PRs between $PREV_TAG and $RELEASE_TAG" - - gh label create "released:${RELEASE_TAG}" \ - --color "0e8a16" \ - --description "Included in ${RELEASE_TAG} release" 2>/dev/null || true - - PR_NUMBERS=$(gh api "repos/${REPO}/compare/${PREV_TAG}...${RELEASE_TAG}" \ - --jq '[.commits[].commit.message | capture("#(?[0-9]+)"; "g") | .n] | unique | .[]' 2>/dev/null || echo "") - - RELEASE_URL="https://github.com/${REPO}/releases/tag/${RELEASE_TAG}" - - for pr in $PR_NUMBERS; do - PR_STATE=$(gh pr view "$pr" --json state --jq '.state' 2>/dev/null || echo "") - if [[ "$PR_STATE" != "MERGED" ]]; then - continue - fi - if gh pr view "$pr" --json labels --jq '.labels[].name' 2>/dev/null | grep -q "^released:${RELEASE_TAG}$"; then - continue - fi - echo "Processing PR #${pr}" - gh pr edit "$pr" --add-label "released:${RELEASE_TAG}" 2>/dev/null || true - { - echo "๐Ÿš€ **Released in [${RELEASE_TAG}](${RELEASE_URL})**" - echo "" - echo "Thank you for your contribution! ๐Ÿ™" - } > /tmp/pr_comment.md - gh pr comment "$pr" --body-file /tmp/pr_comment.md 2>/dev/null || true - - LINKED_ISSUES=$(gh pr view "$pr" --json closingIssuesReferences --jq '.closingIssuesReferences[].number' 2>/dev/null || echo "") - for issue in $LINKED_ISSUES; do - if gh issue view "$issue" --json labels --jq '.labels[].name' 2>/dev/null | grep -q "^released:${RELEASE_TAG}$"; then - continue - fi - echo "Processing Issue #${issue}" - gh issue edit "$issue" --add-label "released:${RELEASE_TAG}" 2>/dev/null || true - { - echo "๐Ÿš€ **Released in [${RELEASE_TAG}](${RELEASE_URL})**" - echo "" - echo "Thank you for reporting this! ๐Ÿ™" - } > /tmp/issue_comment.md - gh issue comment "$issue" --body-file /tmp/issue_comment.md 2>/dev/null || true - done - done - - - name: Append issues link to release notes - run: | - set -euo pipefail - gh release view "$RELEASE_TAG" --json body --jq '.body // ""' > /tmp/notes.md - if grep -q "## Included in this release" /tmp/notes.md; then - exit 0 - fi - FILTER_URL="${{ github.server_url }}/${REPO}/issues?q=label%3Areleased%3A${RELEASE_TAG}+is%3Aclosed" - { - echo "" - echo "---" - echo "" - echo "## Included in this release" - echo "" - echo "[View all PRs and Issues included in this release](${FILTER_URL})" - } >> /tmp/notes.md - gh release edit "$RELEASE_TAG" --notes-file /tmp/notes.md diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index dc78be3e39..e309f18df5 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -4,40 +4,16 @@ on: push: branches: [main] schedule: - # Run weekly on Sunday at midnight - cron: '0 0 * * 0' workflow_dispatch: permissions: read-all jobs: - analysis: - name: Scorecard analysis - runs-on: ubuntu-latest + scorecard: + uses: netresearch/.github/.github/workflows/scorecard.yml@main permissions: + contents: read security-events: write id-token: write - contents: read actions: read - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Run Scorecard analysis - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 - with: - results_file: results.sarif - results_format: sarif - publish_results: true - - - name: Upload Scorecard results to GitHub Security tab - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 - with: - sarif_file: results.sarif