initial commit
Change-Id: I1576d92bb7f22bf742ff052970143d31b1fec8a8
This commit is contained in:
84
.gitea/workflows/build-push.yml
Normal file
84
.gitea/workflows/build-push.yml
Normal file
@@ -0,0 +1,84 @@
|
||||
name: Build and Push to ACR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ dev ]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
AZURE_FEDERATED_TOKEN_FILE: /var/run/secrets/azure/tokens/azure-identity-token
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Push
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.ref != 'refs/heads/main' && (
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000')
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Azure CLI
|
||||
run: |
|
||||
command -v az &>/dev/null || curl -sL https://aka.ms/InstallAzureCLIDeb | bash
|
||||
|
||||
- name: Install Docker CLI
|
||||
run: |
|
||||
command -v docker &>/dev/null || (apt-get update -qq && apt-get install -y docker.io)
|
||||
docker --version
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
run: |
|
||||
az login \
|
||||
--service-principal \
|
||||
--username "$AZURE_CLIENT_ID" \
|
||||
--tenant "$AZURE_TENANT_ID" \
|
||||
--federated-token "$(cat $AZURE_FEDERATED_TOKEN_FILE)"
|
||||
echo "✓ Azure login successful"
|
||||
|
||||
- name: Get ACR details
|
||||
run: |
|
||||
ACR_NAME=$(az acr list --query "[0].name" -o tsv)
|
||||
ACR_NAME="${ACR_NAME:-bstagecjotdevacr}"
|
||||
echo "ACR_NAME=$ACR_NAME" >> $GITHUB_ENV
|
||||
echo "ACR_LOGIN_SERVER=${ACR_NAME}.azurecr.io" >> $GITHUB_ENV
|
||||
echo "✓ Using ACR: ${ACR_NAME}.azurecr.io"
|
||||
|
||||
- name: ACR Login
|
||||
run: |
|
||||
ACR_TOKEN=$(az acr login --name "$ACR_NAME" --expose-token --output tsv --query accessToken)
|
||||
docker login "$ACR_LOGIN_SERVER" \
|
||||
--username 00000000-0000-0000-0000-000000000000 \
|
||||
--password "$ACR_TOKEN"
|
||||
echo "✓ ACR login successful"
|
||||
|
||||
- name: Build and Push Docker image
|
||||
run: |
|
||||
IMAGE_TAG="${{ gitea.sha }}"
|
||||
IMAGE_FULL="${ACR_LOGIN_SERVER}/demo-sam-1:${IMAGE_TAG}"
|
||||
IMAGE_LATEST="${ACR_LOGIN_SERVER}/demo-sam-1:latest"
|
||||
docker build -t "$IMAGE_FULL" -t "$IMAGE_LATEST" .
|
||||
docker push "$IMAGE_FULL"
|
||||
docker push "$IMAGE_LATEST"
|
||||
echo "IMAGE_FULL=$IMAGE_FULL" >> $GITHUB_ENV
|
||||
echo "✓ Pushed: $IMAGE_FULL"
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "### ✅ Build Successful" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| | |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Service** | demo-sam-1 |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Commit** | ${{ gitea.sha }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Image** | $IMAGE_FULL |" >> $GITHUB_STEP_SUMMARY
|
||||
186
.gitea/workflows/deploy.yml
Normal file
186
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,186 @@
|
||||
name: Deploy to Orchestrator
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push to ACR"]
|
||||
types: [completed]
|
||||
branches: [ "dev", "staging", "prod" ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Target environment'
|
||||
required: true
|
||||
default: 'dev'
|
||||
type: choice
|
||||
options:
|
||||
- dev
|
||||
- staging
|
||||
- prod
|
||||
|
||||
env:
|
||||
PO_API_URL: https://api.dev.orchestrator.crucible.kyndemo.live
|
||||
PO_ORG_ID: crucible
|
||||
PO_AUTH_TOKEN: ${{ secrets.PO_AUTH_TOKEN }}
|
||||
# ONE ORCHESTRATOR PROJECT PER APPLICATION.
|
||||
#
|
||||
# This used to be the shared `apps-cluster` project with one environment per app, which
|
||||
# made every app a peer of every other: the Orchestrator tab on any component listed the
|
||||
# entire estate, and an app had exactly one environment named after itself, so there was
|
||||
# nowhere for dev/staging/prod to live.
|
||||
#
|
||||
# That shape existed to avoid a Terraform pull request against config/projects.tf for every
|
||||
# scaffolded app. That constraint turned out not to be real -- `octl create project`,
|
||||
# `octl create runner-rule` and `octl create environment` are all runtime operations, so
|
||||
# the workflow below builds the whole thing on first deploy and needs no repository change.
|
||||
PROJECT_ID: demo-sam-1
|
||||
# The runner a project's workloads execute on. This is NOT cosmetic: the Kubernetes and
|
||||
# Helm providers are ambient, so a workload lands in whichever cluster its runner lives in,
|
||||
# and Terraform state is keyed per runner. A project bound to the wrong runner deploys to
|
||||
# the wrong cluster, and repointing it afterwards orphans the state it already owns.
|
||||
PO_RUNNER_ID: crucible-orchestrator-dev-apps-dev-runner
|
||||
OCTL_VERSION: 1.0.0
|
||||
IMAGE: bstagecjotdevacr.azurecr.io/demo-sam-1
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: Platform guard
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ready: ${{ steps.check.outputs.ready }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check platform initialized
|
||||
id: check
|
||||
run: |
|
||||
if [ -f ".platform/initialized.md" ]; then
|
||||
echo "ready=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "ready=false" >> $GITHUB_OUTPUT
|
||||
echo "Skipping: .platform/initialized.md not found"
|
||||
fi
|
||||
|
||||
deploy:
|
||||
name: Deploy to Orchestrator
|
||||
needs: guard
|
||||
if: >-
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && needs.guard.outputs.ready == 'true') ||
|
||||
(github.event_name == 'workflow_dispatch')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install octl
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSLo /tmp/octl.tar.gz \
|
||||
"https://github.com/stellwerk-labs/platform-orchestrator-cli/releases/download/v${OCTL_VERSION}/platform-orchestrator-cli_${OCTL_VERSION}_linux_amd64.tar.gz"
|
||||
tar xzf /tmp/octl.tar.gz -C /tmp
|
||||
install -m 755 /tmp/octl /usr/local/bin/octl
|
||||
octl --version
|
||||
|
||||
- name: Derive environment
|
||||
run: |
|
||||
# The environment is now a STAGE of this application -- dev, staging, prod -- because
|
||||
# the project is the application. It used to be the component id, which was the only
|
||||
# option while every app shared one project and had to be distinguishable inside it.
|
||||
DISPATCH_ENV="${{ github.event.inputs.environment }}"
|
||||
if [ -n "$DISPATCH_ENV" ]; then
|
||||
ENV_ID="$DISPATCH_ENV"
|
||||
else
|
||||
# On a workflow_run the branch is the triggering run's, not this job's checkout.
|
||||
BRANCH="${{ github.event.workflow_run.head_branch }}"
|
||||
BRANCH="${BRANCH:-${GITHUB_REF_NAME}}"
|
||||
case "$BRANCH" in
|
||||
staging) ENV_ID=staging ;;
|
||||
prod|main|master) ENV_ID=prod ;;
|
||||
*) ENV_ID=dev ;;
|
||||
esac
|
||||
echo "Branch '$BRANCH' maps to environment '$ENV_ID'"
|
||||
fi
|
||||
echo "ENV_ID=$ENV_ID" >> $GITHUB_ENV
|
||||
echo "Deploying $PROJECT_ID to environment: $ENV_ID"
|
||||
|
||||
- name: Ensure the project and its runner binding exist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Created on first deploy rather than by a pull request against config/projects.tf.
|
||||
# None of this needs a repository change: project, runner rule and environment are
|
||||
# all runtime objects.
|
||||
#
|
||||
# Neither create is idempotent, so both fall through to a read on the second run.
|
||||
octl create project "$PROJECT_ID" \
|
||||
--set display_name='demo-sam-1' || \
|
||||
octl get project "$PROJECT_ID"
|
||||
|
||||
# The runner rule is what routes this project's deployments to the apps cluster.
|
||||
# Creating it twice would leave two rules matching the same project, so it is
|
||||
# created only when absent -- `create` would happily add a duplicate.
|
||||
if octl get runner-rules -o json 2>/dev/null | grep -q "\"project_id\": *\"$PROJECT_ID\""; then
|
||||
echo "Runner rule for '$PROJECT_ID' already exists."
|
||||
else
|
||||
octl create runner-rule \
|
||||
--set project_id="$PROJECT_ID" \
|
||||
--set runner_id="$PO_RUNNER_ID" \
|
||||
--no-prompt
|
||||
fi
|
||||
|
||||
- name: Ensure the environment exists
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Project and environment ids are POSITIONAL; only env_type_id and display_name go
|
||||
# through --set. `dev` and `stable` are the only environment TYPES that exist, so
|
||||
# staging rides on the dev type -- the type governs policy, the id governs identity.
|
||||
case "$ENV_ID" in
|
||||
prod) ENV_TYPE=stable ;;
|
||||
*) ENV_TYPE=dev ;;
|
||||
esac
|
||||
octl create environment "$PROJECT_ID" "$ENV_ID" \
|
||||
--set env_type_id="$ENV_TYPE" \
|
||||
--set display_name="$ENV_ID" || \
|
||||
octl get environment "$PROJECT_ID" "$ENV_ID"
|
||||
|
||||
- name: Deploy the Score workload
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `octl score deploy` is ADDITIVE — it adds or updates a workload in the manifest and
|
||||
# never removes one. That is the opposite of `octl deploy`, where omission is
|
||||
# deletion. Do not substitute one for the other.
|
||||
# No --show-logs: octl 1.0.0 has no such flag and exits 1 with `unknown flag`
|
||||
# BEFORE contacting the orchestrator, so the whole deploy dies on an argument
|
||||
# typo. Its nearest relatives are --runner-logs-level (default `info`, already
|
||||
# what we want) and --skip-logs (which suppresses storage). Neither streams the
|
||||
# runner's logs into this job, so there is nothing to substitute -- the runner
|
||||
# logs are read from the orchestrator, not from here.
|
||||
# The tag must be the commit the BUILD built, and it must be the WHOLE sha.
|
||||
#
|
||||
# build-push.yml tags with `` -- all 40 characters -- so the
|
||||
# 7-character `${GITHUB_SHA:0:7}` this used to pass named a tag that has never
|
||||
# existed in the registry.
|
||||
#
|
||||
# And on a workflow_run, GITHUB_SHA is the DEFAULT branch's head, while the build
|
||||
# that produced the image ran on `dev`. They coincide only while the branches are
|
||||
# level. `workflow_run.head_sha` is the triggering run's own commit, which is by
|
||||
# definition the one that was built; `github.sha` covers the workflow_dispatch case,
|
||||
# where there is no triggering run.
|
||||
IMAGE_TAG="${{ github.event.workflow_run.head_sha || github.sha }}"
|
||||
echo "Deploying ${IMAGE}:${IMAGE_TAG}"
|
||||
|
||||
octl score deploy "$PROJECT_ID" "$ENV_ID" score.yaml \
|
||||
--default-image "${IMAGE}:${IMAGE_TAG}" \
|
||||
--no-prompt
|
||||
|
||||
- name: Deployment summary
|
||||
if: always()
|
||||
run: |
|
||||
# Same commit the deploy step resolved, abbreviated for reading only -- the
|
||||
# deployed tag is the full sha.
|
||||
DEPLOYED_SHA="${{ github.event.workflow_run.head_sha || github.sha }}"
|
||||
SHORT_SHA="${DEPLOYED_SHA:0:7}"
|
||||
echo "## Deployment Result" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Project | \`$PROJECT_ID\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Environment | \`$ENV_ID\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Commit | \`$SHORT_SHA\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "[View in Orchestrator Console](https://console.dev.orchestrator.crucible.kyndemo.live/orgs/$PO_ORG_ID/projects/$PROJECT_ID/environments/$ENV_ID)" >> $GITHUB_STEP_SUMMARY
|
||||
161
.gitea/workflows/integration-test.yml
Normal file
161
.gitea/workflows/integration-test.yml
Normal file
@@ -0,0 +1,161 @@
|
||||
|
||||
name: Integration Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── Job 1: Platform Conformance ───────────────────────────────────────────
|
||||
platform-check:
|
||||
name: Platform Conformance
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate catalog-info.yaml
|
||||
run: |
|
||||
if [ ! -f catalog-info.yaml ]; then
|
||||
echo "✗ catalog-info.yaml not found"
|
||||
exit 1
|
||||
fi
|
||||
python3 -c "import yaml; list(yaml.safe_load_all(open('catalog-info.yaml')))" 2>/dev/null \
|
||||
|| (pip install pyyaml -q && python3 -c "import yaml; list(yaml.safe_load_all(open('catalog-info.yaml')))")
|
||||
echo "✓ catalog-info.yaml is valid YAML"
|
||||
|
||||
- name: Check platform initialized
|
||||
run: |
|
||||
if [ -f ".platform/initialized.md" ]; then
|
||||
echo "✓ Platform initialized"
|
||||
else
|
||||
echo "⚠ .platform/initialized.md not found — skipping guard"
|
||||
fi
|
||||
|
||||
# ── Job 2: Unit Tests + Container Smoke ───────────────────────────────────
|
||||
smoke-test:
|
||||
name: Unit Tests + Container Smoke
|
||||
runs-on: ubuntu-latest
|
||||
needs: platform-check
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Free disk space
|
||||
run: |
|
||||
docker system prune -f 2>/dev/null || true
|
||||
df -h / 2>/dev/null || true
|
||||
|
||||
- name: Install Docker CLI
|
||||
run: command -v docker &>/dev/null || (apt-get update -qq && apt-get install -y docker.io)
|
||||
|
||||
- name: Build container image
|
||||
run: |
|
||||
if [ -f Dockerfile ]; then
|
||||
docker build -t ci-image:test .
|
||||
else
|
||||
echo "No Dockerfile found — skipping container smoke test"
|
||||
echo "SKIP_SMOKE=true" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Start service and wait for health
|
||||
if: env.SKIP_SMOKE != 'true'
|
||||
run: |
|
||||
CONTAINER_NAME="ci-${GITHUB_RUN_ID}"
|
||||
|
||||
# Nothing sets CONTAINER_PORT, so this used to be 8080 on every run, for every
|
||||
# application. That is fine for Spring Boot and wrong for everything else -- an
|
||||
# Express app on 3000 was probed on 8080, found nothing, and failed the smoke test
|
||||
# after burning the full 180 second health timeout.
|
||||
#
|
||||
# score.yaml is the one file in this repository that knows the answer: the agent
|
||||
# writes targetPort from the port it detected in the application's own source. So
|
||||
# read it, exactly as the health-path probe below discovers its path instead of
|
||||
# assuming one. Both defaults stay as a last resort for a repo without a score.yaml.
|
||||
PORT="${CONTAINER_PORT:-}"
|
||||
if [ -z "${PORT}" ] && [ -f score.yaml ]; then
|
||||
PORT=$(grep -oE 'targetPort:[[:space:]]*[0-9]+' score.yaml | head -1 | tr -dc '0-9')
|
||||
[ -n "${PORT}" ] && echo "Detected container port ${PORT} from score.yaml"
|
||||
fi
|
||||
PORT="${PORT:-8080}"
|
||||
echo "CONTAINER_NAME=${CONTAINER_NAME}" >> $GITHUB_ENV
|
||||
echo "CONTAINER_PORT=${PORT}" >> $GITHUB_ENV
|
||||
|
||||
docker run -d --name "${CONTAINER_NAME}" -e OTEL_SDK_DISABLED=true ci-image:test
|
||||
|
||||
for i in $(seq 1 10); do
|
||||
CONTAINER_IP=$(docker inspect "${CONTAINER_NAME}" --format '{{.NetworkSettings.IPAddress}}' 2>/dev/null)
|
||||
[ -n "${CONTAINER_IP}" ] && break
|
||||
sleep 1
|
||||
done
|
||||
echo "CONTAINER_IP=${CONTAINER_IP}" >> $GITHUB_ENV
|
||||
|
||||
# This factory renovates arbitrary applications, so it cannot know the health path
|
||||
# in advance. The previous hardcoded /health is wrong for the single most likely
|
||||
# input: a Spring Boot app serves /actuator/health and 404s on /health, so the
|
||||
# smoke test sat through its full 180s timeout against a container that was up and
|
||||
# answering the whole time.
|
||||
#
|
||||
# Probe candidates instead, and settle on whichever answers. `/` is last and is a
|
||||
# legitimate result -- it proves the container serves HTTP, which is all this test
|
||||
# claims. HEALTH_ENDPOINT still wins if the caller sets it.
|
||||
CANDIDATES="${HEALTH_ENDPOINT:-} /actuator/health /health /healthz /health/live /api/health /"
|
||||
echo "Probing for a health endpoint on ${CONTAINER_IP}:${PORT} (up to 180s)..."
|
||||
DEADLINE=$(($(date +%s) + 180))
|
||||
HEALTH_PATH=""
|
||||
while [ -z "${HEALTH_PATH}" ]; do
|
||||
for c in ${CANDIDATES}; do
|
||||
if curl -sf -o /dev/null "http://${CONTAINER_IP}:${PORT}${c}" 2>/dev/null; then
|
||||
HEALTH_PATH="$c"; break
|
||||
fi
|
||||
done
|
||||
[ -n "${HEALTH_PATH}" ] && break
|
||||
if [ $(date +%s) -ge $DEADLINE ]; then
|
||||
echo "Timeout: no candidate path answered (${CANDIDATES})"
|
||||
docker logs "${CONTAINER_NAME}" 2>&1 | tail -30
|
||||
exit 1
|
||||
fi
|
||||
if ! docker ps --filter "name=${CONTAINER_NAME}" --format '{{.Status}}' | grep -q Up; then
|
||||
echo "Container exited:"
|
||||
docker logs "${CONTAINER_NAME}" 2>&1 | tail -30
|
||||
exit 1
|
||||
fi
|
||||
echo " still waiting..."; sleep 3
|
||||
done
|
||||
echo "HEALTH_PATH=${HEALTH_PATH}" >> $GITHUB_ENV
|
||||
echo "✓ Service healthy at ${CONTAINER_IP}:${PORT}${HEALTH_PATH}"
|
||||
|
||||
- name: Validate health response
|
||||
if: env.SKIP_SMOKE != 'true'
|
||||
run: |
|
||||
curl -sf "http://${CONTAINER_IP}:${CONTAINER_PORT}${HEALTH_PATH}" > /tmp/health.json
|
||||
echo "Health response:"
|
||||
cat /tmp/health.json
|
||||
echo ""
|
||||
echo "✓ Container smoke test: PASSED"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: docker rm -f "ci-${GITHUB_RUN_ID}" 2>/dev/null || true
|
||||
|
||||
- name: Post commit status
|
||||
if: always()
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
run: |
|
||||
STATE=$([[ "$JOB_STATUS" == "success" ]] && echo "success" || echo "failure")
|
||||
DESC=$([[ "$STATE" == "success" ]] && echo "All checks passed" || echo "Some checks failed")
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/statuses/${GITHUB_SHA}" \
|
||||
-d "{\"state\":\"${STATE}\",\"context\":\"Integration Test / Unit Tests + Container Smoke (workflow_dispatch)\",\"description\":\"${DESC}\"}" \
|
||||
|| true
|
||||
445
.gitea/workflows/sonar.yaml
Normal file
445
.gitea/workflows/sonar.yaml
Normal file
@@ -0,0 +1,445 @@
|
||||
|
||||
name: SonarQube Analysis
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarqube:
|
||||
name: Build, Test & Analyse
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
env:
|
||||
SONAR_PROJECT_KEY: demo-sam-1
|
||||
SONAR_ADMIN_TOKEN: ${{ secrets.SONAR_ADMIN_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
BACKSTAGE_CLEANUP_TOKEN: ${{ secrets.BACKSTAGE_CLEANUP_TOKEN }}
|
||||
BACKSTAGE_URL: https://backstage.dev.crucible.kyndemo.live
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# This factory renovates arbitrary applications, so the analysis cannot assume one of
|
||||
# them. Everything below used to be Maven unconditionally, and the very first Java step
|
||||
# was `chmod +x mvnw` -- which on a Node repository fails with
|
||||
# "cannot access 'mvnw': No such file or directory" and takes the whole run with it.
|
||||
#
|
||||
# Two paths, chosen from what is actually in the repository:
|
||||
#
|
||||
# maven pom.xml present -> mvnw verify + sonar-maven-plugin, with JaCoCo
|
||||
# cli anything else -> sonar-scanner CLI, which analyses JS/TS, Python, Go
|
||||
# and more without needing that language's build
|
||||
#
|
||||
# `cli` rather than `node` because the fallback is genuinely language-agnostic: a Python
|
||||
# or Go repository gets analysed too, just without coverage. Only Node adds a test step,
|
||||
# because it is the one runtime whose coverage format the scanner reads for free.
|
||||
- name: Detect project type
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f pom.xml ]; then
|
||||
KIND=maven
|
||||
else
|
||||
KIND=cli
|
||||
fi
|
||||
echo "PROJECT_KIND=${KIND}" >> $GITHUB_ENV
|
||||
echo "Detected project kind: ${KIND}"
|
||||
if [ "${KIND}" = "cli" ] && [ -f package.json ]; then
|
||||
echo "HAS_NPM=true" >> $GITHUB_ENV
|
||||
echo "package.json present — will run tests for coverage."
|
||||
fi
|
||||
|
||||
# Deliberately NOT gated on PROJECT_KIND. Maven needs it to build, and sonar-scanner is
|
||||
# itself a Java program that ships no JRE -- so on the CLI path a Node image without
|
||||
# java would fail inside the scanner's wrapper script with a bare "java: not found".
|
||||
# Installing it once here is cheaper than diagnosing that.
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Make Maven wrapper executable
|
||||
if: env.PROJECT_KIND == 'maven'
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Cache Maven packages
|
||||
if: env.PROJECT_KIND == 'maven'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: maven-${{ runner.os }}-
|
||||
|
||||
# The sonar-maven-plugin caches here. The CLI scanner keeps its own cache under
|
||||
# ~/.sonar too, so this is useful on both paths -- but the key must not be keyed on
|
||||
# pom.xml, which does not exist on the CLI path and would collapse every non-Maven
|
||||
# repository onto one shared key.
|
||||
- name: Cache SonarQube analysis data
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.sonar/cache
|
||||
key: sonar-${{ runner.os }}-${{ hashFiles('**/pom.xml', '**/package-lock.json', '**/go.sum', '**/requirements.txt') }}
|
||||
restore-keys: sonar-${{ runner.os }}-
|
||||
|
||||
- name: Validate required secrets
|
||||
run: |
|
||||
[[ -n "$SONAR_ADMIN_TOKEN" ]] || { echo "::error::SONAR_ADMIN_TOKEN is not set"; exit 1; }
|
||||
[[ -n "$SONAR_HOST_URL" ]] || { echo "::error::SONAR_HOST_URL is not set"; exit 1; }
|
||||
[[ -n "$SONAR_PROJECT_KEY" ]] || { echo "::error::SONAR_PROJECT_KEY is not set"; exit 1; }
|
||||
|
||||
SONAR_HOST_URL="${SONAR_HOST_URL%/}"
|
||||
|
||||
AUTH_RESPONSE=$(curl -s -o /tmp/sonar-auth-response.json -w "%{http_code}" \
|
||||
-u "${SONAR_ADMIN_TOKEN}:" \
|
||||
"${SONAR_HOST_URL}/api/authentication/validate")
|
||||
|
||||
if [[ "$AUTH_RESPONSE" != "200" ]]; then
|
||||
echo "::error::SonarQube is unreachable or returned HTTP ${AUTH_RESPONSE} — check SONAR_HOST_URL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN_VALID=$(jq -r '.valid' /tmp/sonar-auth-response.json 2>/dev/null || echo "false")
|
||||
if [[ "$TOKEN_VALID" != "true" ]]; then
|
||||
echo "::error::SONAR_ADMIN_TOKEN is invalid or has been revoked (SonarQube returned valid=false)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ SONAR_ADMIN_TOKEN verified against ${SONAR_HOST_URL}"
|
||||
|
||||
- name: Bootstrap SonarQube project and generate scan token
|
||||
id: sonar-bootstrap
|
||||
run: |
|
||||
PROJECT_COUNT=$(curl -sf \
|
||||
-u "${SONAR_ADMIN_TOKEN}:" \
|
||||
"${SONAR_HOST_URL}/api/projects/search?projects=${SONAR_PROJECT_KEY}" \
|
||||
| jq '.paging.total')
|
||||
|
||||
if [[ "$PROJECT_COUNT" == "0" ]]; then
|
||||
echo "Project '${SONAR_PROJECT_KEY}' not found — creating it..."
|
||||
curl -sf -X POST \
|
||||
-u "${SONAR_ADMIN_TOKEN}:" \
|
||||
"${SONAR_HOST_URL}/api/projects/create" \
|
||||
--data-urlencode "name=${SONAR_PROJECT_KEY}" \
|
||||
--data-urlencode "project=${SONAR_PROJECT_KEY}" \
|
||||
--data-urlencode "mainBranch=main" \
|
||||
--data-urlencode "visibility=private"
|
||||
echo "✅ Project created."
|
||||
else
|
||||
echo "✅ Project '${SONAR_PROJECT_KEY}' already exists — skipping creation."
|
||||
fi
|
||||
|
||||
# The token name MUST be unique per run. It used to be `gitea-scan-<projectKey>`,
|
||||
# shared by every job analysing this project, and each job both revokes that name
|
||||
# before generating and revokes it again on cleanup. Concurrent jobs therefore
|
||||
# destroyed each other's credentials and every analysis failed with
|
||||
# `Not authorized. Please check the user token in 'sonar.token'` -- an error that
|
||||
# reads like a permissions problem and is not one. Observed on reno-rehearsal-6,
|
||||
# where three overlapping jobs failed in a chain:
|
||||
#
|
||||
# 18:37:39 job A generates the shared name
|
||||
# 18:37:40 job B revokes it, generates its own
|
||||
# 18:38:16 job C revokes it, generates its own
|
||||
# 18:38:53 job A analysis -> Not authorized (killed at 18:37:40)
|
||||
# 18:38:53 job A cleanup revokes the name -> kills job C's live token
|
||||
# 18:39:26 job C analysis -> Not authorized (killed at 18:38:53)
|
||||
#
|
||||
# The `concurrency:` block above does not prevent this: Gitea Actions does not
|
||||
# honour it, so the overlap is real no matter what the workflow declares. Scoping
|
||||
# the name to the run id removes the shared resource instead of relying on
|
||||
# serialisation, and the cleanup step revokes that same per-run name so tokens
|
||||
# still do not accumulate.
|
||||
TOKEN_NAME="gitea-scan-${SONAR_PROJECT_KEY}-${GITHUB_RUN_ID}"
|
||||
echo "TOKEN_NAME=${TOKEN_NAME}" >> "$GITHUB_ENV"
|
||||
|
||||
curl -sf -X POST \
|
||||
-u "${SONAR_ADMIN_TOKEN}:" \
|
||||
"${SONAR_HOST_URL}/api/user_tokens/revoke" \
|
||||
--data-urlencode "name=${TOKEN_NAME}" \
|
||||
> /dev/null 2>&1 || true
|
||||
|
||||
SCAN_TOKEN=$(curl -sf -X POST \
|
||||
-u "${SONAR_ADMIN_TOKEN}:" \
|
||||
"${SONAR_HOST_URL}/api/user_tokens/generate" \
|
||||
--data-urlencode "name=${TOKEN_NAME}" \
|
||||
--data-urlencode "type=PROJECT_ANALYSIS_TOKEN" \
|
||||
--data-urlencode "projectKey=${SONAR_PROJECT_KEY}" \
|
||||
| jq -r '.token')
|
||||
|
||||
# `jq -r` prints the string "null" for a missing key, which is non-empty and would
|
||||
# sail past a bare -n test, then fail much later as an authorisation error.
|
||||
if [[ -z "$SCAN_TOKEN" || "$SCAN_TOKEN" == "null" ]]; then
|
||||
echo "::error::Failed to generate scan token '${TOKEN_NAME}'"; exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Scan token generated for project '${SONAR_PROJECT_KEY}'"
|
||||
echo "::add-mask::${SCAN_TOKEN}"
|
||||
echo "SCAN_TOKEN=${SCAN_TOKEN}" >> "$GITHUB_ENV"
|
||||
|
||||
# Spring projects carry io.spring.nohttp, a Checkstyle rule that fails the build on any
|
||||
# `http://` URL anywhere under ${basedir}. The platform artifacts this factory adds
|
||||
# contain one legitimately -- the in-cluster OTLP collector, which is plaintext by
|
||||
# design and has no https:// form -- so the app's own quality gate rejects the
|
||||
# modernisation:
|
||||
#
|
||||
# [ERROR] score.yaml:[7,37] (extension) NoHttp: http:// URLs are not allowed
|
||||
# [ERROR] overlays/otel/kustomization.yaml:[13,81] ...
|
||||
# [ERROR] overlays/otel/patches/otel-patch.yaml:[17,19] ...
|
||||
#
|
||||
# The two -D properties on the build below cannot fix it: the SuppressionFilter in
|
||||
# nohttp-checkstyle.xml reads a FIXED path, ${config_loc}/nohttp-checkstyle-suppressions.xml,
|
||||
# so the only lever is the content of that file. Hence editing it rather than passing
|
||||
# a flag.
|
||||
#
|
||||
# Scoped deliberately: `checks="NoHttp"` only, and only the paths this platform owns.
|
||||
# Every other Checkstyle rule still applies to them, and NoHttp still applies to all
|
||||
# application source -- which is the rule's actual purpose.
|
||||
- name: Allowlist platform artifacts for nohttp
|
||||
if: env.PROJECT_KIND == 'maven'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SUPPRESSIONS=src/checkstyle/nohttp-checkstyle-suppressions.xml
|
||||
if [ ! -f "$SUPPRESSIONS" ]; then
|
||||
echo "No nohttp suppressions file — project does not use nohttp, nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
if grep -q 'crucible-platform-artifacts' "$SUPPRESSIONS"; then
|
||||
echo "Platform artifacts already allowlisted."
|
||||
exit 0
|
||||
fi
|
||||
grep -q '</suppressions>' "$SUPPRESSIONS" || {
|
||||
echo "::error::$SUPPRESSIONS has no closing </suppressions> tag"; exit 1; }
|
||||
|
||||
# awk, not python3 or sed -i: this job runs in whatever image the app's build needs,
|
||||
# so only POSIX tooling is safe to assume.
|
||||
awk '
|
||||
/<\/suppressions>/ && !inserted {
|
||||
print "\t<!-- crucible-platform-artifacts: in-cluster OTLP endpoints are http:// by design -->";
|
||||
print "\t<suppress files=\"score\\.yaml\" checks=\"NoHttp\"/>";
|
||||
print "\t<suppress files=\"catalog-info\\.yaml\" checks=\"NoHttp\"/>";
|
||||
print "\t<suppress files=\"overlays[\\\\/].*\" checks=\"NoHttp\"/>";
|
||||
print "\t<suppress files=\"\\.platform[\\\\/].*\" checks=\"NoHttp\"/>";
|
||||
print "\t<suppress files=\"\\.gitea[\\\\/].*\" checks=\"NoHttp\"/>";
|
||||
print "\t<suppress files=\"k6[\\\\/].*\" checks=\"NoHttp\"/>";
|
||||
inserted = 1
|
||||
}
|
||||
{ print }
|
||||
' "$SUPPRESSIONS" > "$SUPPRESSIONS.new" && mv "$SUPPRESSIONS.new" "$SUPPRESSIONS"
|
||||
|
||||
echo "Allowlisted platform artifacts for NoHttp:"
|
||||
cat "$SUPPRESSIONS"
|
||||
|
||||
- name: Build and test
|
||||
if: env.PROJECT_KIND == 'maven'
|
||||
run: |
|
||||
./mvnw -B verify \
|
||||
-Dtest='!PostgresIntegrationTests,!MySqlIntegrationTests' \
|
||||
-Dnohttp.checkstyle.suppressions.file=src/checkstyle/nohttp-checkstyle-suppressions.xml \
|
||||
-Dcheckstyle.suppressionsFile=src/checkstyle/nohttp-checkstyle-suppressions.xml
|
||||
|
||||
- name: SonarQube analysis
|
||||
if: env.PROJECT_KIND == 'maven'
|
||||
run: |
|
||||
./mvnw -B org.sonarsource.scanner.maven:sonar-maven-plugin:4.0.0.4121:sonar \
|
||||
-Dsonar.projectKey="${SONAR_PROJECT_KEY}" \
|
||||
-Dsonar.host.url="${SONAR_HOST_URL}" \
|
||||
-Dsonar.token="${SCAN_TOKEN}" \
|
||||
-Dsonar.java.source=17 \
|
||||
-Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml
|
||||
|
||||
# ── Non-Maven path ────────────────────────────────────────────────────
|
||||
#
|
||||
# Everything below runs only when there is no pom.xml, and feeds the SAME quality gate:
|
||||
# the scan token, the gate poll, the Backstage notification and the token revoke are all
|
||||
# language-agnostic and are not duplicated.
|
||||
|
||||
- name: Set up Node
|
||||
if: env.PROJECT_KIND == 'cli' && env.HAS_NPM == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# Tests must not fail the job. A quality gate reports on what it measured; a repository
|
||||
# with no test script, or one whose suite needs a database this job does not have, still
|
||||
# deserves to be analysed. Coverage simply goes unreported, and the gate says so.
|
||||
- name: Install and test
|
||||
if: env.PROJECT_KIND == 'cli' && env.HAS_NPM == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
# Prisma validates DATABASE_URL when the client is constructed, and construction
|
||||
# happens at import time -- including in suites that mock every query and never
|
||||
# open a connection. A placeholder is enough to let those run; nothing here
|
||||
# connects to it. Without it the RealWorld service tests fail on
|
||||
# `Environment variable not found: DATABASE_URL` (7 of 27), which is not a finding
|
||||
# about the code, and coverage goes unreported for a suite that would have passed.
|
||||
DATABASE_URL: postgresql://test:test@localhost:5432/test
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
npm test --if-present -- --coverage 2>/dev/null || npm test --if-present || true
|
||||
if [ -f coverage/lcov.info ]; then
|
||||
echo "SONAR_LCOV=coverage/lcov.info" >> $GITHUB_ENV
|
||||
echo "Found coverage/lcov.info — it will be reported."
|
||||
else
|
||||
echo "::notice::No coverage/lcov.info produced; analysing without coverage."
|
||||
fi
|
||||
|
||||
- name: SonarQube analysis (scanner CLI)
|
||||
if: env.PROJECT_KIND == 'cli'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Pinned. `latest` would move the analyser under a quality gate that is supposed to
|
||||
# mean the same thing from one run to the next.
|
||||
SCANNER_VERSION=6.2.1.4610
|
||||
SCANNER_DIR="sonar-scanner-${SCANNER_VERSION}-linux-x64"
|
||||
|
||||
# This job runs in whatever image the application's build needs, so unzip is not a
|
||||
# given -- the same reason the nohttp step above uses awk rather than python3.
|
||||
command -v unzip >/dev/null 2>&1 || {
|
||||
(apt-get update -qq && apt-get install -y -qq unzip) >/dev/null 2>&1 \
|
||||
|| { echo "::error::unzip is unavailable and could not be installed"; exit 1; }
|
||||
}
|
||||
|
||||
curl -sSLo /tmp/sonar-scanner.zip \
|
||||
"https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SCANNER_VERSION}-linux-x64.zip"
|
||||
unzip -q /tmp/sonar-scanner.zip -d /opt
|
||||
export PATH="/opt/${SCANNER_DIR}/bin:${PATH}"
|
||||
|
||||
# The scanner is a Java program and ships no JRE. Most build images have one; if
|
||||
# this repository's does not, say so plainly rather than surface a bare
|
||||
# "java: not found" from inside the wrapper script.
|
||||
command -v java >/dev/null 2>&1 || {
|
||||
echo "::error::sonar-scanner needs a JRE and none is on PATH in this image."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Excluded because they are this platform's artifacts, not the application's code,
|
||||
# and counting them would move the gate for reasons the developer cannot act on.
|
||||
# Mirrors the nohttp allowlist on the Maven path.
|
||||
ARGS=(
|
||||
-Dsonar.projectKey="${SONAR_PROJECT_KEY}"
|
||||
-Dsonar.host.url="${SONAR_HOST_URL}"
|
||||
-Dsonar.token="${SCAN_TOKEN}"
|
||||
-Dsonar.sources=.
|
||||
-Dsonar.exclusions="**/node_modules/**,**/coverage/**,**/dist/**,**/build/**,k6/**,overlays/**,.platform/**,.gitea/**"
|
||||
)
|
||||
if [ -n "${SONAR_LCOV:-}" ]; then
|
||||
ARGS+=(-Dsonar.javascript.lcov.reportPaths="${SONAR_LCOV}")
|
||||
fi
|
||||
sonar-scanner "${ARGS[@]}"
|
||||
|
||||
- name: Quality Gate check
|
||||
id: quality-gate
|
||||
run: |
|
||||
echo "Waiting for SonarQube to process the analysis..."
|
||||
for i in $(seq 1 24); do
|
||||
RESPONSE=$(curl -sf -u "${SCAN_TOKEN}:" \
|
||||
"${SONAR_HOST_URL}/api/qualitygates/project_status?projectKey=${SONAR_PROJECT_KEY}" || true)
|
||||
STATUS=$(echo "$RESPONSE" | jq -r '.projectStatus.status' 2>/dev/null || echo "NONE")
|
||||
if [[ "$STATUS" =~ ^(OK|ERROR|WARN)$ ]]; then break; fi
|
||||
echo " Status: ${STATUS:-pending} — retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════"
|
||||
echo " Quality Gate: $STATUS"
|
||||
echo "══════════════════════════════════════════"
|
||||
|
||||
echo "$RESPONSE" | jq -r '
|
||||
.projectStatus.conditions[] |
|
||||
if .status == "ERROR" then " ❌ \(.metricKey): \(.actualValue) (threshold: \(.errorThreshold), comparator: \(.comparator))"
|
||||
elif .status == "WARN" then " ⚠️ \(.metricKey): \(.actualValue) (threshold: \(.errorThreshold), comparator: \(.comparator))"
|
||||
else " ✅ \(.metricKey): \(.actualValue)"
|
||||
end'
|
||||
|
||||
echo "══════════════════════════════════════════"
|
||||
|
||||
FAILED=$(echo "$RESPONSE" | jq '[.projectStatus.conditions[] | select(.status == "ERROR")] | length')
|
||||
if [[ "$FAILED" -gt 0 ]]; then
|
||||
echo "::error::Quality Gate FAILED — $FAILED metric(s) did not meet threshold"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Notify Backstage on quality gate failure
|
||||
if: always() && steps.quality-gate.outcome == 'failure'
|
||||
run: |
|
||||
echo "--- Backstage notification debug ---"
|
||||
echo "BACKSTAGE_URL: ${BACKSTAGE_URL:-<not set>}"
|
||||
echo "BACKSTAGE_CLEANUP_TOKEN set: $([[ -n "$BACKSTAGE_CLEANUP_TOKEN" ]] && echo yes || echo no)"
|
||||
echo "SONAR_PROJECT_KEY: ${SONAR_PROJECT_KEY:-<not set>}"
|
||||
echo "GITHUB_HEAD_REF: ${GITHUB_HEAD_REF:-<not set>}"
|
||||
|
||||
if [[ -z "$BACKSTAGE_URL" ]]; then
|
||||
echo "::error::BACKSTAGE_URL is not set — cannot send notification"
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "$BACKSTAGE_CLEANUP_TOKEN" ]]; then
|
||||
echo "::error::BACKSTAGE_CLEANUP_TOKEN is not set — cannot send notification"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Built with jq rather than a quoted heredoc-by-hand. The previous version
|
||||
# interpolated shell variables into a \"-escaped string and got one line wrong --
|
||||
# `"description": "Quality gate failed for ...` was unescaped, so bash closed the
|
||||
# string early and tried to run `gate` as a command. Nobody saw it because this
|
||||
# step only runs when the quality gate fails. jq also escapes the branch name and
|
||||
# project key, which are attacker-adjacent (a branch may contain a quote).
|
||||
PAYLOAD=$(jq -nc \
|
||||
--arg project "${SONAR_PROJECT_KEY}" \
|
||||
--arg branch "${GITHUB_HEAD_REF}" \
|
||||
--arg link "${SONAR_HOST_URL}/dashboard?id=${SONAR_PROJECT_KEY}" \
|
||||
'{
|
||||
recipients: { type: "entity", entityRef: "group:default/platform-engineering" },
|
||||
payload: {
|
||||
title: "SonarQube Quality Gate Failed",
|
||||
description: "Quality gate failed for \($project) on branch \($branch).",
|
||||
link: $link,
|
||||
severity: "high",
|
||||
topic: "sonarqube-quality-gate"
|
||||
}
|
||||
}')
|
||||
|
||||
echo "Sending notification to: ${BACKSTAGE_URL}/api/notifications"
|
||||
|
||||
HTTP_CODE=$(curl -s -o /tmp/bs-notify-response.json -w "%{http_code}" \
|
||||
-X POST "${BACKSTAGE_URL}/api/notifications" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${BACKSTAGE_CLEANUP_TOKEN}" \
|
||||
-d "${PAYLOAD}")
|
||||
|
||||
echo "HTTP response code: ${HTTP_CODE}"
|
||||
echo "Response body:"
|
||||
cat /tmp/bs-notify-response.json 2>/dev/null || echo "<empty response>"
|
||||
|
||||
if [[ "$HTTP_CODE" -ge 200 && "$HTTP_CODE" -lt 300 ]]; then
|
||||
echo "✅ Backstage notification sent"
|
||||
else
|
||||
echo "⚠️ Backstage notification failed (HTTP ${HTTP_CODE})"
|
||||
fi
|
||||
|
||||
- name: Revoke scan token
|
||||
if: always()
|
||||
run: |
|
||||
# Revoke THIS run's token, never the bare `gitea-scan-<projectKey>` name -- that
|
||||
# name belongs to no single job, and revoking it here is what killed a concurrent
|
||||
# job's live credential. TOKEN_NAME is empty if bootstrap never ran, in which case
|
||||
# there is nothing to revoke and a blank name would be a no-op request at best.
|
||||
if [[ -z "${TOKEN_NAME:-}" ]]; then
|
||||
echo "No scan token was generated — nothing to revoke."
|
||||
exit 0
|
||||
fi
|
||||
curl -sf -X POST \
|
||||
-u "${SONAR_ADMIN_TOKEN}:" \
|
||||
"${SONAR_HOST_URL}/api/user_tokens/revoke" \
|
||||
--data-urlencode "name=${TOKEN_NAME}" \
|
||||
&& echo "✅ Scan token revoked (${TOKEN_NAME})" \
|
||||
|| echo "⚠️ Token revocation failed"
|
||||
Reference in New Issue
Block a user