200 lines
9.2 KiB
YAML
200 lines
9.2 KiB
YAML
name: sampleapp-cicd
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
|
|
jobs:
|
|
ci:
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
REGISTRY: gitea.tests01.helity.org
|
|
IMAGE: gitea.tests01.helity.org/lab/sampleapp
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Lint / validate content
|
|
run: |
|
|
set -e
|
|
test -f index.html
|
|
test -f Dockerfile
|
|
grep -q "<title>" index.html
|
|
grep -q "FROM nginx" Dockerfile
|
|
echo "lint OK"
|
|
|
|
- name: Inject commit SHA into page
|
|
run: |
|
|
set -e
|
|
sed -i "s/__GIT_SHA__/${GITHUB_SHA}/g" index.html
|
|
grep -q "${GITHUB_SHA}" index.html && echo "sha injected: ${GITHUB_SHA}"
|
|
|
|
- name: Build image
|
|
run: |
|
|
set -e
|
|
docker build -t "${IMAGE}:${GITHUB_SHA}" -t "${IMAGE}:latest" .
|
|
|
|
# --- Feature 4: Trivy vulnerability scan (gate) + SBOM ------------------
|
|
# Trivy runs from its official container over the runner's docker.sock, so
|
|
# there is no fragile binary install and it can scan the locally-built
|
|
# image. A shared cache + shared workdir are bind-mounted; the SBOM is
|
|
# written to the job workspace so the upload step can find it. A single
|
|
# `docker run` per action keeps the runner-in-runner path simple.
|
|
- name: Trivy scan (gate CRITICAL,HIGH)
|
|
run: |
|
|
set -e
|
|
# 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.
|
|
docker run --rm \
|
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
-v trivy-cache:/root/.cache/ \
|
|
aquasec/trivy:0.58.1 image \
|
|
--exit-code 1 \
|
|
--severity CRITICAL,HIGH \
|
|
--ignore-unfixed \
|
|
--scanners vuln \
|
|
--no-progress \
|
|
"${IMAGE}:${GITHUB_SHA}"
|
|
echo "trivy gate passed (no fixable CRITICAL/HIGH)"
|
|
|
|
- name: Generate SBOM (CycloneDX)
|
|
if: always()
|
|
run: |
|
|
set -e
|
|
# Write the SBOM to trivy's stdout and redirect it into the job
|
|
# workspace. A bind-mounted --output would land on the HOST path
|
|
# (docker.sock is the host daemon, so a sibling container's -v resolves
|
|
# host-side, not inside this job container); the stdout redirect writes
|
|
# straight into the job filesystem where the upload step can find it.
|
|
docker run --rm \
|
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
-v trivy-cache:/root/.cache/ \
|
|
aquasec/trivy:0.58.1 image \
|
|
--format cyclonedx \
|
|
--scanners vuln \
|
|
--no-progress \
|
|
"${IMAGE}:${GITHUB_SHA}" > sbom.cdx.json
|
|
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
|
|
|
|
# Gitea's Actions artifact backend rejects the upload-artifact@v4 protocol
|
|
# (GHESNotSupportedError), so publish the SBOM to the Gitea generic package
|
|
# registry instead — a real, versioned, downloadable artifact retrievable
|
|
# at /api/packages/lab/generic/sampleapp-sbom/<sha>/sbom.cdx.json. Also
|
|
# echo a one-line digest into the job log as a always-available fallback.
|
|
- name: Publish SBOM to Gitea package registry
|
|
if: always()
|
|
env:
|
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
run: |
|
|
set -e
|
|
if [ ! -s sbom.cdx.json ]; then
|
|
echo "no sbom.cdx.json produced — skipping upload"
|
|
exit 0
|
|
fi
|
|
echo "SBOM sha256: $(sha256sum sbom.cdx.json | awk '{print $1}') bytes: $(wc -c < sbom.cdx.json)"
|
|
code="$(curl -sS -o /tmp/sbom_up.txt -w '%{http_code}' \
|
|
-u "${REGISTRY_USER}:${REGISTRY_TOKEN}" \
|
|
-X PUT \
|
|
--upload-file sbom.cdx.json \
|
|
"https://gitea.tests01.helity.org/api/packages/lab/generic/sampleapp-sbom/${GITHUB_SHA}/sbom.cdx.json")"
|
|
echo "package registry upload HTTP ${code}"
|
|
# 201 created / 200 exists — 409 (already uploaded for this sha) is fine
|
|
case "$code" in 200|201|409) echo "SBOM published for ${GITHUB_SHA}";; *) cat /tmp/sbom_up.txt; exit 1;; esac
|
|
|
|
# --- Push (main only) ---------------------------------------------------
|
|
- name: Push image (main only)
|
|
if: github.ref == 'refs/heads/main'
|
|
run: |
|
|
set -e
|
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${REGISTRY}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
|
docker push "${IMAGE}:${GITHUB_SHA}"
|
|
docker push "${IMAGE}:latest"
|
|
docker logout "${REGISTRY}"
|
|
|
|
deploy:
|
|
runs-on: ubuntu-latest
|
|
needs: ci
|
|
if: github.ref == 'refs/heads/main'
|
|
steps:
|
|
- name: Trigger Rundeck deploy job
|
|
run: |
|
|
set -e
|
|
code=$(curl -fsS -o /tmp/rd.json -w '%{http_code}' -X POST \
|
|
-H "X-Rundeck-Auth-Token: ${{ secrets.RUNDECK_TOKEN }}" \
|
|
-H "Content-Type: application/json" \
|
|
-H "Accept: application/json" \
|
|
"${{ secrets.RUNDECK_URL }}/api/40/job/${{ secrets.DEPLOY_JOB_UUID }}/run" \
|
|
--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" ] |