diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d4e678c..a283f73 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -2,7 +2,7 @@ name: sampleapp-cicd on: push: - branches: ["**"] + branches: [main] pull_request: jobs: @@ -35,6 +35,58 @@ jobs: set -e docker build -t "${IMAGE}:${GITHUB_SHA}" -t "${IMAGE}:latest" . + # --- Feature 4: Trivy vulnerability scan (gate) -------------------------- + # Install the Trivy binary from the official script (the runner reaches the + # internet; a pinned binary is more reproducible here than the + # trivy-action, which resolves from github.com/master). The scan runs + # against the locally-built image over the runner's docker.sock. + - name: Install Trivy + run: | + set -e + curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \ + | sh -s -- -b /usr/local/bin v0.58.1 + trivy --version + + # Gate: fail ci on any fixable CRITICAL/HIGH so a vulnerable image never + # gets pushed or deployed. ignore-unfixed keeps the gate real but avoids + # failing on distro CVEs with no available fix (nginx:alpine currently has + # zero fixable CRITICAL/HIGH — the gate passes clean but is genuinely + # enforcing). --scanners vuln keeps it to CVEs (no secret/misconfig noise). + - name: Trivy scan (gate CRITICAL,HIGH) + run: | + set -e + trivy image \ + --exit-code 1 \ + --severity CRITICAL,HIGH \ + --ignore-unfixed \ + --scanners vuln \ + --no-progress \ + "${IMAGE}:${GITHUB_SHA}" + echo "trivy gate passed (no fixable CRITICAL/HIGH)" + + # --- Feature 4: SBOM (CycloneDX) generation + artifact upload ----------- + - name: Generate SBOM (CycloneDX) + if: always() + run: | + set -e + trivy image \ + --format cyclonedx \ + --scanners vuln \ + --no-progress \ + --output sbom.cdx.json \ + "${IMAGE}:${GITHUB_SHA}" + echo "SBOM components: $(python3 -c 'import json; print(len(json.load(open("sbom.cdx.json")).get("components",[])))' 2>/dev/null || echo n/a)" + ls -l sbom.cdx.json + + - name: Upload SBOM artifact + if: always() + uses: https://gitea.com/actions/upload-artifact@v4 + with: + name: sbom-cyclonedx-${{ github.sha }} + path: sbom.cdx.json + if-no-files-found: warn + + # --- Push (main only) --------------------------------------------------- - name: Push image (main only) if: github.ref == 'refs/heads/main' run: | @@ -60,3 +112,68 @@ jobs: --data "{\"options\":{\"image_tag\":\"${GITHUB_SHA}\"}}") echo "Rundeck run HTTP ${code}" python3 -c "import json;d=json.load(open('/tmp/rd.json'));print('execution id:',d.get('id'),'status:',d.get('status'),'permalink:',d.get('permalink'))" + + # --- Feature 2: notifications (Mattermost success+failure, GoAlert on failure) + notify: + runs-on: ubuntu-latest + needs: [ci, deploy] + if: always() + env: + MM_HOOK: ${{ secrets.MATTERMOST_WEBHOOK_URL }} + GA_KEY: ${{ secrets.GOALERT_KEY_URL }} + steps: + - name: Compute pipeline outcome + id: outcome + run: | + set -e + CI="${{ needs.ci.result }}" + DEPLOY="${{ needs.deploy.result }}" + # deploy is skipped on non-main (PR) runs; treat skipped as non-failing. + if [ "$CI" = "success" ] && { [ "$DEPLOY" = "success" ] || [ "$DEPLOY" = "skipped" ]; }; then + echo "state=success" >> "$GITHUB_OUTPUT" + echo "emoji=:white_check_mark:" >> "$GITHUB_OUTPUT" + else + echo "state=failure" >> "$GITHUB_OUTPUT" + echo "emoji=:rotating_light:" >> "$GITHUB_OUTPUT" + fi + + - name: Notify Mattermost (success or failure) + env: + STATE: ${{ steps.outcome.outputs.state }} + EMOJI: ${{ steps.outcome.outputs.emoji }} + CI_RESULT: ${{ needs.ci.result }} + DEPLOY_RESULT: ${{ needs.deploy.result }} + REPO: ${{ github.repository }} + REF: ${{ github.ref_name }} + SHA: ${{ github.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }} + run: | + set -e + # Build the text + JSON payload with python (env in, JSON out) so no + # shell quoting of the message can break the webhook body. The webhook + # is locked to the deployments channel, so no channel field is sent. + # Single-line python keeps every line inside the YAML block scalar. + payload="$(python3 -c 'import json,os; e=os.environ; t="%s **sampleapp-cicd %s** on `%s@%s`\nci=`%s` deploy=`%s`\ncommit `%s`\n%s" % (e["EMOJI"], e["STATE"], e["REPO"], e["REF"], e["CI_RESULT"], e["DEPLOY_RESULT"], e["SHA"], e["RUN_URL"]); print(json.dumps({"text": t}))')" + code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ + -H "Content-Type: application/json" -d "$payload" "$MM_HOOK")" + echo "mattermost webhook HTTP ${code}" + [ "$code" = "200" ] + + - name: Notify GoAlert (failure only) + if: steps.outcome.outputs.state == 'failure' + env: + CI_RESULT: ${{ needs.ci.result }} + DEPLOY_RESULT: ${{ needs.deploy.result }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }} + run: | + set -e + # GoAlert generic API: summary via query param, details via JSON body, + # dedup collapses repeated failures onto one alert. Success = HTTP 204. + url="$(python3 -c 'import os,urllib.parse; e=os.environ; base=e["GA_KEY"]; sep="&" if "?" in base else "?"; q=urllib.parse.urlencode({"summary":"CI/CD FAILED: %s (ci=%s deploy=%s)" % (e["REPO"], e["CI_RESULT"], e["DEPLOY_RESULT"]), "dedup":"sampleapp-cicd-failure"}); print(base+sep+q)')" + body="$(python3 -c 'import json,os; print(json.dumps({"details":"commit %s - %s" % (os.environ["SHA"], os.environ["RUN_URL"]), "action":"trigger"}))')" + code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ + -H "Content-Type: application/json" -d "$body" "$url")" + echo "goalert generic HTTP ${code}" + [ "$code" = "204" ] || [ "$code" = "200" ] \ No newline at end of file