Skip to content

Commit 4c503bc

Browse files
authored
Merge pull request #69 from zts212653/codex/main-guard-automation
ci(main-guard): free-tier direct-push guard for main
2 parents 467c2d6 + b669c06 commit 4c503bc

7 files changed

Lines changed: 609 additions & 0 deletions

File tree

‎.github/workflows/main-guard.yml‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
name: main-guard
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
concurrency:
9+
# Keep every main push incident run for audit trail; do not cancel in progress.
10+
group: main-guard-${{ github.ref }}
11+
cancel-in-progress: false
12+
13+
permissions:
14+
contents: write
15+
pull-requests: write
16+
issues: write
17+
18+
jobs:
19+
enforce-main-pr-only:
20+
name: enforce-main-pr-only
21+
runs-on: ubuntu-latest
22+
env:
23+
MAIN_GUARD_MODE: ${{ vars.MAIN_GUARD_MODE }}
24+
MAIN_GUARD_ALLOW_ACTORS: ${{ vars.MAIN_GUARD_ALLOW_ACTORS }}
25+
MAIN_GUARD_ALLOW_MARKER: ${{ vars.MAIN_GUARD_ALLOW_MARKER }}
26+
steps:
27+
- name: Checkout
28+
uses: actions/checkout@v4
29+
with:
30+
fetch-depth: 0
31+
32+
- name: Set up Python
33+
uses: actions/setup-python@v5
34+
with:
35+
python-version: "3.12"
36+
37+
- name: Classify main push
38+
id: classify
39+
run: |
40+
python scripts/ci/main_guard.py \
41+
--event "$GITHUB_EVENT_PATH" \
42+
--repository "$GITHUB_REPOSITORY" \
43+
--token "${{ secrets.GITHUB_TOKEN }}" \
44+
--allow-actors "${MAIN_GUARD_ALLOW_ACTORS:-}" \
45+
--allow-marker "${MAIN_GUARD_ALLOW_MARKER:-[main-guard:allow-direct-push]}"
46+
47+
- name: Write run summary
48+
run: |
49+
{
50+
echo "## Main Guard Result"
51+
echo "- Direct push incident: \`${{ steps.classify.outputs.is_direct_push }}\`"
52+
echo "- Decision reason: \`${{ steps.classify.outputs.reason }}\`"
53+
echo "- Pushed commits: \`${{ steps.classify.outputs.commit_shas }}\`"
54+
echo "- Unlinked commits: \`${{ steps.classify.outputs.unlinked_commits }}\`"
55+
echo "- Associated PR numbers: \`${{ steps.classify.outputs.associated_pr_numbers }}\`"
56+
echo "- Mode: \`${MAIN_GUARD_MODE:-revert-pr}\`"
57+
} >> "$GITHUB_STEP_SUMMARY"
58+
59+
- name: Create incident issue
60+
id: incident
61+
if: steps.classify.outputs.is_direct_push == 'true'
62+
continue-on-error: true
63+
env:
64+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65+
run: |
66+
mode="${MAIN_GUARD_MODE:-revert-pr}"
67+
title="Main guard incident: direct push on main (${GITHUB_SHA:0:7})"
68+
body=$(cat <<EOF
69+
## Main Guard Incident
70+
71+
Direct push to \`main\` was detected by \`main-guard\`.
72+
73+
- Repo: \`${GITHUB_REPOSITORY}\`
74+
- Actor: \`${GITHUB_ACTOR}\`
75+
- Run: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}
76+
- Decision: \`${{ steps.classify.outputs.reason }}\`
77+
- Mode: \`${mode}\`
78+
- Commit SHA: \`${GITHUB_SHA}\`
79+
- Unlinked commits: \`${{ steps.classify.outputs.unlinked_commits }}\`
80+
- Associated PR numbers: \`${{ steps.classify.outputs.associated_pr_numbers }}\`
81+
82+
If this was intentional emergency maintenance, include marker \`[main-guard:allow-direct-push]\` in commit message and add a postmortem note.
83+
EOF
84+
)
85+
issue_url=$(gh issue create --title "$title" --body "$body")
86+
echo "issue_url=$issue_url" >> "$GITHUB_OUTPUT"
87+
88+
- name: Create rollback branch and PR
89+
id: rollback
90+
if: steps.classify.outputs.is_direct_push == 'true'
91+
continue-on-error: true
92+
env:
93+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
94+
UNLINKED_COMMITS: ${{ steps.classify.outputs.unlinked_commits }}
95+
run: |
96+
mode="${MAIN_GUARD_MODE:-revert-pr}"
97+
if [ "$mode" != "revert-pr" ]; then
98+
echo "rollback_pr_url=" >> "$GITHUB_OUTPUT"
99+
echo "Skipping rollback PR because MAIN_GUARD_MODE=${mode}."
100+
exit 0
101+
fi
102+
103+
if [ -z "$UNLINKED_COMMITS" ]; then
104+
echo "rollback_pr_url=" >> "$GITHUB_OUTPUT"
105+
echo "No unlinked commits found; skipping rollback PR."
106+
exit 0
107+
fi
108+
109+
git config user.name "main-guard[bot]"
110+
git config user.email "main-guard[bot]@users.noreply.github.com"
111+
112+
rollback_branch="auto/main-guard-revert-${GITHUB_RUN_ID}"
113+
git switch --create "$rollback_branch"
114+
115+
reverted_count=0
116+
IFS=',' read -r -a commits <<< "$UNLINKED_COMMITS"
117+
for sha in "${commits[@]}"; do
118+
if [ -z "$sha" ]; then
119+
continue
120+
fi
121+
122+
if git revert --no-edit "$sha"; then
123+
reverted_count=$((reverted_count + 1))
124+
else
125+
git revert --abort || true
126+
echo "rollback_pr_url=" >> "$GITHUB_OUTPUT"
127+
echo "Failed to revert $sha automatically; manual rollback is required."
128+
exit 0
129+
fi
130+
done
131+
132+
if [ "$reverted_count" -eq 0 ]; then
133+
echo "rollback_pr_url=" >> "$GITHUB_OUTPUT"
134+
echo "No commits reverted; skipping rollback PR."
135+
exit 0
136+
fi
137+
138+
git push origin "$rollback_branch"
139+
rollback_pr_url=$(gh pr create \
140+
--base main \
141+
--head "$rollback_branch" \
142+
--title "chore(main-guard): rollback direct push ${GITHUB_SHA:0:7}" \
143+
--body "Auto-generated rollback PR for a direct push incident detected by \`main-guard\`.
144+
145+
- Incident run: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}
146+
- Unlinked commits reverted: \`${UNLINKED_COMMITS}\`
147+
- Detection reason: \`${{ steps.classify.outputs.reason }}\`")
148+
echo "rollback_pr_url=$rollback_pr_url" >> "$GITHUB_OUTPUT"
149+
150+
- name: Add remediation links to summary
151+
if: steps.classify.outputs.is_direct_push == 'true'
152+
run: |
153+
{
154+
echo ""
155+
echo "## Main Guard Remediation"
156+
echo "- Incident issue: \`${{ steps.incident.outputs.issue_url }}\`"
157+
echo "- Rollback PR: \`${{ steps.rollback.outputs.rollback_pr_url }}\`"
158+
} >> "$GITHUB_STEP_SUMMARY"
159+
160+
- name: Mark incident as failed gate
161+
if: steps.classify.outputs.is_direct_push == 'true'
162+
run: |
163+
echo "Direct push incident detected and remediation workflow executed."
164+
exit 1

‎CONTRIBUTING.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,19 @@ Read and follow `docs/agent_rules.md` before opening a PR.
5151

5252
## Governance Setup
5353
For GitHub branch protection and merge queue settings, follow `docs/governance/branch-protection.md`.
54+
55+
## Free-Tier Main Guard (Direct Push Fallback)
56+
- Workflow: `.github/workflows/main-guard.yml`
57+
- Trigger: every `push` to `main`
58+
- Behavior:
59+
- Detect commits on `main` without PR association metadata
60+
- Open an incident issue automatically
61+
- In `revert-pr` mode, open an automatic rollback PR for unlinked commits
62+
- Mark the run as failed for clear red-signal audit trail
63+
64+
### Repository Variables
65+
- `MAIN_GUARD_MODE`: `revert-pr` (default) or `alert-only`
66+
- `MAIN_GUARD_ALLOW_ACTORS`: comma-separated actor allowlist for emergency/bot bypass
67+
- `MAIN_GUARD_ALLOW_MARKER`: commit marker to bypass incident (default `[main-guard:allow-direct-push]`)
68+
69+
Use bypass only for emergency hotfixes and always attach a postmortem note in follow-up PR/issue.

‎docs/governance/branch-protection.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,25 @@ Use pre-merge combined checks:
3838
2. Keep PR checks running on GitHub's synthetic merge commit (default `pull_request` behavior).
3939
3. Require a fresh green run after rebasing/cherry-picking onto latest `main`.
4040

41+
## Free-Tier Fallback if Branch Protection Is Unavailable
42+
Use `.github/workflows/main-guard.yml` as an automated guard rail:
43+
44+
1. Detect pushes to `main` where commits have no associated PR metadata.
45+
2. Open an incident issue automatically with run link and commit context.
46+
3. In `MAIN_GUARD_MODE=revert-pr`, auto-create a rollback PR that reverts unlinked commits.
47+
4. Fail the `main-guard` run intentionally so incidents are visible in Actions history.
48+
49+
### Variables (Repository -> Settings -> Secrets and variables -> Actions -> Variables)
50+
- `MAIN_GUARD_MODE`
51+
- `revert-pr`: detect + issue + rollback PR (recommended default)
52+
- `alert-only`: detect + issue only
53+
- `MAIN_GUARD_ALLOW_ACTORS`
54+
- Comma-separated pusher names allowed to bypass detection (emergency admins/bots)
55+
- `MAIN_GUARD_ALLOW_MARKER`
56+
- Commit message override marker (default `[main-guard:allow-direct-push]`)
57+
58+
This fallback does not replace native branch protection, but it gives enforceable detection and remediation when plan limits block branch rules.
59+
4160
## Verification Steps
4261
1. Open a small PR.
4362
2. Confirm `lint` and `build` run automatically.

‎scripts/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Scripts package for CI and local automation helpers."""

‎scripts/ci/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""CI helper scripts package."""

0 commit comments

Comments
 (0)