From e244c89eefe3b7470203653f4c8ee855c6f67386 Mon Sep 17 00:00:00 2001 From: igorsatsyuk Date: Sun, 31 May 2026 17:39:44 +0300 Subject: [PATCH 1/7] chore(ci): align workflow structure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/compose_telegram_message.py | 93 ++++++ .github/scripts/sonar_quality_gate_report.py | 298 +++++++++++++++++++ .github/workflows/ci.yml | 292 ++++++++++-------- 3 files changed, 553 insertions(+), 130 deletions(-) create mode 100644 .github/scripts/compose_telegram_message.py create mode 100644 .github/scripts/sonar_quality_gate_report.py diff --git a/.github/scripts/compose_telegram_message.py b/.github/scripts/compose_telegram_message.py new file mode 100644 index 0000000..0647c17 --- /dev/null +++ b/.github/scripts/compose_telegram_message.py @@ -0,0 +1,93 @@ +import html +import os + +FAILURE_STATES = {"failure", "cancelled", "timed_out", "action_required"} +SUCCESS_STATES = {"success", "skipped", "neutral"} + +JOB_RESULT_FIELDS = ( + ("backend", "BACKEND_RESULT"), + ("sonarqube-backend", "SONAR_BACKEND_RESULT"), +) + +SONAR_SKIP_FLAGS = { + "SONAR_BACKEND_RESULT": "SONAR_BACKEND_SKIPPED", +} + + +def normalize(value: str) -> str: + return (value or "unknown").lower() + + +def status_icon(status: str) -> str: + if status == "success": + return "✅" + if status in FAILURE_STATES: + return "❌" + if status in {"skipped", "neutral"}: + return "⏭️" + return "❓" + + +def get_actual_status(env_name: str, result: str) -> str: + skip_flag = SONAR_SKIP_FLAGS.get(env_name) + if skip_flag and normalize(os.environ.get(skip_flag, "")) == "true": + return "skipped" + return result + + +def overall_status() -> str: + statuses = [] + for _, env_name in JOB_RESULT_FIELDS: + result = normalize(os.environ.get(env_name, "unknown")) + actual = get_actual_status(env_name, result) + statuses.append(actual) + + if any(status in FAILURE_STATES for status in statuses): + return "failure" + if all(status in SUCCESS_STATES for status in statuses): + return "success" + return "unknown" + + +def esc(value: str) -> str: + return html.escape(str(value), quote=True) + + +def compose_message() -> str: + current_overall = overall_status() + lines = [ + "jwt-demo-reactive CI finished", + "", + f"{status_icon(current_overall)} Status: {esc(current_overall)}", + f"Branch: {esc(os.environ.get('GITHUB_REF_NAME', ''))}", + f"Commit: {esc(os.environ.get('GITHUB_SHA', ''))}", + f"Actor: {esc(os.environ.get('GITHUB_ACTOR', ''))}", + f"Workflow: {esc(os.environ.get('GITHUB_WORKFLOW', ''))}", + "", + "Job results", + ] + + for job_name, env_name in JOB_RESULT_FIELDS: + result = normalize(os.environ.get(env_name, "unknown")) + actual = get_actual_status(env_name, result) + lines.append(f"- {status_icon(actual)} {esc(job_name)}: {esc(actual)}") + + lines.append("") + lines.append( + f"Link: {esc(os.environ.get('GITHUB_SERVER_URL', ''))}/{esc(os.environ.get('GITHUB_REPOSITORY', ''))}/actions/runs/{esc(os.environ.get('GITHUB_RUN_ID', ''))}" + ) + + return "\n".join(lines) + + +def main() -> None: + message = compose_message() + output_path = os.environ["GITHUB_OUTPUT"] + with open(output_path, "a", encoding="utf-8") as output_file: + output_file.write("message< dict[str, str]: + data: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + data[key.strip()] = value.strip() + return data + + +def api_get_json(url: str, token: str) -> dict: + request = urllib.request.Request(url) + request.add_header("Authorization", f"Basic {build_basic_auth(token)}") + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + response_body = error.read().decode("utf-8", errors="replace") + raise RuntimeError( + f"Sonar API request failed: {url} returned HTTP {error.code}. Response: {response_body}" + ) from error + except urllib.error.URLError as error: + raise RuntimeError(f"Sonar API request failed: {url}. Reason: {error.reason}") from error + + +def build_basic_auth(token: str) -> str: + import base64 + + raw = f"{token}:".encode("utf-8") + return base64.b64encode(raw).decode("ascii") + + +def wait_for_ce_task(ce_task_url: str, token: str, timeout_seconds: int, poll_seconds: int) -> dict: + started = time.monotonic() + while True: + payload = api_get_json(ce_task_url, token) + task = payload.get("task", {}) + status = task.get("status") + + if status in {"SUCCESS", "FAILED", "CANCELED"}: + return task + + if (time.monotonic() - started) > timeout_seconds: + raise TimeoutError(f"Timed out waiting for Sonar CE task at {ce_task_url}") + + time.sleep(poll_seconds) + + +def build_measures_url(host_url: str, project_key: str, pull_request: str | None, branch: str | None) -> str: + metric_keys = ",".join( + [ + "coverage", + "new_coverage", + "bugs", + "new_bugs", + "vulnerabilities", + "new_vulnerabilities", + "code_smells", + "new_code_smells", + "duplicated_lines_density", + "new_duplicated_lines_density", + ] + ) + query = { + "component": project_key, + "metricKeys": metric_keys, + } + if pull_request: + query["pullRequest"] = pull_request + elif branch: + query["branch"] = branch + return f"{host_url}/api/measures/component?{urllib.parse.urlencode(query)}" + + +def detect_analysis_scope() -> tuple[str | None, str | None]: + event_path = os.getenv("GITHUB_EVENT_PATH", "") + if event_path: + try: + event_payload = json.loads(Path(event_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + event_payload = {} + pull_request_number = event_payload.get("pull_request", {}).get("number") + if pull_request_number is not None: + return str(pull_request_number), None + + branch = (os.getenv("GITHUB_HEAD_REF") or os.getenv("GITHUB_REF_NAME") or "").strip() + return None, branch or None + + +def to_measure_map(payload: dict) -> dict[str, str]: + component = payload.get("component", {}) + result: dict[str, str] = {} + for measure in component.get("measures", []): + result[measure.get("metric")] = measure.get("value", "-") + return result + + +def fetch_measures(host_url: str, project_key: str, token: str) -> dict[str, str]: + pull_request, branch = detect_analysis_scope() + scoped_url = build_measures_url(host_url, project_key, pull_request, branch) + + try: + return to_measure_map(api_get_json(scoped_url, token)) + except RuntimeError as scoped_error: + base_url = build_measures_url(host_url, project_key, None, None) + if base_url != scoped_url: + try: + print( + "Scoped measures query failed; retrying without branch/pullRequest context", + file=sys.stderr, + ) + return to_measure_map(api_get_json(base_url, token)) + except RuntimeError as base_error: + print(f"Unable to load Sonar measures: {base_error}", file=sys.stderr) + return {} + + print(f"Unable to load Sonar measures: {scoped_error}", file=sys.stderr) + return {} + + +def append_summary(text: str) -> None: + summary_file = os.getenv("GITHUB_STEP_SUMMARY") + if not summary_file: + return + with open(summary_file, "a", encoding="utf-8") as handle: + handle.write(text) + + +def is_missing_new_code_metrics_only(gate_status: str, conditions: list[dict], measures: dict[str, str]) -> bool: + if gate_status != "ERROR": + return False + + if measures.get("new_coverage", "-") not in {"-", "NO_VALUE", ""}: + return False + + if not conditions: + return False + + has_non_ok_condition = False + for condition in conditions: + status = (condition.get("status") or "").upper() + if status in {"", "OK"}: + continue + + has_non_ok_condition = True + metric_key = condition.get("metricKey", "") + actual_value = condition.get("actualValue") + normalized_actual = "-" if actual_value in (None, "") else str(actual_value) + + if not metric_key.startswith("new_"): + return False + if normalized_actual not in {"-", "NO_VALUE", "None"}: + return False + + return has_non_ok_condition + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--project-key", required=True) + parser.add_argument("--component-name", required=True) + parser.add_argument("--report-task-file", required=True) + parser.add_argument("--timeout-seconds", type=int, default=300) + parser.add_argument("--poll-seconds", type=int, default=5) + parser.add_argument( + "--allow-missing-new-code-metrics", + action="store_true", + help="Do not fail when Sonar returns ERROR only because new_* metrics are unavailable", + ) + args = parser.parse_args() + + token = os.getenv("SONAR_TOKEN", "") + if not token: + print("SONAR_TOKEN is missing", file=sys.stderr) + return 2 + + configured_host_url = (os.getenv("SONAR_HOST_URL") or "").strip() + report_task_path = Path(args.report_task_file) + if not report_task_path.exists(): + print(f"report-task.txt not found: {report_task_path}", file=sys.stderr) + return 2 + + report = parse_kv_file(report_task_path) + report_server_url = (report.get("serverUrl") or "").strip() + host_url = (configured_host_url or report_server_url or "https://sonarcloud.io").rstrip("/") + ce_task_url = report.get("ceTaskUrl") + if not ce_task_url: + print("ceTaskUrl is missing in report-task.txt", file=sys.stderr) + return 2 + + try: + print(f"Waiting for Sonar CE task: {ce_task_url}") + task = wait_for_ce_task(ce_task_url, token, args.timeout_seconds, args.poll_seconds) + task_status = task.get("status", "UNKNOWN") + analysis_id = task.get("analysisId") + + if task_status != "SUCCESS" or not analysis_id: + print(f"Sonar CE task status is {task_status}; analysisId={analysis_id}", file=sys.stderr) + return 1 + + qg_url = f"{host_url}/api/qualitygates/project_status?analysisId={urllib.parse.quote(analysis_id)}" + qg_payload = api_get_json(qg_url, token) + project_status = qg_payload.get("projectStatus", {}) + gate_status = project_status.get("status", "NONE") + conditions = project_status.get("conditions", []) + + measures = fetch_measures(host_url, args.project_key, token) + except RuntimeError as error: + print(str(error), file=sys.stderr) + return 1 + + print(f"Quality Gate ({args.component_name}): {gate_status}") + print("Measures:") + for metric_key in [ + "coverage", + "new_coverage", + "bugs", + "new_bugs", + "vulnerabilities", + "new_vulnerabilities", + "code_smells", + "new_code_smells", + "duplicated_lines_density", + "new_duplicated_lines_density", + ]: + print(f" {metric_key}: {measures.get(metric_key, '-')}") + + summary_lines = [ + f"### SonarQube - {args.component_name}", + "", + f"- Quality Gate: **{gate_status}**", + f"- Project key: `{args.project_key}`", + "", + "| Metric | Value |", + "|---|---:|", + ] + for metric_key in [ + "coverage", + "new_coverage", + "bugs", + "new_bugs", + "vulnerabilities", + "new_vulnerabilities", + "code_smells", + "new_code_smells", + "duplicated_lines_density", + "new_duplicated_lines_density", + ]: + summary_lines.append(f"| `{metric_key}` | {measures.get(metric_key, '-')} |") + + summary_lines.extend(["", "| Condition metric | Status | Actual | Threshold |", "|---|---|---:|---:|"]) + if conditions: + for condition in conditions: + summary_lines.append( + "| `{}` | {} | {} | {} |".format( + condition.get("metricKey", "-"), + condition.get("status", "-"), + condition.get("actualValue", "-"), + condition.get("errorThreshold", "-"), + ) + ) + else: + summary_lines.append("| `-` | - | - | - |") + + summary_lines.append("\n") + append_summary("\n".join(summary_lines)) + + if gate_status != "OK": + if args.allow_missing_new_code_metrics and is_missing_new_code_metrics_only(gate_status, conditions, measures): + print( + "Quality Gate returned ERROR due to unavailable new-code metrics; treated as neutral for this run" + ) + return 0 + print("Quality Gate failed", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3f92c1..52e0fdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,165 +1,197 @@ name: CI -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - on: push: - branches: ["**"] + branches: [ main ] pull_request: permissions: contents: read - checks: write - actions: write - pull-requests: write jobs: - build: + backend: + name: Backend (Maven) runs-on: ubuntu-latest + steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 + - uses: actions/checkout@v6 - name: Set up JDK 25 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@v5 with: distribution: temurin - java-version: "25" + java-version: 25 cache: maven - - name: Cache SonarQube packages - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.sonar/cache - key: ${{ runner.os }}-sonar - restore-keys: ${{ runner.os }}-sonar - - - name: Build and verify + - name: Run backend tests run: mvn -B -ntp verify - - name: Upload Unit Test Reports + - name: Upload backend JaCoCo report if: always() uses: actions/upload-artifact@v7 with: - name: unit-test-reports - path: target/surefire-reports/TEST-*.xml + name: backend-jacoco-report + path: target/site/jacoco-merged/jacoco.xml if-no-files-found: warn retention-days: 7 - - name: Upload Integration Test Reports - if: always() - uses: actions/upload-artifact@v7 + sonar-backend: + name: SonarQube - backend + runs-on: ubuntu-latest + needs: [backend] + if: > + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'pull_request' && github.actor != 'dependabot[bot]')) + outputs: + sonar_skipped: ${{ steps.sonar-check.outputs.sonar_skipped }} + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }} + SONAR_ORGANIZATION: ${{ vars.SONAR_ORGANIZATION }} + + steps: + - uses: actions/checkout@v6 with: - name: integration-test-reports - path: target/failsafe-reports/TEST-*.xml - if-no-files-found: warn - retention-days: 7 + fetch-depth: 0 - - name: Test Reports Summary (Actions UI) - if: always() - shell: bash + - name: Check Sonar configuration + id: sonar-check run: | - unit_count=$(find target/surefire-reports -maxdepth 1 -name 'TEST-*.xml' 2>/dev/null | wc -l) - it_count=$(find target/failsafe-reports -maxdepth 1 -name 'TEST-*.xml' 2>/dev/null | wc -l) - { - echo "## Test Reports" - echo "- Unit JUnit XML files: ${unit_count} (artifact: unit-test-reports)" - echo "- Integration JUnit XML files: ${it_count} (artifact: integration-test-reports)" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Unit Test Summary - if: always() - id: unit - uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 + if [ -z "$SONAR_TOKEN" ]; then + echo "sonar_skipped=true" >> $GITHUB_OUTPUT + echo "SONAR_TOKEN not set; skipping backend analysis" + exit 0 + fi + host_url="${SONAR_HOST_URL:-https://sonarcloud.io}" + if [ "$host_url" = "https://sonarcloud.io" ] && [ -z "$SONAR_ORGANIZATION" ]; then + echo "sonar_skipped=true" >> $GITHUB_OUTPUT + echo "SONAR_ORGANIZATION not set for SonarCloud; skipping backend analysis" + exit 0 + fi + echo "sonar_skipped=false" >> $GITHUB_OUTPUT + echo "sonar_enabled=true" >> $GITHUB_OUTPUT + + - name: Set up JDK 25 + if: steps.sonar-check.outputs.sonar_enabled == 'true' + uses: actions/setup-java@v5 with: - token: ${{ secrets.GITHUB_TOKEN }} - name: Unit Tests (JUnit) - path: target/surefire-reports/TEST-*.xml - reporter: java-junit - fail-on-error: false - fail-on-empty: false - use-actions-summary: true - - - name: Integration Test Summary - if: always() - id: integration - uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 + distribution: temurin + java-version: 25 + cache: maven + + - name: Download backend JaCoCo report + if: steps.sonar-check.outputs.sonar_enabled == 'true' + continue-on-error: true + uses: actions/download-artifact@v8 with: - token: ${{ secrets.GITHUB_TOKEN }} - name: Integration Tests (JUnit) - path: target/failsafe-reports/TEST-*.xml - reporter: java-junit - fail-on-error: false - fail-on-empty: false - use-actions-summary: true - - # ----------------------------- - # Telegram notification - # ----------------------------- + name: backend-jacoco-report + path: coverage + + - name: Run SonarQube analysis (backend) + if: steps.sonar-check.outputs.sonar_enabled == 'true' + shell: bash + env: + REPO_NAME: ${{ github.event.repository.name }} + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_REF: ${{ github.head_ref }} + PR_BASE_REF: ${{ github.base_ref }} + run: | + host_url="${SONAR_HOST_URL:-https://sonarcloud.io}" + report_path="" + if [ -f "coverage/jacoco.xml" ]; then + report_path="$(pwd)/coverage/jacoco.xml" + fi + cmd=( + mvn -B -ntp + -Djacoco.skip=true + -DskipTests + verify + sonar:sonar + "-Dsonar.host.url=${host_url}" + "-Dsonar.projectKey=${REPO_NAME}" + "-Dsonar.token=${SONAR_TOKEN}" + ) + + if [ -n "${report_path}" ]; then + cmd+=("-Dsonar.coverage.jacoco.xmlReportPaths=${report_path}") + else + echo "JaCoCo XML report not found; Sonar will run without coverage input" + fi + + if [ -n "${SONAR_ORGANIZATION}" ]; then + cmd+=("-Dsonar.organization=${SONAR_ORGANIZATION}") + fi + + if [ "${EVENT_NAME}" = "pull_request" ] && [ -n "${PR_NUMBER}" ] && [ -n "${PR_HEAD_REF}" ] && [ -n "${PR_BASE_REF}" ]; then + cmd+=("-Dsonar.pullrequest.key=${PR_NUMBER}") + cmd+=("-Dsonar.pullrequest.branch=${PR_HEAD_REF}") + cmd+=("-Dsonar.pullrequest.base=${PR_BASE_REF}") + fi + + "${cmd[@]}" + + - name: Locate backend Sonar report-task + if: steps.sonar-check.outputs.sonar_enabled == 'true' + id: report-task + shell: bash + run: | + report_task=$(find . -type f \( -path "*/target/sonar/report-task.txt" -o -path "*/.scannerwork/report-task.txt" \) | head -1) + if [ -z "$report_task" ]; then + echo "Unable to find Sonar report-task.txt" >&2 + exit 1 + fi + echo "path=${report_task}" >> "$GITHUB_OUTPUT" + + - name: Wait for Quality Gate and publish backend Sonar summary + if: steps.sonar-check.outputs.sonar_enabled == 'true' + shell: bash + env: + REPO_NAME: ${{ github.event.repository.name }} + run: | + extra_args=() + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + extra_args+=("--allow-missing-new-code-metrics") + fi + + python3 .github/scripts/sonar_quality_gate_report.py \ + --component-name "backend" \ + --project-key "${REPO_NAME}" \ + --report-task-file "${{ steps.report-task.outputs.path }}" \ + --timeout-seconds 900 \ + "${extra_args[@]}" + + notify-telegram: + name: Notify Telegram + runs-on: ubuntu-latest + needs: [backend, sonar-backend] + if: ${{ always() }} + permissions: + contents: read + env: + TELEGRAM_TO: ${{ secrets.TELEGRAM_TO }} + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + + steps: + - uses: actions/checkout@v6 + + - name: Compose Telegram message + id: compose + run: python3 .github/scripts/compose_telegram_message.py + env: + BACKEND_RESULT: ${{ needs.backend.result }} + SONAR_BACKEND_RESULT: ${{ needs.sonar-backend.result }} + SONAR_BACKEND_SKIPPED: ${{ needs.sonar-backend.outputs.sonar_skipped }} + - name: Notify Telegram - if: always() - uses: appleboy/telegram-action@master + if: ${{ env.TELEGRAM_TO != '' && env.TELEGRAM_TOKEN != '' }} + uses: appleboy/telegram-action@v1.0.1 with: to: ${{ secrets.TELEGRAM_TO }} token: ${{ secrets.TELEGRAM_TOKEN }} - message: | - 🚀 jwt-demo-reactive CI завершён - - *Статус:* ${{ job.status }} - *Бранч:* ${{ github.ref_name }} - *Коммит:* ${{ github.sha }} - *Автор:* ${{ github.actor }} - - 🧪 *Unit Tests* - - Total: ${{ steps.unit.outputs.total }} - - Passed: ${{ steps.unit.outputs.passed }} - - Failed: ${{ steps.unit.outputs.failed }} - - Skipped: ${{ steps.unit.outputs.skipped }} - - 🔧 *Integration Tests* - - Total: ${{ steps.integration.outputs.total }} - - Passed: ${{ steps.integration.outputs.passed }} - - Failed: ${{ steps.integration.outputs.failed }} - - Skipped: ${{ steps.integration.outputs.skipped }} - format: markdown - - # ----------------------------- - # PR Comment with test results - # ----------------------------- - - name: Post Test Summary as PR Comment - if: github.event_name == 'pull_request' - uses: marocchino/sticky-pull-request-comment@v3 - with: - header: "Test Results" - message: | - ## 🧪 Test Results - - ### Unit Tests - - Total: ${{ steps.unit.outputs.total }} - - Passed: ${{ steps.unit.outputs.passed }} - - Failed: ${{ steps.unit.outputs.failed }} - - Skipped: ${{ steps.unit.outputs.skipped }} - - ### Integration Tests - - Total: ${{ steps.integration.outputs.total }} - - Passed: ${{ steps.integration.outputs.passed }} - - Failed: ${{ steps.integration.outputs.failed }} - - Skipped: ${{ steps.integration.outputs.skipped }} - - - name: SonarCloud analysis - if: > - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'pull_request' && github.actor != 'dependabot[bot]') - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY }} - SONAR_ORGANIZATION: ${{ vars.SONAR_ORGANIZATION }} - run: >- - mvn -B -ntp sonar:sonar - -Dsonar.host.url=https://sonarcloud.io - -Dsonar.projectKey=${SONAR_PROJECT_KEY} - -Dsonar.organization=${SONAR_ORGANIZATION} - -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco-merged/jacoco.xml \ No newline at end of file + message: ${{ steps.compose.outputs.message }} + format: html + + - name: Telegram skipped (missing secrets) + if: ${{ env.TELEGRAM_TO == '' || env.TELEGRAM_TOKEN == '' }} + run: echo "TELEGRAM_TO and/or TELEGRAM_TOKEN are not set. Skipping Telegram notification." \ No newline at end of file From ffc9334e9a330a1ab817a6c93176c535eb07bbf4 Mon Sep 17 00:00:00 2001 From: igorsatsyuk Date: Sun, 31 May 2026 17:46:21 +0300 Subject: [PATCH 2/7] fix(ci): address copilot review comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52e0fdb..e4c98db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,12 @@ name: CI on: push: - branches: [ main ] + branches: ["**"] pull_request: permissions: contents: read + actions: write jobs: backend: @@ -14,10 +15,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up JDK 25 - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: temurin java-version: 25 @@ -50,7 +51,7 @@ jobs: SONAR_ORGANIZATION: ${{ vars.SONAR_ORGANIZATION }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -73,7 +74,7 @@ jobs: - name: Set up JDK 25 if: steps.sonar-check.outputs.sonar_enabled == 'true' - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: temurin java-version: 25 @@ -99,8 +100,9 @@ jobs: run: | host_url="${SONAR_HOST_URL:-https://sonarcloud.io}" report_path="" - if [ -f "coverage/jacoco.xml" ]; then - report_path="$(pwd)/coverage/jacoco.xml" + found_report=$(find coverage -type f -name "jacoco.xml" | head -1 || true) + if [ -n "${found_report}" ]; then + report_path="$(pwd)/${found_report}" fi cmd=( mvn -B -ntp @@ -173,7 +175,7 @@ jobs: TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Compose Telegram message id: compose From 912f2bb728389b2eb2916ebd71d35a1ebdf1c4c1 Mon Sep 17 00:00:00 2001 From: igorsatsyuk Date: Sun, 31 May 2026 17:54:26 +0300 Subject: [PATCH 3/7] fix(ci): address copilot follow-up comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/compose_telegram_message.py | 114 ++++++++++---------- .github/workflows/ci.yml | 16 +-- 2 files changed, 67 insertions(+), 63 deletions(-) diff --git a/.github/scripts/compose_telegram_message.py b/.github/scripts/compose_telegram_message.py index 0647c17..5be6359 100644 --- a/.github/scripts/compose_telegram_message.py +++ b/.github/scripts/compose_telegram_message.py @@ -5,89 +5,89 @@ SUCCESS_STATES = {"success", "skipped", "neutral"} JOB_RESULT_FIELDS = ( - ("backend", "BACKEND_RESULT"), - ("sonarqube-backend", "SONAR_BACKEND_RESULT"), + ("backend", "BACKEND_RESULT"), + ("sonarqube-backend", "SONAR_BACKEND_RESULT"), ) SONAR_SKIP_FLAGS = { - "SONAR_BACKEND_RESULT": "SONAR_BACKEND_SKIPPED", + "SONAR_BACKEND_RESULT": "SONAR_BACKEND_SKIPPED", } def normalize(value: str) -> str: - return (value or "unknown").lower() + return (value or "unknown").lower() def status_icon(status: str) -> str: - if status == "success": - return "✅" - if status in FAILURE_STATES: - return "❌" - if status in {"skipped", "neutral"}: - return "⏭️" - return "❓" + if status == "success": + return "✅" + if status in FAILURE_STATES: + return "❌" + if status in {"skipped", "neutral"}: + return "⏭️" + return "❓" def get_actual_status(env_name: str, result: str) -> str: - skip_flag = SONAR_SKIP_FLAGS.get(env_name) - if skip_flag and normalize(os.environ.get(skip_flag, "")) == "true": - return "skipped" - return result + skip_flag = SONAR_SKIP_FLAGS.get(env_name) + if skip_flag and normalize(os.environ.get(skip_flag, "")) == "true": + return "skipped" + return result def overall_status() -> str: - statuses = [] - for _, env_name in JOB_RESULT_FIELDS: - result = normalize(os.environ.get(env_name, "unknown")) - actual = get_actual_status(env_name, result) - statuses.append(actual) + statuses = [] + for _, env_name in JOB_RESULT_FIELDS: + result = normalize(os.environ.get(env_name, "unknown")) + actual = get_actual_status(env_name, result) + statuses.append(actual) - if any(status in FAILURE_STATES for status in statuses): - return "failure" - if all(status in SUCCESS_STATES for status in statuses): - return "success" - return "unknown" + if any(status in FAILURE_STATES for status in statuses): + return "failure" + if all(status in SUCCESS_STATES for status in statuses): + return "success" + return "unknown" def esc(value: str) -> str: - return html.escape(str(value), quote=True) + return html.escape(str(value), quote=True) def compose_message() -> str: - current_overall = overall_status() - lines = [ - "jwt-demo-reactive CI finished", - "", - f"{status_icon(current_overall)} Status: {esc(current_overall)}", - f"Branch: {esc(os.environ.get('GITHUB_REF_NAME', ''))}", - f"Commit: {esc(os.environ.get('GITHUB_SHA', ''))}", - f"Actor: {esc(os.environ.get('GITHUB_ACTOR', ''))}", - f"Workflow: {esc(os.environ.get('GITHUB_WORKFLOW', ''))}", - "", - "Job results", - ] - - for job_name, env_name in JOB_RESULT_FIELDS: - result = normalize(os.environ.get(env_name, "unknown")) - actual = get_actual_status(env_name, result) - lines.append(f"- {status_icon(actual)} {esc(job_name)}: {esc(actual)}") - - lines.append("") - lines.append( - f"Link: {esc(os.environ.get('GITHUB_SERVER_URL', ''))}/{esc(os.environ.get('GITHUB_REPOSITORY', ''))}/actions/runs/{esc(os.environ.get('GITHUB_RUN_ID', ''))}" - ) - - return "\n".join(lines) + current_overall = overall_status() + lines = [ + "jwt-demo-reactive CI finished", + "", + f"{status_icon(current_overall)} Status: {esc(current_overall)}", + f"Branch: {esc(os.environ.get('GITHUB_REF_NAME', ''))}", + f"Commit: {esc(os.environ.get('GITHUB_SHA', ''))}", + f"Actor: {esc(os.environ.get('GITHUB_ACTOR', ''))}", + f"Workflow: {esc(os.environ.get('GITHUB_WORKFLOW', ''))}", + "", + "Job results", + ] + + for job_name, env_name in JOB_RESULT_FIELDS: + result = normalize(os.environ.get(env_name, "unknown")) + actual = get_actual_status(env_name, result) + lines.append(f"- {status_icon(actual)} {esc(job_name)}: {esc(actual)}") + + lines.append("") + lines.append( + f"Link: {esc(os.environ.get('GITHUB_SERVER_URL', ''))}/{esc(os.environ.get('GITHUB_REPOSITORY', ''))}/actions/runs/{esc(os.environ.get('GITHUB_RUN_ID', ''))}" + ) + + return "\n".join(lines) def main() -> None: - message = compose_message() - output_path = os.environ["GITHUB_OUTPUT"] - with open(output_path, "a", encoding="utf-8") as output_file: - output_file.write("message<> $GITHUB_OUTPUT + echo "sonar_skipped=true" >> "$GITHUB_OUTPUT" echo "SONAR_TOKEN not set; skipping backend analysis" exit 0 fi host_url="${SONAR_HOST_URL:-https://sonarcloud.io}" + host_url="${host_url%/}" if [ "$host_url" = "https://sonarcloud.io" ] && [ -z "$SONAR_ORGANIZATION" ]; then - echo "sonar_skipped=true" >> $GITHUB_OUTPUT + echo "sonar_skipped=true" >> "$GITHUB_OUTPUT" echo "SONAR_ORGANIZATION not set for SonarCloud; skipping backend analysis" exit 0 fi - echo "sonar_skipped=false" >> $GITHUB_OUTPUT - echo "sonar_enabled=true" >> $GITHUB_OUTPUT + echo "sonar_skipped=false" >> "$GITHUB_OUTPUT" + echo "sonar_enabled=true" >> "$GITHUB_OUTPUT" - name: Set up JDK 25 if: steps.sonar-check.outputs.sonar_enabled == 'true' @@ -99,6 +101,7 @@ jobs: PR_BASE_REF: ${{ github.base_ref }} run: | host_url="${SONAR_HOST_URL:-https://sonarcloud.io}" + sonar_project_key="${SONAR_PROJECT_KEY:-$REPO_NAME}" report_path="" found_report=$(find coverage -type f -name "jacoco.xml" | head -1 || true) if [ -n "${found_report}" ]; then @@ -111,7 +114,7 @@ jobs: verify sonar:sonar "-Dsonar.host.url=${host_url}" - "-Dsonar.projectKey=${REPO_NAME}" + "-Dsonar.projectKey=${sonar_project_key}" "-Dsonar.token=${SONAR_TOKEN}" ) @@ -151,6 +154,7 @@ jobs: env: REPO_NAME: ${{ github.event.repository.name }} run: | + sonar_project_key="${SONAR_PROJECT_KEY:-$REPO_NAME}" extra_args=() if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then extra_args+=("--allow-missing-new-code-metrics") @@ -158,7 +162,7 @@ jobs: python3 .github/scripts/sonar_quality_gate_report.py \ --component-name "backend" \ - --project-key "${REPO_NAME}" \ + --project-key "${sonar_project_key}" \ --report-task-file "${{ steps.report-task.outputs.path }}" \ --timeout-seconds 900 \ "${extra_args[@]}" From 52190102e643f400083d535afae5a12314a69a09 Mon Sep 17 00:00:00 2001 From: igorsatsyuk Date: Sun, 31 May 2026 18:03:24 +0300 Subject: [PATCH 4/7] fix(ci): handle missing coverage and sonar timeout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/sonar_quality_gate_report.py | 2 +- .github/workflows/ci.yml | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/scripts/sonar_quality_gate_report.py b/.github/scripts/sonar_quality_gate_report.py index 8a2528c..83a1e27 100644 --- a/.github/scripts/sonar_quality_gate_report.py +++ b/.github/scripts/sonar_quality_gate_report.py @@ -222,7 +222,7 @@ def main() -> int: conditions = project_status.get("conditions", []) measures = fetch_measures(host_url, args.project_key, token) - except RuntimeError as error: + except (RuntimeError, TimeoutError) as error: print(str(error), file=sys.stderr) return 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92af738..1cfe701 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,10 @@ jobs: host_url="${SONAR_HOST_URL:-https://sonarcloud.io}" sonar_project_key="${SONAR_PROJECT_KEY:-$REPO_NAME}" report_path="" - found_report=$(find coverage -type f -name "jacoco.xml" | head -1 || true) + found_report="" + if [ -d "coverage" ]; then + found_report=$(find coverage -type f -name "jacoco.xml" | head -1 || true) + fi if [ -n "${found_report}" ]; then report_path="$(pwd)/${found_report}" fi From fc35036caaaa8094c55ebce3ef97cdba94adb751 Mon Sep 17 00:00:00 2001 From: igorsatsyuk Date: Sun, 31 May 2026 18:10:46 +0300 Subject: [PATCH 5/7] fix(ci): pin remaining workflow actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cfe701..807f8e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - name: Upload backend JaCoCo report if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: backend-jacoco-report path: target/site/jacoco-merged/jacoco.xml @@ -85,7 +85,7 @@ jobs: - name: Download backend JaCoCo report if: steps.sonar-check.outputs.sonar_enabled == 'true' continue-on-error: true - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: backend-jacoco-report path: coverage @@ -194,7 +194,7 @@ jobs: - name: Notify Telegram if: ${{ env.TELEGRAM_TO != '' && env.TELEGRAM_TOKEN != '' }} - uses: appleboy/telegram-action@v1.0.1 + uses: appleboy/telegram-action@221e6b684967abe813051ee4a37dd61770a83ad3 # v1.0.1 with: to: ${{ secrets.TELEGRAM_TO }} token: ${{ secrets.TELEGRAM_TOKEN }} From fdabffe32767c27b150e83842310bf7734e42c59 Mon Sep 17 00:00:00 2001 From: igorsatsyuk Date: Sun, 31 May 2026 18:19:10 +0300 Subject: [PATCH 6/7] fix(ci): align sonar job label in telegram summary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/compose_telegram_message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/compose_telegram_message.py b/.github/scripts/compose_telegram_message.py index 5be6359..48ee761 100644 --- a/.github/scripts/compose_telegram_message.py +++ b/.github/scripts/compose_telegram_message.py @@ -6,7 +6,7 @@ JOB_RESULT_FIELDS = ( ("backend", "BACKEND_RESULT"), - ("sonarqube-backend", "SONAR_BACKEND_RESULT"), + ("sonar-backend", "SONAR_BACKEND_RESULT"), ) SONAR_SKIP_FLAGS = { From b6a990fcc3b92fdd20a357d7f5f5e5b036013a44 Mon Sep 17 00:00:00 2001 From: igorsatsyuk Date: Sun, 31 May 2026 18:24:06 +0300 Subject: [PATCH 7/7] fix(ci): scope actions write permission to backend job Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 807f8e0..7bc43d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,15 @@ on: permissions: contents: read - actions: write + actions: read jobs: backend: name: Backend (Maven) runs-on: ubuntu-latest + permissions: + contents: read + actions: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2