Chapter 34Lesson 02~215 minutes

Notifications, Checks, Commit Status, Pull Request Feedback, Chat Integrations, and Release Communication: Guided Hands-On Workflow and Core Operations

Publish build feedback to a disposable local HTTP sink, preserve delivery evidence, simulate SCM status/check semantics, deduplicate retries, and notify only meaningful state transitions.

hands-onlocal webhookJSONdeduplicationfailure/recoverysafe payload

Learning objectives

  • Run a local notification sink that records event ID, source SHA and build identity.
  • Create a canonical feedback payload without secrets.
  • Publish with bounded retry and verify response state.
  • Simulate success, failure and recovery notifications without external provider credentials.
  • Separate status/check publication from email/chat rendering.

1. Scenario and safe boundaries

Use a disposable Pipeline job jenkins-labs/feedback on agent label feedback-lab. A Python server bound to 127.0.0.1:18080 acts as the external notification endpoint. The sink deliberately stores only safe JSON under a temporary lab directory.

Identity Value Why
Job jenkins-labs/feedback Exact retained Jenkins history.
Agent feedback-lab No routine build on controller.
Sink http://127.0.0.1:18080/events No internet or provider token.
Check name ci/jenkins/feedback-lab Stable simulated external status identity.
Event key job#build:event:state Supports duplicate detection.
Evidence directory feedback-evidence/ Archives request/response without secrets.

2. Create the local notification sink

The sink accepts POSTs, validates a small schema, deduplicates by event_id, and returns an application-level delivery ID. It intentionally ignores authorization headers because the lab requires no secret.

# ci/mock_feedback_sink.py
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import json, hashlib

STORE = Path("feedback-sink")
STORE.mkdir(exist_ok=True)

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/events":
            self.send_error(404)
            return
        try:
            n = int(self.headers.get("Content-Length", "0"))
            payload = json.loads(self.rfile.read(n))
            required = {"event_id", "job_full_name", "build_number", "source_sha", "state", "summary"}
            missing = sorted(required - payload.keys())
            if missing:
                self._json(422, {"accepted": False, "missing": missing})
                return
            if len(str(payload["source_sha"])) != 40:
                self._json(422, {"accepted": False, "error": "source_sha must be full SHA-1 text for this lab"})
                return
            event_id = str(payload["event_id"])
            delivery_id = hashlib.sha256(event_id.encode()).hexdigest()[:16]
            path = STORE / f"{delivery_id}.json"
            duplicate = path.exists()
            if not duplicate:
                path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
            self._json(200, {"accepted": True, "delivery_id": delivery_id, "duplicate": duplicate})
        except Exception as exc:
            self._json(400, {"accepted": False, "error": type(exc).__name__})

    def log_message(self, fmt, *args):
        print("sink", self.address_string(), fmt % args)

    def _json(self, status, obj):
        body = (json.dumps(obj, sort_keys=True) + "\n").encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

ThreadingHTTPServer(("127.0.0.1", 18080), Handler).serve_forever()
set -euo pipefail
mkdir -p ci feedback-evidence
python3 ci/mock_feedback_sink.py >feedback-evidence/sink.log 2>&1 &
echo $! > feedback-evidence/sink.pid
sleep 1
curl --fail --silent --show-error http://127.0.0.1:18080/not-found || true

The final curl should receive 404; that proves the server is reachable without pretending a valid event was accepted.

3. Build a canonical event from explicit Jenkins state

# ci/build_feedback_event.py
import json, os, subprocess, sys
from pathlib import Path

state = sys.argv[1]
allowed = {"success", "failure", "recovery"}
if state not in allowed:
    raise SystemExit(f"state must be one of {sorted(allowed)}")

sha = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
job = os.environ.get("JOB_NAME", "local/feedback")
build = int(os.environ.get("BUILD_NUMBER", "0"))
build_url = os.environ.get("BUILD_URL", "http://jenkins.invalid/local/0/")
event_kind = "quality-result"

event = {
    "schema": "devops-academy.feedback/v1",
    "event_id": f"{job}#{build}:{event_kind}:{state}",
    "job_full_name": job,
    "build_number": build,
    "build_url": build_url,
    "source_sha": sha,
    "change_id": os.environ.get("CHANGE_ID", "none"),
    "check_name": "ci/jenkins/feedback-lab",
    "state": state,
    "summary": {
        "success": "Quality evidence accepted.",
        "failure": "Quality evidence failed; inspect retained Jenkins evidence.",
        "recovery": "Quality evidence recovered after the previous failed build."
    }[state],
    "artifact_digest": os.environ.get("LAB_ARTIFACT_DIGEST", "none"),
}
Path("feedback-evidence/event.json").write_text(json.dumps(event, indent=2, sort_keys=True) + "\n")
print(json.dumps(event, sort_keys=True))

Notice what is absent: no token, no credential ID value that could be confused with a secret, no environment dump and no raw test log.

4. Publish with bounded retry and application-level verification

#!/usr/bin/env bash
# ci/publish_feedback.sh
set -euo pipefail
endpoint="${1:?endpoint required}"
event_file="${2:?event file required}"
response_file="${3:?response file required}"

