ci(release): gate promote-main on explicit main checks (#349)

* ci(release): gate promote-main on explicit main checks

Add an always-on ci-main workflow that produces a stable check name, and validate that check via check runs (not branch protection APIs). Wire promote-main-to-next to require ci-main / ci-ok and document the required check in the release SOP.

* ci(release): accept ci-ok or UI-style name in main promote gate

GitHub check run names for normal workflows are often the job name only;
keep optional match for the UI-style workflow/job label. Drop unused
statuses:read permission.

Made-with: Cursor
This commit is contained in:
cucadmuh
2026-04-29 00:22:51 +03:00
committed by GitHub
parent 8fe75b3d74
commit a9573625f4
4 changed files with 77 additions and 63 deletions
+15
View File
@@ -0,0 +1,15 @@
name: ci-main
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
ci-ok:
runs-on: ubuntu-latest
steps:
- name: ci sentinel
run: echo "main CI sentinel is green"
@@ -8,6 +8,7 @@ jobs:
uses: ./.github/workflows/validate-main-green-ci.yml
with:
branch: main
required_contexts: ci-ok|ci-main / ci-ok
promote:
needs: validate-main
+60 -62
View File
@@ -8,58 +8,55 @@ on:
required: false
default: main
type: string
required_contexts:
description: "Comma-separated required checks; use | for alternatives (e.g. ci-ok|ci-main / ci-ok)"
required: false
default: ci-ok|ci-main / ci-ok
type: string
workflow_call:
inputs:
branch:
required: false
default: main
type: string
required_contexts:
required: false
default: ci-ok|ci-main / ci-ok
type: string
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
checks: read
statuses: read
steps:
- name: validate branch checks are green
- name: validate required checks
uses: actions/github-script@v7
env:
TARGET_BRANCH: ${{ inputs.branch || github.event.inputs.branch || 'main' }}
REQUIRED_CONTEXTS: ${{ inputs.required_contexts || github.event.inputs.required_contexts || 'ci-ok|ci-main / ci-ok' }}
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const branch = process.env.TARGET_BRANCH || "main";
const groups = String(process.env.REQUIRED_CONTEXTS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((group) =>
group
.split("|")
.map((s) => s.trim())
.filter(Boolean)
);
const runId = String(context.runId);
const branchResp = await github.rest.repos.getBranch({ owner, repo, branch });
const sha = branchResp.data.commit.sha;
core.info(`Validating checks for ${branch} @ ${sha}`);
let requiredContexts = [];
try {
const protection = await github.rest.repos.getBranchProtection({
owner,
repo,
branch,
});
requiredContexts = protection.data.required_status_checks?.contexts || [];
} catch (e) {
core.warning(`Could not read branch protection for ${branch}: ${e.message}`);
}
const combined = await github.rest.repos.getCombinedStatusForRef({
owner,
repo,
ref: sha,
});
const statusState = combined.data.state;
const statusCount = combined.data.statuses.length;
core.info(`Combined status state: ${statusState} (${statusCount} status contexts)`);
const checksAll = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
@@ -67,52 +64,53 @@ jobs:
per_page: 100,
});
const checks = checksAll.filter((c) => !(c.details_url || "").includes(`/actions/runs/${runId}/`));
const statuses = combined.data.statuses.filter((s) => !(s.target_url || "").includes(`/actions/runs/${runId}/`));
if (checks.length === 0 && statuses.length === 0) {
core.setFailed(`No checks/statuses found for ${branch}. Refusing promotion.`);
return;
const newestByName = new Map();
for (const c of checks) {
const key = c.name;
const prev = newestByName.get(key);
if (!prev) {
newestByName.set(key, c);
continue;
}
const prevTime = Date.parse(prev.started_at || prev.completed_at || "") || 0;
const curTime = Date.parse(c.started_at || c.completed_at || "") || 0;
if (curTime >= prevTime) {
newestByName.set(key, c);
}
}
if (requiredContexts.length > 0) {
core.info(`Required contexts from branch protection: ${requiredContexts.join(", ")}`);
const statusByContext = new Map(statuses.map((s) => [s.context, s.state]));
const failures = [];
for (const ctx of requiredContexts) {
const state = statusByContext.get(ctx);
if (!state) {
failures.push(`${ctx}: missing`);
const failures = [];
for (const alternatives of groups) {
let ok = false;
const altNotes = [];
for (const name of alternatives) {
const latest = newestByName.get(name);
if (!latest) {
altNotes.push(`${name}: missing`);
continue;
}
if (state !== "success") {
failures.push(`${ctx}: ${state}`);
if (latest.status !== "completed") {
altNotes.push(`${name}: status=${latest.status}, conclusion=${latest.conclusion}`);
continue;
}
if (["success", "neutral", "skipped"].includes(latest.conclusion || "")) {
ok = true;
break;
}
altNotes.push(`${name}: conclusion=${latest.conclusion}`);
}
if (failures.length > 0) {
core.setFailed(`Required checks for ${branch} are not green:\n${failures.join("\n")}`);
return;
if (!ok) {
failures.push(`(${alternatives.join(" | ")}): none green — ${altNotes.join("; ")}`);
}
core.info(`All required checks for ${branch} are green.`);
}
if (failures.length > 0) {
core.setFailed(`Required checks for ${branch} are not green:\n${failures.join("\n")}`);
return;
}
core.warning("No required status contexts configured; using fallback all-check validation.");
const badChecks = checks.filter((c) => {
if (c.status !== "completed") return true;
return !["success", "neutral", "skipped"].includes(c.conclusion || "");
});
if (badChecks.length > 0) {
const details = badChecks.map((c) => `${c.name}: status=${c.status}, conclusion=${c.conclusion}`).join("\n");
core.setFailed(`Branch ${branch} is not green.\n${details}`);
return;
}
const badStatuses = statuses.filter((s) => !["success"].includes(s.state || ""));
if (badStatuses.length > 0) {
const statusDetails = badStatuses.map((s) => `${s.context}: ${s.state}`).join("\n");
core.setFailed(`Branch ${branch} has failing/pending status contexts.\n${statusDetails}`);
return;
}
core.info(`Branch ${branch} is green.`);
const summary = groups
.map((alts) => (alts.length > 1 ? `(${alts.join(" | ")})` : alts[0]))
.join(", ");
core.info(`All required checks for ${branch} are green (${summary}).`);
+1 -1
View File
@@ -46,7 +46,7 @@ Rules:
1. Run workflow: **Promote main to next**.
2. Workflow behavior:
- validates that `main` checks are green before promotion
- validates required `main` checks before promotion (default: `ci-ok`, or UI-style `ci-main / ci-ok`; either satisfies the gate)
- resets `next` to `main` snapshot
- auto-bump package version in `next` to next `-rc.N`
- commit and push version bump