Skip to content
Merged
Show file tree
Hide file tree
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
4 changes: 4 additions & 0 deletions .changelog/unreleased/59-ci-full-label-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Fixed

- Preserved the `ci:full` label escape hatch without letting arbitrary PR label
changes such as `no-changelog` invoke or cancel the required gate. (#59)
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,14 @@ repo-required-gate / decision

Keep targeted checks inside the gate or leave them non-required. Do not make branch protection depend on workflows that can be skipped by path filters; GitHub can leave those required checks pending.

The required-gate caller intentionally does not listen for `labeled` or
`unlabeled` events. Labels such as `no-changelog` should not cancel and restart
the required branch-protection gate. The `ci:full` label is still honored by the
classifier when the gate runs; after applying it to an already-open PR, rerun the
workflow, push a new commit, or use another normal PR event to refresh the gate.
The required-gate caller listens for `labeled` and `unlabeled` events only to
preserve the `ci:full` escape hatch. The caller job and concurrency expression
are guarded so `ci:full` is the only label change that can run or cancel the
required branch-protection gate. Other labels such as `no-changelog` may create
a skipped caller run, but they do not start `repo-required-gate / decision` and
do not cancel an in-flight required-gate run. If a repo overrides the
`force-full-ci-label` input, update the caller guard and concurrency label name
to match.

For agent closeout, use the shared local preflight instead of direct promotion:

Expand Down
25 changes: 20 additions & 5 deletions examples/repo-required-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name: Repo Required Gate
on:
pull_request:
branches: [main]
types: [opened, edited, synchronize, reopened, ready_for_review]
types: [opened, edited, synchronize, reopened, ready_for_review, labeled, unlabeled]
merge_group:

permissions:
Expand All @@ -16,7 +16,14 @@ permissions:

concurrency:
group: repo-required-gate-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: >-
${{
github.event_name == 'pull_request' &&
(
(github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
github.event.label.name == 'ci:full'
Comment on lines +21 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep non-ci label events out of concurrency

For non-ci:full label changes this expression only sets cancel-in-progress to false, but the run still enters the same workflow-level concurrency group. GitHub's concurrency docs state that, by default, only one run can be pending in a group and any additional pending run cancels the previous one; so if a gate run is already running and a newer synchronize gate is pending, adding no-changelog can replace that pending required-gate run with a skipped label run, leaving the latest commit without the required repo-required-gate / decision result. The non-ci label runs need to avoid this concurrency group entirely or use a separate group/queueing behavior.

Useful? React with 👍 / 👎.

)
}}

jobs:
# -------------------------------------------------------------------------
Expand All @@ -26,6 +33,12 @@ jobs:
# code (node|python|go|polyglot) / forced-full (via the `ci:full` label).
# -------------------------------------------------------------------------
repo-required-gate:
if: >-
${{
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
github.event.label.name == 'ci:full'
}}
uses: ArchonVII/github-workflows/.github/workflows/repo-required-gate.yml@v1
with:
# ------ Minimal (docs / config repos with no language CI) -----------
Expand Down Expand Up @@ -105,8 +118,10 @@ jobs:
# ------ Force-full escape hatch (any stack) -------------------------
# Default label is `ci:full`. Apply it to a PR to bypass path-based
# routing and run the full language CI surface — e.g. a workflow-only
# change that semantically affects CI.
# If you add the label after the latest gate run, rerun the workflow,
# push a new commit, or use another normal PR event to refresh the gate.
# change that semantically affects CI. The caller guard above allows
# only this label to run/cancel the gate on label changes; other labels
# such as `no-changelog` skip without replacing the required decision.
# If you override the label input, update the guard/concurrency label
# name above to match.
#
# force-full-ci-label: ci:full
19 changes: 15 additions & 4 deletions scripts/workflow-structure.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,23 @@ describe('repo-required-gate workflow node delegation', () => {
});

describe('repo-required-gate caller example', () => {
it('does not rerun the required gate for arbitrary label changes', () => {
it('runs the required gate only for ci:full label changes', () => {
const body = readExample('repo-required-gate');

expect(body).toContain('types: [opened, edited, synchronize, reopened, ready_for_review]');
expect(body).not.toMatch(/^\s*labeled,?\s*$/m);
expect(body).not.toMatch(/^\s*unlabeled,?\s*$/m);
expect(body).toContain(
'types: [opened, edited, synchronize, reopened, ready_for_review, labeled, unlabeled]',
);
expect(body).toContain("github.event.action != 'labeled'");
expect(body).toContain("github.event.action != 'unlabeled'");
expect(body).toContain("github.event.label.name == 'ci:full'");

const jobBlock = workflowJobBlock(body, 'repo-required-gate');
expect(jobBlock).toContain('if: >-');
expect(jobBlock).toContain("github.event.label.name == 'ci:full'");

const concurrencyBlock = body.slice(body.indexOf('concurrency:'), body.indexOf('jobs:'));
expect(concurrencyBlock).toContain('cancel-in-progress: >-');
expect(concurrencyBlock).toContain("github.event.label.name == 'ci:full'");
});
});

Expand Down
Loading