for attempt in 1 2 3; do
  http_code="$(curl --silent --show-error \
    --output "$response_file" \
    --write-out '%{http_code}' \
    --header 'Content-Type: application/json' \
    --data-binary "@$event_file" \
    "$endpoint" || true)"
  if [ "$http_code" = "200" ] && python3 - "$response_file" <<'PYRESP'
import json, sys
obj=json.load(open(sys.argv[1]))
raise SystemExit(0 if obj.get("accepted") is True and obj.get("delivery_id") else 1)
PYRESP
  then
    printf 'feedback accepted attempt=%s response=%s\n' "$attempt" "$response_file"
    exit 0
  fi
  printf 'feedback attempt=%s failed http=%s\n' "$attempt" "$http_code" >&2
  sleep "$attempt"
done
exit 75

This retry scope repeats only the notification request. It does not rebuild, republish an artifact or rerun deployment. Because the event ID is stable, an accepted first attempt followed by a lost response can be retried safely against this lab sink and recorded as a duplicate rather than a second logical event.

5. Exercise success, duplicate and failure states locally

set -euo pipefail
mkdir -p feedback-evidence
python3 ci/build_feedback_event.py success
bash ci/publish_feedback.sh \
  http://127.0.0.1:18080/events \
  feedback-evidence/event.json \
  feedback-evidence/response-1.json

# Same event again: sink should return duplicate=true.
bash ci/publish_feedback.sh \
  http://127.0.0.1:18080/events \
  feedback-evidence/event.json \
  feedback-evidence/response-2.json

cat feedback-evidence/response-1.json
cat feedback-evidence/response-2.json

The second response should preserve the same delivery ID and report duplicate=true. That is concrete evidence that retry identity is working.

6. Jenkinsfile: feedback is a post-result side effect

pipeline {
  agent { label 'feedback-lab' }
  parameters {
    choice(name: 'SCENARIO', choices: ['success', 'failure'], description: 'Synthetic build outcome')
  }
  options { timestamps(); disableConcurrentBuilds() }
  stages {
    stage('Checkout + identity') {
      steps {
        checkout scm
        sh '''
          set -euo pipefail
          mkdir -p feedback-evidence
          git rev-parse HEAD | tee feedback-evidence/source-sha.txt
          printf 'job=%s\nbuild=%s\nnode=%s\n' \
            "$JOB_NAME" "$BUILD_NUMBER" "$NODE_NAME" | tee feedback-evidence/build.txt
        '''
      }
    }
    stage('Synthetic quality decision') {
      steps {
        script {
          if (params.SCENARIO == 'failure') {
            error('Synthetic quality failure for Chapter 34 lab')
          }
          sh 'printf "quality=success\\n" > feedback-evidence/quality.txt'
        }
      }
    }
  }
  post {
    success {
      sh '''
        set -euo pipefail
        python3 ci/build_feedback_event.py success
        bash ci/publish_feedback.sh http://127.0.0.1:18080/events \
          feedback-evidence/event.json feedback-evidence/delivery.json
      '''
    }
    failure {
      sh '''
        set -euo pipefail
        python3 ci/build_feedback_event.py failure
        bash ci/publish_feedback.sh http://127.0.0.1:18080/events \
          feedback-evidence/event.json feedback-evidence/delivery.json
      '''
    }
    always {
      archiveArtifacts artifacts: 'feedback-evidence/**', allowEmptyArchive: true, fingerprint: true
    }
  }
}

The notification outcome should not erase the original build outcome. In production, decide explicitly whether a feedback-delivery failure should fail the whole Pipeline, mark it unstable, or create a separate operational alert. That policy depends on whether the feedback mechanism is advisory or a required integration.

7. Recovery means a state transition, not merely another success

A useful recovery message is sent when a previously failing signal becomes healthy. It should identify both current build and the relevant prior failure. The mandatory lab simulates this by creating a recovery event after one controlled failed build and one later successful build. In a production design, use Jenkins build history or an external incident/notification record rather than guessing from “last build” under concurrency.

8. Optional provider translations

Internal event field GitHub check/status Email/chat
source_sha Target commit/check-run SHA Include short SHA and repository link.
check_name Check name or status context Human-readable signal name.
state queued/in-progress/completed conclusion or pending/success/failure/error status Success/failure/recovery wording.
build_url details/target URL Primary link back to retained Jenkins evidence.
event_id External ID/update key where supported Deduplication key stored by publisher/sink.
summary Short output/description Redacted actionable message.

Provider APIs differ. Do not mechanically map every internal state to every external enum without current documentation.

9. Challenge: choose the right layer

The Jenkins build failed correctly, but the local sink is down. Should you rerun the whole build? No. Preserve the original build/source evidence, repair or restore the feedback endpoint, and retry only the feedback event with the same ID if your policy permits. Rebuilding could create a different build identity and repeat unrelated side effects.

Next

Choose feedback mechanisms deliberately

Lesson 3 compares checks/statuses, plugins/direct APIs, email/chat, per-stage/final events and failure/recovery strategies.

Knowledge check

Answer before revealing the explanation.

1. Why does the sink return a delivery ID in addition to HTTP 200?

2. Why does the lab use a stable event ID?

3. If the feedback endpoint is unavailable after a failed build, should you rerun tests?

4. What does the local sink simulate accurately?

5. Why archive request and response evidence?

Official references and version notes

Notification plugins and provider APIs change independently. Verify current primary documentation before applying these patterns to a real organization.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.