GitLab Security Setup
Full security stack for your company's GitLab.com projects on the Free tier. Covers: pnpm 11 supply chain, Trivy weekly scan, HTML email reports via Gmail.
Placeholder:
{report_recipients}is a comma-separated list of email addresses that receive the vulnerability reports (e.g.you@example.com, teammate@example.com). Replace it everywhere it appears below with your own recipient address(es) before running.
What gets set up
- pnpm 11 with supply chain protection (
minimumReleaseAge, overrides) - Trivy vulnerability + secret scanner via GitLab CI
- Weekly scheduled pipeline (Monday 8am Madrid) with HTML email report
- Composer audit for PHP/Laravel projects
- Gmail SMTP delivery via GitLab CI/CD variables
Step 1 — pnpm 11 Supply Chain
pnpm-workspace.yaml (create or update)
# WARNING: single-package repos do NOT need a `packages:` block on pnpm 11.
# BUT on pnpm 9 (Vercel default for older projects) the mere presence of this
# file REQUIRES a non-empty `packages:` or install dies with
# "packages field missing or empty". If targeting pnpm 9, add `packages: ['.']`.
minimumReleaseAge: 4320 # packages must be 72h old before install (minutes)
# Block transitive deps from git repos / raw tarball URLs (needs pnpm 10.26+,
# silently inert below). See supply-chain-security skill for the full checklist.
blockExoticSubdeps: true
# Allowlist for postinstall/build scripts. pnpm 10+ blocks ALL by default.
# List ONLY packages that genuinely need to compile. pnpm will prompt you to
# add new entries when you install a dep with a blocked build script.
allowBuilds:
esbuild: true
sharp: true
# lightningcss-cli: true # uncomment if using lightningcss
overrides:
form-data: ">=4.0.6"
axios: ">=1.15.2"
lodash: ">=4.18.0"
picomatch: ">=4.0.4"
qs: ">=6.14.2"
shell-quote: ">=1.8.4"
Rules:
minimumReleaseAgeis in minutes (4320 = 72h). Blocks supply chain attacks via typosquatting/fast-publish.blockExoticSubdepsneeds pnpm 10.26+ — silently inert below. Check the pinned version.allowBuilds— pnpm 10+ blocks all postinstall scripts by default. Add ONLY packages that need to compile. pnpm 11 will tell you during install if a new dep needs adding here.overridespins known vulnerable transitive deps. Add new entries as CVEs appear.- Do NOT put
minimumReleaseAgein.npmrc— pnpm 11 reads it frompnpm-workspace.yamlonly. - Real test is CI:
pnpm install --frozen-lockfileon a clean machine enforces all policies; a warm local cache skips them.
package.json additions
{
"packageManager": "pnpm@11.1.2",
"private": true
}
Pin the exact version (not 11.x.x) so CI/Vercel use the version you tested.
Remove any overrides or pnpm.overrides blocks from package.json — they belong in pnpm-workspace.yaml for pnpm 11.
publicar deploy script (if project has one)
#!/bin/bash
php artisan migrate --force
pnpm build
Ensure it uses pnpm, not npm run.
GitHub Actions lint workflow (if exists)
Replace npm ci / npm install / npm run with:
- run: npm install -g pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run format
- run: pnpm run lint
Step 2 — GitLab CI Trivy Scan
Create or update .gitlab-ci.yml:
dependency-scan:
image:
name: aquasec/trivy:latest
entrypoint: [""]
before_script:
- apk add --no-cache curl python3 py3-packaging
script:
# JSON report (structured data) — vuln + secret scan
- trivy fs --exit-code 0 --scanners vuln,secret --format json -o trivy-report.json . 2>/dev/null
# HTML report artifact
- trivy fs --exit-code 0 --scanners vuln,secret --format template --template "@/contrib/html.tpl" -o trivy-report.html . 2>/dev/null || true
# Parse JSON and build HTML email
- |
python3 << 'PYEOF'
import json, os
with open("trivy-report.json") as f:
data = json.load(f)
from packaging.version import Version, InvalidVersion
def highest_fix(fixed_str):
if not fixed_str or fixed_str == "N/A":
return None
parts = [p.strip() for p in fixed_str.split(",") if p.strip()]
parsed = []
for p in parts:
try:
parsed.append((Version(p), p))
except InvalidVersion:
parsed.append((Version("0"), p))
return max(parsed, key=lambda x: x[0])[1] if parsed else None
grouped = {}
severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "UNKNOWN": 4}
for result in data.get("Results", []):
for v in result.get("Vulnerabilities", []):
key = (v.get("PkgName", ""), v.get("InstalledVersion", ""))
fixed_raw = v.get("FixedVersion", "")
sev = v.get("Severity", "UNKNOWN")
cve = v.get("VulnerabilityID", "")
fix = highest_fix(fixed_raw)
if key not in grouped:
grouped[key] = {"pkg": key[0], "installed": key[1], "severity": sev, "cves": [], "fixes": []}
entry = grouped[key]
if severity_order.get(sev, 5) < severity_order.get(entry["severity"], 5):
entry["severity"] = sev
if cve:
entry["cves"].append(cve)
if fix:
entry["fixes"].append(fix)
def max_version(versions):
parsed = []
for v in versions:
try:
parsed.append((Version(v), v))
except InvalidVersion:
pass
return max(parsed, key=lambda x: x[0])[1] if parsed else None
vulns = list(grouped.values())
for entry in vulns:
entry["best_fix"] = max_version(entry["fixes"])
vulns.sort(key=lambda x: severity_order.get(x["severity"], 5))
counts = {s: sum(1 for v in vulns if v["severity"] == s) for s in ["CRITICAL", "HIGH", "MEDIUM", "LOW"]}
total = len(vulns)
sev_colors = {
"CRITICAL": ("#fff1f2", "#be123c", "#fecdd3"),
"HIGH": ("#fffbeb", "#b45309", "#fde68a"),
"MEDIUM": ("#eff6ff", "#1d4ed8", "#bfdbfe"),
"LOW": ("#f9fafb", "#374151", "#e5e7eb"),
}
project = os.environ.get("CI_PROJECT_NAME", "")
branch = os.environ.get("CI_COMMIT_REF_NAME", "")
sha = os.environ.get("CI_COMMIT_SHORT_SHA", "")
pipeline_url = os.environ.get("CI_PIPELINE_URL", "#")
gmail_user = os.environ.get("GMAIL_USER", "")
# Comma-separated recipient list — set REPORT_RECIPIENTS as a CI/CD variable
report_recipients = os.environ.get("REPORT_RECIPIENTS", "")
pipeline_created_at = os.environ.get("CI_PIPELINE_CREATED_AT", "")
scan_date = pipeline_created_at[:10] if pipeline_created_at else ""
scan_ts = pipeline_created_at.replace("T", " ").replace("Z", " UTC") if pipeline_created_at else ""
def cve_links(cves, limit=5):
links = []
for cve in cves[:limit]:
if cve.startswith("CVE-"):
links.append(f"<a href='https://nvd.nist.gov/vuln/detail/{cve}' style='color:#f97316;text-decoration:none'>{cve}</a>")
else:
links.append(cve)
out = ", ".join(links)
if len(cves) > limit:
out += f" <span style='color:#98A2B3'>+{len(cves)-limit} more</span>"
return out
sev_pills = {
"CRITICAL": ("background:#FFF1F3;color:#C01048;border:1px solid #FFC5D0", "C"),
"HIGH": ("background:#FFFAEB;color:#B54708;border:1px solid #FEDF89", "H"),
"MEDIUM": ("background:#EFF8FF;color:#175CD3;border:1px solid #B2DDFF", "M"),
"LOW": ("background:#F9FAFB;color:#344054;border:1px solid #D0D5DD", "L"),
}
dot = {"CRITICAL": "#F04438", "HIGH": "#F79009", "MEDIUM": "#2E90FA", "LOW": "#98A2B3"}
rows = ""
for i, v in enumerate(vulns):
pill_style, _ = sev_pills.get(v["severity"], ("background:#F9FAFB;color:#344054;border:1px solid #D0D5DD", "?"))
fixed = v["best_fix"] if v["best_fix"] else "<span style='color:#98A2B3'>No fix yet</span>"
cves_html = cve_links(v["cves"])
sep = "border-bottom:1px solid #EAECF0;" if i < len(vulns) - 1 else ""
rows += f"""<tr style='background:#ffffff'>
<td style='padding:8px 16px;{sep}font-size:13px;color:#101828;font-weight:500;white-space:nowrap'>{v['pkg']}</td>
<td style='padding:8px 16px;{sep}font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#475467'>{v['installed']}</td>
<td style='padding:8px 16px;{sep}font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#101828;font-weight:500'>{fixed}</td>
<td style='padding:8px 16px;{sep}white-space:nowrap'>
<span style='{pill_style};padding:2px 7px;border-radius:9999px;font-size:11px;font-weight:500;display:inline-block'>{v['severity'].capitalize()}</span>
</td>
<td style='padding:8px 16px;{sep}font-size:12px;color:#475467'>{cves_html}</td>
</tr>"""
badges = "".join([
f"<span style='display:inline-block;padding:3px 10px;border-radius:9999px;font-size:12px;font-weight:500;margin-right:6px;{sev_pills[s][0]}'>{s.capitalize()} {counts[s]}</span>"
for s in ["CRITICAL", "HIGH", "MEDIUM", "LOW"] if counts[s] > 0
])
table_section = "" if not vulns else f"""
<table border='0' width='100%' cellpadding='0' cellspacing='0' role='presentation' style='border-collapse:separate;border-spacing:0;border:1px solid #EAECF0;border-radius:8px;overflow:hidden;margin-top:20px'>
<thead>
<tr style='background:#F9FAFB'>
<th style='padding:10px 16px;text-align:left;font-size:11px;font-weight:500;color:#475467;border-bottom:1px solid #EAECF0'>Package</th>
<th style='padding:10px 16px;text-align:left;font-size:11px;font-weight:500;color:#475467;border-bottom:1px solid #EAECF0'>Installed</th>
<th style='padding:10px 16px;text-align:left;font-size:11px;font-weight:500;color:#475467;border-bottom:1px solid #EAECF0'>Fix to</th>
<th style='padding:10px 16px;text-align:left;font-size:11px;font-weight:500;color:#475467;border-bottom:1px solid #EAECF0'>Severity</th>
<th style='padding:10px 16px;text-align:left;font-size:11px;font-weight:500;color:#475467;border-bottom:1px solid #EAECF0'>CVE</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>"""
no_vulns = """
<div style='text-align:center;padding:48px 0'>
<p style='font-size:18px;font-weight:600;color:#111827;margin:0 0 8px'>All clear!</p>
<p style='font-size:14px;color:#6b7280;margin:0'>No vulnerabilities found in this scan.</p>
</div>""" if not vulns else ""
md_lines = [
f"# Security Scan — {project}",
f"Branch: {branch} | Commit: {sha}" + (f" | {scan_date}" if scan_date else ""),
"", "## Vulnerabilities", "",
"| Package | Installed | Fix version | Severity | CVEs |",
"|---------|-----------|-------------|----------|------|",
]
for v in vulns:
fix = v["best_fix"] if v["best_fix"] else "No fix yet"
md_lines.append(f"| {v['pkg']} | {v['installed']} | {fix} | {v['severity']} | {', '.join(v['cves'])} |")
md_lines += ["", "## Task",
"Review these vulnerabilities. For each package identify if it's a direct or transitive dependency and provide the exact command to update it."]
md_content = "\n".join(md_lines)
with open("trivy-report.md", "w") as f:
f.write(md_content)
md_escaped = md_content.replace("&", "&").replace("<", "<").replace(">", ">")
summary_text = f"{total} package{'s' if total != 1 else ''} with known vulnerabilities"
if scan_ts:
summary_text += f" · {scan_ts}"
meta_date = f' <span style="color:rgb(163,163,163)">{scan_date}</span>' if scan_date else ""
subject_suffix = f" · {scan_date}" if scan_date else ""
subject = f"[{project}] Security Scan · {counts.get('CRITICAL',0)}C {counts.get('HIGH',0)}H{subject_suffix}"
html = (
f"From: {gmail_user}\r\nTo: {report_recipients}\r\nSubject: {subject}\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n"
f"<!DOCTYPE html><html dir='ltr' lang='en'><head>"
f"<meta content='text/html; charset=UTF-8' http-equiv='Content-Type'>"
f"<meta name='color-scheme' content='light'><meta name='supported-color-schemes' content='light'></head>"
f"<body style='background-color:rgb(250,250,250);margin:0;padding:0'>"
f"<table border='0' width='100%' cellpadding='0' cellspacing='0' role='presentation' align='center'><tbody><tr>"
f"<td style='margin:0;background-color:rgb(250,250,250);padding:0;font-family:Inter,-apple-system,Segoe UI,system-ui,Roboto,Arial,sans-serif;-webkit-font-smoothing:antialiased'>"
f"<table align='center' width='100%' border='0' cellpadding='0' cellspacing='0' role='presentation' style='max-width:640px;width:100%;background-color:rgb(250,250,250)'>"
f"<tbody><tr style='width:100%'><td>"
f"<table align='left' width='100%' border='0' cellpadding='0' cellspacing='0' role='presentation' style='max-width:100%;background-color:rgb(255,255,255);padding:24px'>"
f"<tbody><tr style='width:100%'><td>"
f"<table border='0' width='100%' cellpadding='0' cellspacing='0' role='presentation'><tbody><tr>"
f"<td style='vertical-align:middle'>"
f"<img alt='DepSheriff' src='https://ik.imagekit.io/16u211libb/security-logo.png' style='display:inline-block;outline:none;border:none;text-decoration:none;height:32px;width:auto;vertical-align:middle;margin-right:10px'>"
f"<span style='font-size:15px;font-weight:600;color:rgb(23,23,23);vertical-align:middle'>DepSheriff</span></td>"
f"<td style='text-align:right;vertical-align:middle'><span style='font-size:11px;color:rgb(163,163,163);font-family:ui-monospace,monospace'>{project}</span></td>"
f"</tr></tbody></table></td></tr></tbody></table>"
f"<table align='left' width='100%' border='0' cellpadding='0' cellspacing='0' role='presentation' style='max-width:100%;background-color:rgb(255,255,255);padding:32px 24px'>"
f"<tbody><tr style='width:100%'><td>"
f"<p style='font-size:24px;line-height:32px;margin:0;font-weight:600;color:rgb(23,23,23)'>Security Scan Report</p>"
f"<table border='0' width='100%' cellpadding='0' cellspacing='0' role='presentation' style='margin-top:8px;margin-bottom:32px'>"
f"<tbody><tr><td style='font-size:14px;color:rgb(82,82,82)'>"
f"Branch <code style='background:#f3f4f6;padding:1px 5px;border-radius:4px;font-size:12px'>{branch}</code>"
f" Commit <code style='background:#f3f4f6;padding:1px 5px;border-radius:4px;font-size:12px'>{sha}</code>"
f"{meta_date}</td></tr></tbody></table>"
f"<div style='margin-bottom:6px'>{badges}</div>"
f"<p style='font-size:13px;color:rgb(163,163,163);margin:0 0 8px;line-height:20px'>{summary_text}</p>"
f"{no_vulns}{table_section}"
f"<table border='0' width='100%' cellpadding='0' cellspacing='0' role='presentation' style='margin-top:28px'><tbody><tr><td>"
f"<p style='font-size:14px;font-weight:600;color:#101828;margin:0 0 8px 0'>Paste to Agent</p>"
f"<p style='font-size:14px;color:#475467;margin:0 0 12px 0'>Copy the block below into your AI agent for fix recommendations.</p>"
f"<pre style='margin:0;background:#F9FAFB;padding:16px;border-radius:8px;font-size:11px;white-space:pre-wrap;border:1px solid #EAECF0;color:#344054;overflow-x:auto;line-height:1.6'>{md_escaped}</pre>"
f"</td></tr></tbody></table>"
f"<table align='center' border='0' width='100%' cellpadding='0' cellspacing='0' role='presentation' style='margin-top:32px'>"
f"<tbody><tr style='width:100%'><td align='center'>"
f"<a href='{pipeline_url}' style='line-height:24px;text-decoration:none;display:inline-block;border-radius:8px;background-color:#f97316;border:1px solid #ea6d10;color:#ffffff;padding:10px 20px;font-size:14px;font-weight:600'>"
f"View Pipeline & Download Artifacts</a></td></tr></tbody></table>"
f"</td></tr></tbody></table>"
f"<table align='left' width='100%' border='0' cellpadding='0' cellspacing='0' role='presentation' style='max-width:100%;background-color:rgb(255,255,255);padding:24px'>"
f"<tbody><tr style='width:100%'><td>"
f"<table align='center' width='100%' border='0' cellpadding='0' cellspacing='0' role='presentation' style='margin-bottom:24px'>"
f"<tbody><tr><td><hr style='width:100%;border:none;border-top:1px solid rgb(229,229,229)'></td></tr></tbody></table>"
f"<p style='font-size:13px;line-height:20px;margin:0;text-align:center;color:rgb(163,163,163)'>DepSheriff — because npm audit alone wasn't enough</p>"
f"<p style='font-size:11px;margin:10px 0 0;text-align:center;color:rgb(163,163,163)'>Creado por <a href='https://www.linkedin.com/in/educlopez/' style='color:rgb(163,163,163);text-decoration:underline'>Edu Calvo</a></p>"
f"</td></tr></tbody></table>"
f"</td></tr></tbody></table></td></tr></tbody></table></body></html>"
)
with open("email_body.txt", "w") as f:
f.write(html)
print(f"Vulnerabilities found: {total} (CRITICAL: {counts['CRITICAL']}, HIGH: {counts['HIGH']})")
PYEOF
- |
# REPORT_RECIPIENTS is a comma-separated CI/CD variable (e.g. "a@x.com,b@y.com").
# Build one --mail-rcpt flag per address. POSIX sh only — the Trivy Alpine image
# runs /bin/sh (busybox), so NO bash-isms: no `read -ra` arrays, no `<<<` here-strings
# (they fail with "syntax error: unexpected redirection"). Word-split on IFS=','
# instead (leave $REPORT_RECIPIENTS unquoted on purpose).
RCPT_ARGS=""
OLD_IFS="$IFS"; IFS=','
for addr in $REPORT_RECIPIENTS; do
addr="$(echo "$addr" | xargs)" # trim whitespace
[ -n "$addr" ] && RCPT_ARGS="$RCPT_ARGS --mail-rcpt $addr"
done
IFS="$OLD_IFS"
curl --url "smtps://smtp.gmail.com:465" \
--ssl-reqd \
--mail-from "$GMAIL_USER" \
$RCPT_ARGS \
--user "$GMAIL_USER:$GMAIL_APP_PASS" \
-T email_body.txt
artifacts:
paths:
- trivy-report.html
- trivy-report.json
- trivy-report.md
expire_in: 30 days
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
Key implementation details:
entrypoint: [""]— mandatory; Trivy Docker image has no shell otherwise (exit code 127)--exit-code 0— never fail the pipeline; email even when clean--scanners vuln,secret— covers both dependency CVEs and leaked secretsrules: schedule— only runs on scheduled pipelines, not every push- Vulnerabilities grouped by
(package, installed_version)— one row per package, showing highest severity and best fix version py3-packagingvia apk — not pip (pip is blocked in Alpine CI)- Recipients are parametrized via the
REPORT_RECIPIENTSCI/CD variable (comma-separated) in BOTH the PythonTo:header and the curl--mail-rcptloop — never hardcode addresses in the job. - "DepSheriff" branded email — modern Untitled-UI HTML (Inter font, rounded severity pills, orange accent, logo). Trivy's
contrib/html.tplartifact still ships too. - CVE → NVD links via
cve_links()(first 5 linked, rest collapsed to+N more). - Scan date/timestamp from
CI_PIPELINE_CREATED_AT, shown in the header and the subject ([proj] Security Scan · 2C 5H · YYYY-MM-DD). - "Paste to Agent" block — a markdown table of findings ready to drop into any AI agent for fix recommendations.
- The logo (
ik.imagekit.io/.../security-logo.png) and footer credit are the DepSheriff brand — keep or swap for your own.
Step 3 — Gmail CI/CD Variables
In GitLab project: Settings → CI/CD → Variables
| Variable | Value | Protected | Masked |
|---|---|---|---|
GMAIL_USER |
your-account@gmail.com |
No | No |
GMAIL_APP_PASS |
App password from Google | No | Yes |
REPORT_RECIPIENTS |
you@example.com,teammate@example.com (comma-separated) |
No | No |
Set
REPORT_RECIPIENTSto your own recipient address(es) — this is who receives the vulnerability report email.
Getting Gmail App Password:
- Google Account → Security → 2-Step Verification (must be ON)
- Search "App passwords" → Create → name it "GitLab CI"
- Copy the 16-char password → paste as
GMAIL_APP_PASS
Step 4 — Weekly Scheduled Pipeline
Create via GitLab API (run once per project):
curl --request POST \
--header "PRIVATE-TOKEN: <your-gitlab-token>" \
"https://gitlab.com/api/v4/projects/<PROJECT_ID>/pipeline_schedules" \
--form "description=Weekly security scan" \
--form "ref=main" \
--form "cron=0 7 * * 1" \
--form "cron_timezone=Europe/Madrid"
0 7 * * 1= Monday 07:00 UTC = 08:00/09:00 Madrid (winter/summer)ref= default branch (mainordevelop)PROJECT_ID= GitLab project → Settings → General
Or via UI: CI/CD → Schedules → New schedule
Trigger manually to test:
curl --request POST \
--header "PRIVATE-TOKEN: <token>" \
"https://gitlab.com/api/v4/projects/<PROJECT_ID>/pipeline_schedules/<SCHEDULE_ID>/play"
Step 5 — PHP/Composer Projects (Laravel)
The Trivy fs scan above already reads composer.lock and reports PHP CVEs in
the email — so PHP deps are covered out of the box. composer audit adds the
Packagist Security Advisories feed on top (some advisories land there before a
CVE is assigned).
Do NOT add composer audit inline to the dependency-scan job — the aquasec/trivy
Alpine image has no PHP/Composer binary, so it would silently no-op. Add a separate
job with a composer image instead:
# Packagist advisory audit — complements the Trivy composer.lock scan.
# Output goes to the job log (not the email). Non-blocking.
composer-audit:
image: composer:2
script:
- composer audit --no-interaction --format=plain || true
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
allow_failure: true
For fixing PHP vulnerabilities locally:
# Update specific packages
composer update "symfony/*" --with-all-dependencies
# Update all PHP deps (careful — test after)
composer update
Common PHP transitive dep CVEs — update these when flagged:
symfony/*— update to latest patch on your major (e.g.7.4.x)phpunit/phpunit+pestphp/pest— must update together:composer update phpunit/phpunit pestphp/pest --with-all-dependenciesleague/commonmark,psy/psysh—composer update <package>
Updating overrides for new CVEs
When the scan reports a fixable HIGH/CRITICAL on a transitive dep:
- Check if it's in
pnpm-workspace.yamloverridesalready → bump version - If new package → add entry:
package-name: ">=fixed-version" - Run
pnpm installto regenerate lockfile - Commit + push → next weekly scan should show it resolved
Skip alpha/RC fixes: If the only fix is an alpha (e.g. 8.0.0-alpha.17), skip — wait for stable release.
Adapting for different project types
| Project type | Notes |
|---|---|
| Laravel | Include composer audit step; publicar = php artisan migrate --force && pnpm build |
| Astro | No composer step; publicar = pnpm build |
| TanStack / pure frontend | No composer step; check pnpm-workspace.yaml at root |
| No Node | Skip pnpm setup; Trivy still scans PHP deps |
Checklist for new project
-
pnpm-workspace.yamlcreated withminimumReleaseAge: 4320+blockExoticSubdeps: true+overrides+allowBuilds(add only what compiles) -
package.jsonhas"packageManager": "pnpm@11.x.x"(exact version),"private": true, nooverridesblock - Verified with clean
pnpm install --frozen-lockfile(not just warm cache) -
.gitlab-ci.ymlhasdependency-scanjob (Step 2) with recipients viaREPORT_RECIPIENTS(never hardcoded) - Laravel/PHP: separate
composer-auditjob added (Step 5) — NOT inline in the trivy job - GitLab CI/CD variables set:
GMAIL_USER,GMAIL_APP_PASS,REPORT_RECIPIENTS - Pipeline schedule created (Monday 8am Madrid)
- Manual trigger test → email received by every address in
REPORT_RECIPIENTS -
pnpm installruns clean locally