mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-20 00:11:37 +00:00
* ci: standardize workflow concurrency and automate release-note labeling
Concurrency — prevent racing CI jobs
- Every top-level workflow now declares an explicit concurrency block.
- PR runs cancel-in-progress on supersede; main/push/workflow_call/publish
runs queue instead of cancelling so every commit and every release is
validated end-to-end.
- ci.yml uses a literal `CI-` prefix (not `${{ github.workflow }}`) and a
per-run nested group for workflow_call invocations, avoiding a potential
deadlock with publish.yml and release-candidate.yml callers whose own
concurrency groups could otherwise collide with the called workflow.
- ci-report.yml falls back to `<head-repo>/<head-branch>` for fork PRs
(stable across reruns) instead of the per-run-unique workflow_run.id
which did not actually serialize anything.
- ci-quality.yml enforces the convention: fails CI if any non-reusable
workflow lacks a concurrency block or a reusable workflow declares one.
Release-note automation
- New pr-labeler.yml: amannn/action-semantic-pull-request enforces
conventional-commit PR titles on pull_request (fork-safe, read-only);
release-drafter/release-drafter with disable-releaser: true applies the
matching label under pull_request_target (write-scoped). sync-labels in
.github/release-drafter.yml removes managed autolabels that no longer
match (e.g. when `!` or `BREAKING CHANGE:` is dropped from a PR).
- .github/release.yml (unchanged) continues to map labels to categorized
release-notes sections.
- dependabot.yml added for the github-actions ecosystem so pinned SHAs
auto-refresh on a weekly cadence.
Docs
- CONTRIBUTING.md documents the concurrency convention, the
conventional-commit PR-title rules, and the reusable-workflow exception.
Follow-up to verify before relying on the labeler in anger
- gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v5.5.3
- gh api repos/release-drafter/release-drafter/git/refs/tags/v6.0.0
- Confirm release-drafter reads its config from the base ref (not fork
head) when invoked via pull_request_target.
* ci: address PR review feedback on concurrency and labeler workflows
Two blocking fixes
- pr-labeler.yml: separate concurrency slots for pull_request and
pull_request_target. Previously both triggers shared a single group
with cancel-in-progress: true, so the privileged autolabel run could
cancel the title-validation check mid-run and leave a required status
in a permanent cancelled state.
- pr-labeler.yml autolabel job: add contents: read. release-drafter's
context.config() reads .github/release-drafter.yml from the default
branch via the repo-contents API and 403s without the scope. Job-level
permissions nullify all unlisted scopes so an explicit grant is needed.
Two non-blocking improvements
- Replace the hardcoded reusable-workflow allowlist in ci-quality.yml
with dynamic on:-block parsing. New workflow_call-only workflows no
longer produce false-positive convention failures.
- Implement actual group-key validation. The check now also asserts that
every concurrency.group expression references either ${{ github.workflow }}
or the literal CI- prefix (the documented ci.yml exception).
- Script extracted to .github/scripts/check-workflow-concurrency.py so
it is runnable locally and independently testable.
173 lines
6.1 KiB
Python
173 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Enforce the GitHub Actions concurrency convention.
|
|
|
|
See CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention" for the rules.
|
|
|
|
Invoked from .github/workflows/ci-quality.yml. Runs locally too:
|
|
python3 .github/scripts/check-workflow-concurrency.py .github/workflows
|
|
|
|
Rules:
|
|
1. Every entry-point (non-reusable) workflow declares a top-level
|
|
`concurrency:` block.
|
|
2. Reusable workflows (on: workflow_call ONLY) do NOT declare one.
|
|
3. The `concurrency.group` expression MUST reference either
|
|
`${{ github.workflow }}` or a literal `CI-` prefix (the documented
|
|
ci.yml reusable-workflow-safe exception). This is checked by substring
|
|
containment rather than prefix match because ci.yml's group is a
|
|
conditional expression that resolves to a `CI-…` literal at runtime.
|
|
|
|
We deliberately do not use a YAML library — keeps the script dependency-free
|
|
on any vanilla runner. `on:` block parsing is line-based and handles both the
|
|
flat (`on: workflow_call`) and mapping (`on:\n workflow_call:`) forms.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
|
|
REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-")
|
|
|
|
|
|
def is_reusable(lines: list[str]) -> bool:
|
|
"""Return True iff the workflow's `on:` block names only `workflow_call`."""
|
|
in_on = False
|
|
on_indent: int | None = None
|
|
keys: list[str] = []
|
|
|
|
for raw in lines:
|
|
# Skip blank lines and comments
|
|
stripped = raw.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
|
|
indent = len(raw) - len(raw.lstrip(" "))
|
|
|
|
if not in_on:
|
|
if raw.startswith("on:"):
|
|
remainder = raw[len("on:"):].strip()
|
|
if not remainder:
|
|
# `on:` followed by indented mapping on next lines
|
|
in_on = True
|
|
on_indent = indent
|
|
continue
|
|
if remainder.startswith("[") and remainder.endswith("]"):
|
|
# Flow-style list: on: [workflow_call]
|
|
items = [
|
|
item.strip() for item in remainder.strip("[]").split(",")
|
|
]
|
|
return items == ["workflow_call"]
|
|
# Scalar form: on: workflow_call (or a single other event)
|
|
return remainder == "workflow_call"
|
|
continue
|
|
|
|
# Inside the `on:` block; stop when indentation returns to <= on_indent
|
|
if on_indent is not None and indent <= on_indent:
|
|
break
|
|
|
|
# Only consider keys at on_indent + indentation step (anything deeper
|
|
# is nested config like `types:`)
|
|
if ":" not in stripped:
|
|
continue
|
|
# Heuristic: first-level event keys are those with indent == on_indent + 2
|
|
# (the canonical step for a 2-space YAML doc). We collect all first-level
|
|
# keys by tracking the smallest indent seen inside the block.
|
|
keys.append((indent, stripped.split(":", 1)[0].strip()))
|
|
|
|
if not keys:
|
|
return False
|
|
|
|
# Take only the outermost-indented keys as the event list
|
|
min_indent = min(i for i, _ in keys)
|
|
events = [name for i, name in keys if i == min_indent]
|
|
return events == ["workflow_call"]
|
|
|
|
|
|
CONCURRENCY_RE = re.compile(r"^concurrency:\s*$")
|
|
GROUP_RE = re.compile(r"^\s+group:\s*(.+?)\s*$")
|
|
|
|
|
|
def extract_group_key(lines: list[str]) -> str | None:
|
|
"""Return the `group:` value of the top-level `concurrency:` block, or None."""
|
|
for idx, raw in enumerate(lines):
|
|
if CONCURRENCY_RE.match(raw):
|
|
# Scan forward until we leave the concurrency block (next top-level key
|
|
# is at column 0 and ends with `:`).
|
|
for follow in lines[idx + 1:]:
|
|
if follow and not follow.startswith(" ") and follow.rstrip().endswith(":"):
|
|
break
|
|
m = GROUP_RE.match(follow)
|
|
if m:
|
|
return m.group(1).strip().strip("'").strip('"')
|
|
break
|
|
return None
|
|
|
|
|
|
def has_top_level_concurrency(lines: list[str]) -> bool:
|
|
return any(CONCURRENCY_RE.match(raw) for raw in lines)
|
|
|
|
|
|
def check(workflows_dir: pathlib.Path) -> int:
|
|
fail = 0
|
|
files = sorted(
|
|
list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml"))
|
|
)
|
|
for path in files:
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
reusable = is_reusable(lines)
|
|
has_conc = has_top_level_concurrency(lines)
|
|
|
|
if reusable:
|
|
if has_conc:
|
|
print(
|
|
f"::error file={path}::Reusable workflow (on: workflow_call) "
|
|
"must NOT declare its own concurrency block — it inherits "
|
|
"from the caller. See CONTRIBUTING.md -> GitHub Actions — "
|
|
"Concurrency Convention."
|
|
)
|
|
fail = 1
|
|
continue
|
|
|
|
if not has_conc:
|
|
print(
|
|
f"::error file={path}::Missing top-level concurrency block. "
|
|
"See CONTRIBUTING.md -> GitHub Actions — Concurrency Convention."
|
|
)
|
|
fail = 1
|
|
continue
|
|
|
|
group = extract_group_key(lines)
|
|
if group is None:
|
|
print(
|
|
f"::error file={path}::concurrency block is missing a "
|
|
"`group:` key."
|
|
)
|
|
fail = 1
|
|
continue
|
|
|
|
if not any(token in group for token in REQUIRED_TOKENS):
|
|
print(
|
|
f"::error file={path}::concurrency.group `{group}` must "
|
|
f"reference one of {REQUIRED_TOKENS}. See CONTRIBUTING.md -> "
|
|
"GitHub Actions — Concurrency Convention."
|
|
)
|
|
fail = 1
|
|
|
|
return fail
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
if len(argv) != 2:
|
|
print(f"usage: {argv[0]} <workflows-dir>", file=sys.stderr)
|
|
return 2
|
|
workflows_dir = pathlib.Path(argv[1])
|
|
if not workflows_dir.is_dir():
|
|
print(f"not a directory: {workflows_dir}", file=sys.stderr)
|
|
return 2
|
|
return check(workflows_dir)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|