668 lines
22 KiB
Bash
Executable File
668 lines
22 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# verify-admin-web-docker.sh — Validation structurelle de la feature
|
|
# "Pipeline CI/CD Docker pour admin-web (build + push registry)".
|
|
#
|
|
# Encode TOUS les critères d'acceptation structurels issus du planner :
|
|
# - existence + contenu des fichiers créés par la feature
|
|
# - validité YAML / JSON
|
|
# - patterns de sécurité (secrets, login password-stdin, pas de GITEA_TOKEN…)
|
|
# - actions pinnées par SHA
|
|
# - garde anti-[skip ci] sur chaque job
|
|
# - contextes git (branche feat/admin-web-docker + commit feat:)
|
|
#
|
|
# Usage :
|
|
# ./scripts/verify-admin-web-docker.sh # run complet
|
|
# ./scripts/verify-admin-web-docker.sh --summary # run + total final uniquement
|
|
#
|
|
# Exit : 0 si 0 échec, 1 si ≥1 échec.
|
|
# Idempotent / ré-entrant : lancé avant implé (tout rouge) ET après (tout vert).
|
|
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
cd "$ROOT"
|
|
|
|
SUMMARY_ONLY=0
|
|
[[ "${1:-}" == "--summary" ]] && SUMMARY_ONLY=1
|
|
|
|
PASS_COUNT=0
|
|
FAIL_COUNT=0
|
|
declare -a FAIL_NAMES=()
|
|
|
|
# --- helpers -------------------------------------------------------- #
|
|
|
|
pass() { # name
|
|
PASS_COUNT=$((PASS_COUNT + 1))
|
|
if [[ "$SUMMARY_ONLY" -eq 0 ]]; then
|
|
printf '[PASS] %s\n' "$1"
|
|
fi
|
|
}
|
|
|
|
fail() { # name [detail...]
|
|
FAIL_COUNT=$((FAIL_COUNT + 1))
|
|
FAIL_NAMES+=("$1 :: ${2:-}")
|
|
if [[ "$SUMMARY_ONLY" -eq 0 ]]; then
|
|
printf '[FAIL] %s' "$1"
|
|
[[ $# -ge 2 ]] && printf ' — %s' "${@:2}"
|
|
printf '\n'
|
|
fi
|
|
}
|
|
|
|
# file_contains <file> <label> <pattern> (uses grep -E)
|
|
file_contains() {
|
|
local file="$1" label="$2" pattern="$3"
|
|
if [[ ! -f "$file" ]]; then
|
|
fail "$label" "file missing: $file"
|
|
return
|
|
fi
|
|
if grep -Eq "$pattern" "$file"; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "pattern not found: $pattern"
|
|
fi
|
|
}
|
|
|
|
# file_not_contains <file> <label> <pattern>
|
|
file_not_contains() {
|
|
local file="$1" label="$2" pattern="$3"
|
|
if [[ ! -f "$file" ]]; then
|
|
fail "$label" "file missing: $file"
|
|
return
|
|
fi
|
|
if grep -Eq "$pattern" "$file"; then
|
|
fail "$label" "forbidden pattern found: $pattern"
|
|
else
|
|
pass "$label"
|
|
fi
|
|
}
|
|
|
|
# json_valid <file> <label>
|
|
json_valid() {
|
|
local file="$1" label="$2"
|
|
if [[ ! -f "$file" ]]; then
|
|
fail "$label" "file missing: $file"
|
|
return
|
|
fi
|
|
if python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$file" 2>/dev/null; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "invalid JSON"
|
|
fi
|
|
}
|
|
|
|
# yaml_valid <file> <label>
|
|
yaml_valid() {
|
|
local file="$1" label="$2"
|
|
if [[ ! -f "$file" ]]; then
|
|
fail "$label" "file missing: $file"
|
|
return
|
|
fi
|
|
if ! python3 -c "import yaml" 2>/dev/null; then
|
|
fail "$label" "pyyaml not installed (cannot validate)"
|
|
return
|
|
fi
|
|
if python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" "$file" 2>/dev/null; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "invalid YAML"
|
|
fi
|
|
}
|
|
|
|
# =================================================================== #
|
|
# CHECKS
|
|
# =================================================================== #
|
|
|
|
# --- version.json --------------------------------------------------- #
|
|
check_version_json() {
|
|
local label="version.json exists with {\"version\":\"1.0.0\"}"
|
|
if [[ ! -f version.json ]]; then
|
|
fail "$label" "file missing"
|
|
return
|
|
fi
|
|
local v
|
|
v="$(node -p "require('./version.json').version" 2>/dev/null || true)"
|
|
if [[ "$v" == "1.0.0" ]]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "version.json.version='$v' (expected 1.0.0)"
|
|
fi
|
|
}
|
|
|
|
# --- root package.json ---------------------------------------------- #
|
|
check_root_package_json() {
|
|
local label="root package.json: private + semantic-release deps + script"
|
|
if [[ ! -f package.json ]]; then
|
|
fail "$label" "file missing"
|
|
return
|
|
fi
|
|
local missing=()
|
|
node -e '
|
|
const p = require("./package.json");
|
|
const want = [
|
|
["private === true", p.private === true],
|
|
["scripts.semantic-release", typeof p.scripts?.["semantic-release"] === "string"],
|
|
["devDependencies.semantic-release", !!(p.devDependencies?.["semantic-release"] || p.dependencies?.["semantic-release"])],
|
|
["@semantic-release/exec", !!(p.devDependencies?.["@semantic-release/exec"] || p.dependencies?.["@semantic-release/exec"])],
|
|
["@semantic-release/git", !!(p.devDependencies?.["@semantic-release/git"] || p.dependencies?.["@semantic-release/git"])],
|
|
["@semantic-release/commit-analyzer", !!(p.devDependencies?.["@semantic-release/commit-analyzer"] || p.dependencies?.["@semantic-release/commit-analyzer"])],
|
|
["@semantic-release/release-notes-generator", !!(p.devDependencies?.["@semantic-release/release-notes-generator"] || p.dependencies?.["@semantic-release/release-notes-generator"])],
|
|
["@markwylde/semantic-release-gitea", !!(p.devDependencies?.["@markwylde/semantic-release-gitea"] || p.dependencies?.["@markwylde/semantic-release-gitea"])],
|
|
];
|
|
for (const [name, ok] of want) if (!ok) console.log(name);
|
|
' > /tmp/_pkgcheck 2>/dev/null || { fail "$label" "node parse failed"; return; }
|
|
while IFS= read -r line; do
|
|
[[ -n "$line" ]] && missing+=("$line")
|
|
done < /tmp/_pkgcheck
|
|
rm -f /tmp/_pkgcheck
|
|
if [[ ${#missing[@]} -eq 0 ]]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "missing: ${missing[*]}"
|
|
fi
|
|
}
|
|
|
|
# --- root package-lock.json ----------------------------------------- #
|
|
check_root_pkg_lock() {
|
|
if [[ -f package-lock.json ]]; then
|
|
pass "root package-lock.json exists"
|
|
else
|
|
fail "root package-lock.json exists" "file missing"
|
|
fi
|
|
}
|
|
|
|
# --- .releaserc.json ------------------------------------------------ #
|
|
check_releaserc() {
|
|
local label1=".releaserc.json valid JSON"
|
|
if [[ ! -f .releaserc.json ]]; then
|
|
fail "$label1" "file missing"
|
|
fail ".releaserc.json assets include version.json + admin-web/package.json" "file missing"
|
|
fail ".releaserc.json git plugin message contains [skip ci]" "file missing"
|
|
return
|
|
fi
|
|
if python3 -c "import json,sys; json.load(open(sys.argv[1]))" .releaserc.json 2>/dev/null; then
|
|
pass "$label1"
|
|
else
|
|
fail "$label1" "invalid JSON"
|
|
fi
|
|
|
|
# assets contain version.json + admin-web/package.json
|
|
local label2=".releaserc.json assets include version.json + admin-web/package.json"
|
|
if grep -Eq '"version\.json"' .releaserc.json && grep -Eq '"admin-web/package\.json"' .releaserc.json; then
|
|
pass "$label2"
|
|
else
|
|
fail "$label2" "assets should reference both version.json and admin-web/package.json"
|
|
fi
|
|
|
|
# git plugin message contains literal [skip ci]
|
|
local label3=".releaserc.json git plugin message contains [skip ci]"
|
|
if grep -Fq "[skip ci]" .releaserc.json; then
|
|
pass "$label3"
|
|
else
|
|
fail "$label3" "literal [skip ci] not found"
|
|
fi
|
|
}
|
|
|
|
# --- Dockerfile ----------------------------------------------------- #
|
|
check_dockerfile() {
|
|
local f="admin-web/Dockerfile"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "Dockerfile exists" "file missing: $f"
|
|
return
|
|
fi
|
|
pass "Dockerfile exists"
|
|
|
|
# 3 stages FROM node:22-alpine
|
|
local count
|
|
count=$(grep -Ec '^FROM node:22-alpine' "$f" || true)
|
|
if [[ "$count" -eq 3 ]]; then
|
|
pass "Dockerfile has exactly 3 'FROM node:22-alpine' stages"
|
|
else
|
|
fail "Dockerfile has exactly 3 'FROM node:22-alpine' stages" "found $count"
|
|
fi
|
|
|
|
file_contains "$f" "Dockerfile uses USER node" '^USER node\b'
|
|
file_contains "$f" "Dockerfile EXPOSE 3000" '^EXPOSE 3000\b'
|
|
file_contains "$f" 'Dockerfile CMD ["node","server.js"]' 'CMD \[ ?"node" ?, ?"server\.js" ?\]'
|
|
|
|
# build-args NEXT_PUBLIC_SUPABASE_URL + NEXT_PUBLIC_SUPABASE_ANON_KEY
|
|
file_contains "$f" "Dockerfile ARG NEXT_PUBLIC_SUPABASE_URL" 'ARG[[:space:]]+NEXT_PUBLIC_SUPABASE_URL'
|
|
file_contains "$f" "Dockerfile ARG NEXT_PUBLIC_SUPABASE_ANON_KEY" 'ARG[[:space:]]+NEXT_PUBLIC_SUPABASE_ANON_KEY'
|
|
}
|
|
|
|
# --- next.config.ts standalone -------------------------------------- #
|
|
check_next_config() {
|
|
local f="admin-web/next.config.ts"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "next.config.ts contains output: \"standalone\"" "file missing"
|
|
return
|
|
fi
|
|
if grep -Eq "output:[[:space:]]*[\"']standalone[\"']" "$f"; then
|
|
pass 'next.config.ts contains output: "standalone"'
|
|
else
|
|
fail 'next.config.ts contains output: "standalone"' "pattern not found"
|
|
fi
|
|
}
|
|
|
|
# --- .dockerignore -------------------------------------------------- #
|
|
check_dockerignore() {
|
|
local f="admin-web/.dockerignore"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail ".dockerignore exists" "file missing"
|
|
return
|
|
fi
|
|
pass ".dockerignore exists"
|
|
file_contains "$f" ".dockerignore excludes node_modules" '^node_modules$'
|
|
file_contains "$f" ".dockerignore excludes .next" '^\.next(/.*)?$'
|
|
file_contains "$f" ".dockerignore excludes .git" '^\.git(/.*)?$'
|
|
file_contains "$f" ".dockerignore excludes .env" '^\.env(\..*)?$'
|
|
file_not_contains "$f" ".dockerignore does NOT exclude package-lock.json" '^package-lock\.json$'
|
|
}
|
|
|
|
# --- workflow YAML validity ----------------------------------------- #
|
|
check_workflow_valid() {
|
|
yaml_valid ".github/workflows/admin-web-docker.yml" "admin-web-docker.yml is valid YAML"
|
|
}
|
|
|
|
# --- workflow: exactly 3 jobs --------------------------------------- #
|
|
check_workflow_jobs() {
|
|
local label="workflow has exactly 3 jobs: build-validation + semantic-release + build-and-push"
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "$label" "file missing"
|
|
return
|
|
fi
|
|
local out
|
|
if ! out=$(python3 -c "
|
|
import yaml,sys
|
|
d=yaml.safe_load(open(sys.argv[1]))
|
|
jobs=list((d.get('jobs') or {}).keys())
|
|
print(','.join(sorted(jobs)))
|
|
" "$f" 2>/dev/null); then
|
|
fail "$label" "could not parse jobs"
|
|
return
|
|
fi
|
|
if [[ "$out" == "build-and-push,build-validation,semantic-release" ]]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "jobs=[$out]"
|
|
fi
|
|
}
|
|
|
|
# --- workflow: build-and-push needs semantic-release --------------- #
|
|
check_workflow_needs() {
|
|
local label="build-and-push needs: semantic-release"
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "$label" "file missing"
|
|
return
|
|
fi
|
|
local out
|
|
out=$(python3 -c "
|
|
import yaml,sys
|
|
d=yaml.safe_load(open(sys.argv[1]))
|
|
bp=d.get('jobs',{}).get('build-and-push',{})
|
|
n=bp.get('needs')
|
|
if isinstance(n,list): print(','.join(n))
|
|
else: print(str(n))
|
|
" "$f" 2>/dev/null || true)
|
|
if [[ "$out" == "semantic-release" ]]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "needs='$out'"
|
|
fi
|
|
}
|
|
|
|
# --- workflow: skip-ci guard on both jobs -------------------------- #
|
|
check_skip_ci_guard() {
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "both jobs guard against [skip ci] + [ci skip]" "file missing"
|
|
return
|
|
fi
|
|
# Need at least 2 occurrences of each literal pattern (one per job minimum).
|
|
# We accept "≥2" rather than "exactly 2" to stay robust to multi-line ifs.
|
|
local c_skip c_ciskip
|
|
c_skip=$(grep -Fc "[skip ci]" "$f" || true)
|
|
c_ciskip=$(grep -Fc "[ci skip]" "$f" || true)
|
|
if [[ "$c_skip" -ge 2 && "$c_ciskip" -ge 2 ]]; then
|
|
pass "both jobs guard against [skip ci] + [ci skip]"
|
|
else
|
|
fail "both jobs guard against [skip ci] + [ci skip]" "[skip ci] x$c_skip, [ci skip] x$c_ciskip (need >=2 each)"
|
|
fi
|
|
}
|
|
|
|
# --- workflow: build-and-push if condition ------------------------- #
|
|
check_bp_if_condition() {
|
|
local label="build-and-push if = skip-ci guard AND (released || workflow_dispatch)"
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "$label" "file missing"
|
|
return
|
|
fi
|
|
# On extrait la condition `if:` du job build-and-push.
|
|
local cond
|
|
cond=$(python3 -c "
|
|
import yaml,sys
|
|
d=yaml.safe_load(open(sys.argv[1]))
|
|
print(d.get('jobs',{}).get('build-and-push',{}).get('if','') or '')
|
|
" "$f" 2>/dev/null || true)
|
|
if [[ -z "$cond" ]]; then
|
|
fail "$label" "no 'if' on build-and-push"
|
|
return
|
|
fi
|
|
# La condition doit mentionner : [skip ci], [ci skip], released, workflow_dispatch
|
|
local miss=()
|
|
grep -Fq "[skip ci]" <<<"$cond" || miss+=("[skip ci]")
|
|
grep -Fq "[ci skip]" <<<"$cond" || miss+=("[ci skip]")
|
|
grep -Eq "released" <<<"$cond" || miss+=("released")
|
|
grep -Eq "workflow_dispatch" <<<"$cond" || miss+=("workflow_dispatch")
|
|
if [[ ${#miss[@]} -eq 0 ]]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "condition missing: ${miss[*]} (cond=$cond)"
|
|
fi
|
|
}
|
|
|
|
# --- workflow: docker login uses --password-stdin + env, no inline --#
|
|
check_docker_login() {
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "docker login uses --password-stdin + env (no inline secret)" "file missing"
|
|
return
|
|
fi
|
|
# doit contenir --password-stdin et echo "$DOCKER_PASSWORD"
|
|
if grep -Eq -- '--password-stdin' "$f" && grep -Fq 'echo "$DOCKER_PASSWORD"' "$f"; then
|
|
# On n'interdit ${{ secrets.DOCKER_PASSWORD }} que DANS les blocs run:
|
|
# (secret inline = fuite potentielle dans les logs). Une référence dans un
|
|
# bloc env: est au contraire la bonne pratique et ne doit PAS échouer.
|
|
# On extrait donc le contenu des run: via awk, puis on grep ce contenu.
|
|
# Cas couverts : run: <cmd> (mono-ligne) et run: | ou run: > (multi-lignes).
|
|
local runs
|
|
runs=$(awk '
|
|
/^[[:space:]]*run:[[:space:]]*[|>][-+]?[[:space:]]*$/ { in_run=1; next }
|
|
/^[[:space:]]*run:[[:space:]].+/ { in_run=0; print; next }
|
|
in_run && /^[[:space:]]+/ { print; next }
|
|
in_run { in_run=0 }
|
|
' "$f")
|
|
# Regex tolérante au whitespace : ${{ secrets.DOCKER_PASSWORD }}, ${{secrets.DOCKER_PASSWORD}}, etc.
|
|
if echo "$runs" | grep -Eq '\$\{\{\s*secrets\.DOCKER_PASSWORD\s*\}\}'; then
|
|
fail "docker login uses --password-stdin + env (no inline secret)" \
|
|
"secrets.DOCKER_PASSWORD must not be used inline in a run: block — pass via env + --password-stdin"
|
|
else
|
|
pass "docker login uses --password-stdin + env (no inline secret)"
|
|
fi
|
|
else
|
|
fail "docker login uses --password-stdin + env (no inline secret)" "missing --password-stdin or echo \"\$DOCKER_PASSWORD\""
|
|
fi
|
|
}
|
|
|
|
# --- workflow: login uses DOCKER_LOGIN (not DOCKER_USERNAME) ------- #
|
|
check_docker_login_secret_name() {
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "docker login uses secrets.DOCKER_LOGIN" "file missing"
|
|
return
|
|
fi
|
|
if grep -Fq 'secrets.DOCKER_LOGIN' "$f"; then
|
|
pass "docker login uses secrets.DOCKER_LOGIN"
|
|
else
|
|
fail "docker login uses secrets.DOCKER_LOGIN" "secrets.DOCKER_LOGIN not referenced"
|
|
fi
|
|
file_not_contains "$f" "workflow does not use secrets.DOCKER_USERNAME" 'secrets\.DOCKER_USERNAME'
|
|
}
|
|
|
|
# --- workflow: push :VERSION + :latest + set -e ------------------- #
|
|
check_push_tags() {
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "workflow pushes :VERSION and :latest with set -e" "file missing"
|
|
return
|
|
fi
|
|
local has_version=0 has_latest=0 has_set_e=0
|
|
grep -Eq '\$\{?VERSION\}?|:\$\{VERSION\}' "$f" && has_version=1 || true
|
|
grep -Eq ':latest' "$f" && has_latest=1 || true
|
|
grep -Eq 'set -e' "$f" && has_set_e=1 || true
|
|
if [[ $has_version -eq 1 && $has_latest -eq 1 && $has_set_e -eq 1 ]]; then
|
|
pass "workflow pushes :VERSION and :latest with set -e"
|
|
else
|
|
fail "workflow pushes :VERSION and :latest with set -e" "VERSION=$has_version latest=$has_latest set-e=$has_set_e"
|
|
fi
|
|
}
|
|
|
|
# --- workflow: forbidden secrets ----------------------------------- #
|
|
check_forbidden_secrets() {
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "workflow forbids secrets.(GITEA_TOKEN|GITHUB_TOKEN) (use CI_GITEA_TOKEN)" "file missing"
|
|
fail "workflow forbids GEMINI_API_KEY + SUPABASE_SERVICE_ROLE_KEY" "file missing"
|
|
return
|
|
fi
|
|
file_not_contains "$f" "workflow forbids secrets.(GITEA_TOKEN|GITHUB_TOKEN) (use CI_GITEA_TOKEN)" 'secrets\.(GITEA_TOKEN|GITHUB_TOKEN)'
|
|
file_not_contains "$f" "workflow forbids GEMINI_API_KEY + SUPABASE_SERVICE_ROLE_KEY" '(GEMINI_API_KEY|SUPABASE_SERVICE_ROLE_KEY)'
|
|
}
|
|
|
|
# --- workflow: build-args from vars.NEXT_PUBLIC_* ------------------ #
|
|
check_buildargs_source() {
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "build-args fed by vars.NEXT_PUBLIC_* (not secrets.*)" "file missing"
|
|
return
|
|
fi
|
|
if grep -Eq 'vars\.NEXT_PUBLIC_(SUPABASE_URL|SUPABASE_ANON_KEY)' "$f"; then
|
|
pass "build-args fed by vars.NEXT_PUBLIC_*"
|
|
else
|
|
fail "build-args fed by vars.NEXT_PUBLIC_*" "no vars.NEXT_PUBLIC_* reference"
|
|
fi
|
|
}
|
|
|
|
# --- workflow: actions pinned by SHA (no @vN) --------------------- #
|
|
check_actions_pinned() {
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "actions pinned by SHA (no @vN)" "file missing"
|
|
return
|
|
fi
|
|
# Pour checkout / setup-node / setup-buildx-action : aucun @v<digit>
|
|
local bad
|
|
bad=$(grep -E 'uses:[[:space:]]+(actions/checkout|actions/setup-node|docker/setup-buildx-action)@v[0-9]' "$f" || true)
|
|
if [[ -z "$bad" ]]; then
|
|
pass "actions pinned by SHA (no @vN)"
|
|
else
|
|
fail "actions pinned by SHA (no @vN)" "non-pinned: $(echo "$bad" | tr '\n' '|')"
|
|
fi
|
|
}
|
|
|
|
# --- git context --------------------------------------------------- #
|
|
check_git_branch() {
|
|
local label="current branch = feat/admin-web-docker"
|
|
local b
|
|
b="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
if [[ "$b" == "feat/admin-web-docker" ]]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "branch='$b'"
|
|
fi
|
|
}
|
|
|
|
check_git_commit_prefix() {
|
|
local label="HEAD commit message starts with 'feat:'"
|
|
local subj
|
|
subj="$(git log -1 --format=%s 2>/dev/null || true)"
|
|
if [[ "$subj" == feat:* ]]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "subject='$subj'"
|
|
fi
|
|
}
|
|
|
|
# =================================================================== #
|
|
# PR BUILD-VALIDATION FEATURE CHECKS
|
|
# (trigger pull_request + job build-validation build-only/no-secret)
|
|
# =================================================================== #
|
|
|
|
# --- workflow: pull_request trigger with admin-web/** path filter --- #
|
|
check_pr_trigger() {
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
local label="workflow triggers on pull_request with admin-web/** path filter"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "$label" "file missing"
|
|
return
|
|
fi
|
|
local out
|
|
out=$(python3 -c "
|
|
import yaml,sys
|
|
d=yaml.safe_load(open(sys.argv[1]))
|
|
on=d.get(True) or d.get('on') or {}
|
|
if not isinstance(on,dict): print('NO-PR: on not a dict'); sys.exit()
|
|
pr=on.get('pull_request')
|
|
if pr is None: print('NO-PR: no pull_request key'); sys.exit()
|
|
paths=(pr.get('paths') if isinstance(pr,dict) else None) or []
|
|
print('|'.join(str(p) for p in paths))
|
|
" "$f" 2>/dev/null || echo "ERR")
|
|
if echo "$out" | grep -Eq 'admin-web/\*\*'; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "pull_request.paths=[$out]"
|
|
fi
|
|
}
|
|
|
|
# --- build-validation job: exists + if: pull_request ---------------- #
|
|
check_build_validation_if() {
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
local label="build-validation job if: github.event_name == 'pull_request'"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "$label" "file missing"
|
|
return
|
|
fi
|
|
local cond
|
|
cond=$(python3 -c "
|
|
import yaml,sys
|
|
d=yaml.safe_load(open(sys.argv[1]))
|
|
bv=d.get('jobs',{}).get('build-validation',{})
|
|
print(bv.get('if','') or '')
|
|
" "$f" 2>/dev/null || true)
|
|
if echo "$cond" | grep -Eq "github\.event_name\s*==\s*'pull_request'"; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "if='$cond'"
|
|
fi
|
|
}
|
|
|
|
# --- build-validation job: NO docker push (build-only) -------------- #
|
|
check_build_validation_no_push() {
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
local label="build-validation does NOT docker push (build-only)"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "$label" "file missing"
|
|
return
|
|
fi
|
|
local out
|
|
out=$(python3 -c "
|
|
import yaml,sys,json
|
|
d=yaml.safe_load(open(sys.argv[1]))
|
|
bv=d.get('jobs',{}).get('build-validation',{})
|
|
blob=json.dumps(bv)
|
|
print('PUSH' if 'docker push' in blob else 'NO-PUSH')
|
|
" "$f" 2>/dev/null || echo "ERR")
|
|
if [[ "$out" == "NO-PUSH" ]]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "docker push found in build-validation job"
|
|
fi
|
|
}
|
|
|
|
# --- build-validation job: NO secrets (public vars only) ------------ #
|
|
check_build_validation_no_secrets() {
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
local label="build-validation references no secrets (build-only, public vars only)"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "$label" "file missing"
|
|
return
|
|
fi
|
|
local out
|
|
out=$(python3 -c "
|
|
import yaml,sys,json
|
|
d=yaml.safe_load(open(sys.argv[1]))
|
|
bv=d.get('jobs',{}).get('build-validation',{})
|
|
blob=json.dumps(bv)
|
|
print('SECRET' if 'secrets.' in blob else 'NO-SECRET')
|
|
" "$f" 2>/dev/null || echo "ERR")
|
|
if [[ "$out" == "NO-SECRET" ]]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "secrets.* referenced in build-validation job"
|
|
fi
|
|
}
|
|
|
|
# --- semantic-release if excludes pull_request ---------------------- #
|
|
check_sr_excludes_pr() {
|
|
local f=".github/workflows/admin-web-docker.yml"
|
|
local label="semantic-release if excludes pull_request"
|
|
if [[ ! -f "$f" ]]; then
|
|
fail "$label" "file missing"
|
|
return
|
|
fi
|
|
local cond
|
|
cond=$(python3 -c "
|
|
import yaml,sys
|
|
d=yaml.safe_load(open(sys.argv[1]))
|
|
print(d.get('jobs',{}).get('semantic-release',{}).get('if','') or '')
|
|
" "$f" 2>/dev/null || true)
|
|
if echo "$cond" | grep -Eq "github\.event_name\s*!=\s*'pull_request'"; then
|
|
pass "$label"
|
|
else
|
|
fail "$label" "if missing pull_request exclusion (if='$cond')"
|
|
fi
|
|
}
|
|
|
|
# =================================================================== #
|
|
# RUN ALL
|
|
# =================================================================== #
|
|
|
|
if [[ "$SUMMARY_ONLY" -eq 0 ]]; then
|
|
echo "# verify-admin-web-docker.sh — structural validation"
|
|
echo "# repo: $ROOT"
|
|
echo ""
|
|
fi
|
|
|
|
check_version_json
|
|
check_root_package_json
|
|
check_root_pkg_lock
|
|
check_releaserc
|
|
check_dockerfile
|
|
check_next_config
|
|
check_dockerignore
|
|
check_workflow_valid
|
|
check_workflow_jobs
|
|
check_workflow_needs
|
|
check_skip_ci_guard
|
|
check_bp_if_condition
|
|
check_docker_login
|
|
check_docker_login_secret_name
|
|
check_push_tags
|
|
check_forbidden_secrets
|
|
check_buildargs_source
|
|
check_actions_pinned
|
|
check_git_branch
|
|
check_git_commit_prefix
|
|
# PR build-validation feature checks
|
|
check_pr_trigger
|
|
check_build_validation_if
|
|
check_build_validation_no_push
|
|
check_build_validation_no_secrets
|
|
check_sr_excludes_pr
|
|
|
|
echo ""
|
|
echo "# Summary: $PASS_COUNT passed, $FAIL_COUNT failed"
|
|
if [[ "$FAIL_COUNT" -gt 0 ]]; then
|
|
echo "# Failures:"
|
|
for f in "${FAIL_NAMES[@]}"; do
|
|
echo "# - $f"
|
|
done
|
|
fi
|
|
|
|
if [[ "$FAIL_COUNT" -gt 0 ]]; then
|
|
exit 1
|
|
fi
|
|
exit 0
|