Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions .github/workflows/verify-vercel-env-vars.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# Vercel Environment Variable Drift Detection
#
# Compares env var keys across three Vercel projects and posts a Slack alert
# if any variables are present in some projects but not all.
#
# Required secrets:
# VERCEL_TOKEN — Vercel API token with read access to all three projects
# VERCEL_TEAM_ID — Vercel team/org ID (if projects are under a team)
# SLACK_ENV_DRIFT_WEBHOOK_URL — Slack incoming webhook URL for drift alerts

name: Verify Vercel Env Vars

on:
schedule:
- cron: "0 6 * * *" # 6 AM UTC daily
workflow_dispatch:

permissions:
contents: read

jobs:
check-env-drift:
runs-on: ubuntu-latest
steps:
- name: Compare env vars across Vercel projects
uses: actions/github-script@v7
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_ENV_DRIFT_WEBHOOK_URL }}
WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const PROJECTS = [
"kilocode-app",
"kilocode-global-app",
"kilocode-gateway",
];

const TARGETS = ["production", "preview", "development"];

// Vercel-managed or expected-to-differ env vars.
// Add entries here to exclude them from drift detection.
const IGNORED_KEYS = new Set([
"VERCEL",
"VERCEL_URL",
"VERCEL_ENV",
"VERCEL_GIT_COMMIT_SHA",
"VERCEL_GIT_COMMIT_MESSAGE",
"VERCEL_GIT_COMMIT_AUTHOR_LOGIN",
"VERCEL_GIT_COMMIT_AUTHOR_NAME",
"VERCEL_GIT_COMMIT_REF",
"VERCEL_GIT_PROVIDER",
"VERCEL_GIT_REPO_SLUG",
"VERCEL_GIT_REPO_OWNER",
"VERCEL_GIT_REPO_ID",
"VERCEL_GIT_PULL_REQUEST_ID",
"VERCEL_BRANCH_URL",
"VERCEL_PROJECT_PRODUCTION_URL",
"VERCEL_AUTOMATION_BYPASS_SECRET",
]);

// --- Fetch env vars from the Vercel API ---

async function fetchEnvVars(projectName) {
const url = new URL(`https://api.vercel.com/v10/projects/${encodeURIComponent(projectName)}/env`);
url.searchParams.set("teamId", process.env.VERCEL_TEAM_ID);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Missing guard for VERCEL_TEAM_ID

If the VERCEL_TEAM_ID secret is not configured, process.env.VERCEL_TEAM_ID will be undefined, and url.searchParams.set("teamId", undefined) will set the query parameter to the literal string "undefined". This could cause the Vercel API to return an error or unexpected results.

The file header (line 8) implies this is conditionally needed ("if projects are under a team"), but there's no guard here. Consider either:

  • Making it required and failing fast if missing, or
  • Conditionally adding the param:
if (process.env.VERCEL_TEAM_ID) {
  url.searchParams.set("teamId", process.env.VERCEL_TEAM_ID);
}

// Request up to 100 env vars per page (Vercel default limit is 20)
url.searchParams.set("limit", "100");

const allEnvs = [];
let nextUrl = url.toString();

while (nextUrl) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Pagination loop has no upper bound

If the Vercel API returns the same pagination.next cursor repeatedly (due to a bug or API change), this while (nextUrl) loop will run indefinitely, causing the workflow to hang until the GitHub Actions timeout (6 hours by default).

Consider adding a max iteration guard:

const MAX_PAGES = 20;
let page = 0;
while (nextUrl && page++ < MAX_PAGES) {

const res = await fetch(nextUrl, {
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
});

if (!res.ok) {
const body = await res.text();
throw new Error(`Vercel API error for ${projectName}: ${res.status} ${body}`);
}

const data = await res.json();
const envs = data.envs ?? data;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: Silent fallback on unexpected API response shape

data.envs ?? data falls back to the entire response object if envs is missing. Combined with the Array.isArray check on the next line, this means an unexpected API response shape would silently produce zero env vars for that page — no error, no warning. This could mask a real problem (e.g., API version change).

Consider logging a warning when the response shape is unexpected:

const envs = data.envs;
if (!envs) {
  core.warning(`Unexpected Vercel API response shape for ${projectName} — missing 'envs' key`);
}
allEnvs.push(...(Array.isArray(envs) ? envs : []));

allEnvs.push(...(Array.isArray(envs) ? envs : []));

// Handle pagination
const next = data.pagination?.next;
if (next) {
const paginatedUrl = new URL(url.toString());
paginatedUrl.searchParams.set("until", String(next));
nextUrl = paginatedUrl.toString();
} else {
nextUrl = null;
}
}

return allEnvs;
}

// --- Build per-target key sets for each project ---

// envsByProject: Map<projectName, Map<target, Set<key>>>
const envsByProject = new Map();

for (const project of PROJECTS) {
const envVars = await fetchEnvVars(project);
const targetMap = new Map(TARGETS.map((t) => [t, new Set()]));

for (const env of envVars) {
if (IGNORED_KEYS.has(env.key)) continue;

// env.target can be a string or an array of strings
const targets = Array.isArray(env.target) ? env.target : [env.target];
for (const t of targets) {
if (targetMap.has(t)) {
targetMap.get(t).add(env.key);
}
}
}

envsByProject.set(project, targetMap);
}

// --- Compare across projects per target ---

// mismatches: Map<target, Array<{ key, missingFrom: string[] }>>
const mismatches = new Map();

for (const target of TARGETS) {
// Collect the union of all keys for this target
const allKeys = new Set();
for (const project of PROJECTS) {
for (const key of envsByProject.get(project).get(target)) {
allKeys.add(key);
}
}

const targetMismatches = [];
for (const key of [...allKeys].sort()) {
const missingFrom = PROJECTS.filter(
(p) => !envsByProject.get(p).get(target).has(key)
);
if (missingFrom.length > 0) {
targetMismatches.push({ key, missingFrom });
}
}

if (targetMismatches.length > 0) {
mismatches.set(target, targetMismatches);
}
}

// --- Report results ---

if (mismatches.size === 0) {
core.info("No env var drift detected across projects. All good!");
return;
}

// Build Slack message
const lines = [
":warning: *Vercel Env Var Drift Detected*",
"",
`The following environment variables are not consistent across all three Vercel projects (${PROJECTS.join(", ")}):`,
];

for (const target of TARGETS) {
const items = mismatches.get(target);
if (!items) continue;
lines.push("");
lines.push(`*${target.charAt(0).toUpperCase() + target.slice(1)}:*`);
for (const { key, missingFrom } of items) {
lines.push(` • \`${key}\` — missing from: ${missingFrom.join(", ")}`);
}
}

lines.push("");
lines.push(`<${process.env.WORKFLOW_RUN_URL}|View workflow run>`);

const message = lines.join("\n");

// Log to the workflow output as well
core.info(message);

// Post to Slack
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
if (!webhookUrl) {
core.warning("SLACK_ENV_DRIFT_WEBHOOK_URL secret is not set — skipping Slack notification.");
return;
}

const slackRes = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: message }),
});

if (!slackRes.ok) {
const body = await slackRes.text();
throw new Error(`Slack webhook error: ${slackRes.status} ${body}`);
}

core.info("Slack notification sent successfully.");