detached ccaa3bb

fix(workflow): skip directives are case-sensitive and skip-checks needs two blank lines

commit
ccaa3bbc39b50b68a6db6451b570ccb8680d4319
author
brice · · 2026-10-12 19:14 UTC
committer
brice · · 2026-10-18 14:59 UTC
parent
dcaf616
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +86 -67
M workflow/skip.go +31 -35
@@ -6,53 +6,49 @@ import "strings"
66
77 var skipMarkers = []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"}
88
9-// SkipCI reports whether the message of the head commit of a push tells
10-// GitHub not to run workflows for it: it contains [skip ci], [ci skip],
11-// [no ci], [skip actions] or [actions skip], or it ends with a
12-// "skip-checks: true" (or "skip-checks:true") trailer in its last paragraph.
9+// SkipCI reports whether the message of the head commit of a push (or of the
10+// commit a tag points at) tells GitHub not to run workflows for it. As
11+// recorded on github.com (scenarios skip-directives, skip-directives-placement,
12+// skip-checks-trailer and skip-directive-tag):
1313 //
14-// TODO(verify with recording): the following is assumed. Markers are matched
15-// without regard to case, anywhere in the message, subject or body. GitHub
16-// documents the trailer as "two empty lines followed by skip-checks: true";
17-// here one blank line is enough, the trailer must be in the last paragraph
18-// and not the first one, its key and value are case-insensitive, and other
19-// trailers may share its paragraph. Whether GitHub looks at the head commit
20-// only, or at every commit of the push, is not settled by its documentation.
14+// - the markers [skip ci], [ci skip], [no ci], [skip actions] and
15+// [actions skip] skip anywhere in the subject or the body, and are
16+// case-sensitive: [SKIP CI] and [Skip Ci] do not skip. Near misses do
17+// not skip either: [skip-ci], "skip ci" without brackets, [skip ci please]
18+// and the markers ***NO_CI*** and ***SKIP_CI***;
19+// - the trailer "skip-checks: true" skips only as the end of the message,
20+// preceded by two blank lines ("x\n\n\nskip-checks: true"). The space
21+// after the colon is optional and trailing white space is ignored. It does
22+// not skip with one blank line or none, with text after it, after another
23+// trailer, with another value ("false", "yes"), or with any change of case
24+// in the key or the value.
25+//
26+// Not recorded, so assumed: more than two blank lines before the trailer
27+// count like two, trailing blank lines and CRLF line ends are ignored, more
28+// than one space after the colon is not accepted, and a message
29+// made of the trailer alone does not skip.
2130 func SkipCI(headCommitMessage string) bool {
22- lower := strings.ToLower(headCommitMessage)
2331 for _, m := range skipMarkers {
24- if strings.Contains(lower, m) {
32+ if strings.Contains(headCommitMessage, m) {
2533 return true
2634 }
2735 }
2836 return hasSkipChecksTrailer(headCommitMessage)
2937 }
3038
39+// hasSkipChecksTrailer reports whether the message ends with a
40+// "skip-checks: true" line preceded by two empty lines and some text.
3141 func hasSkipChecksTrailer(msg string) bool {
3242 msg = strings.ReplaceAll(msg, "\r\n", "\n")
33- var paras [][]string
34- var cur []string
35- for _, line := range strings.Split(msg, "\n") {
36- if strings.TrimSpace(line) == "" {
37- if len(cur) > 0 {
38- paras = append(paras, cur)
39- cur = nil
40- }
41- continue
42- }
43- cur = append(cur, line)
44- }
45- if len(cur) > 0 {
46- paras = append(paras, cur)
47- }
48- if len(paras) < 2 {
43+ msg = strings.TrimRight(msg, " \t\n")
44+ lines := strings.Split(msg, "\n")
45+ n := len(lines)
46+ if n < 4 {
4947 return false
5048 }
51- for _, line := range paras[len(paras)-1] {
52- key, val, ok := strings.Cut(line, ":")
53- if ok && strings.EqualFold(strings.TrimSpace(key), "skip-checks") && strings.EqualFold(strings.TrimSpace(val), "true") {
54- return true
55- }
49+ val, ok := strings.CutPrefix(lines[n-1], "skip-checks:")
50+ if !ok || strings.TrimPrefix(val, " ") != "true" {
51+ return false
5652 }
57- return false
53+ return lines[n-2] == "" && lines[n-3] == "" && strings.TrimSpace(strings.Join(lines[:n-3], "")) != ""
5854 }
@@ -6,53 +6,49 @@ import "strings"
6 6
7 var skipMarkers = []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"}7 var skipMarkers = []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"}
8 8
9-// SkipCI reports whether the message of the head commit of a push tells9+// SkipCI reports whether the message of the head commit of a push (or of the
10-// GitHub not to run workflows for it: it contains [skip ci], [ci skip],10+// commit a tag points at) tells GitHub not to run workflows for it. As
11-// [no ci], [skip actions] or [actions skip], or it ends with a11+// recorded on github.com (scenarios skip-directives, skip-directives-placement,
12-// "skip-checks: true" (or "skip-checks:true") trailer in its last paragraph.12+// skip-checks-trailer and skip-directive-tag):
13 //13 //
14-// TODO(verify with recording): the following is assumed. Markers are matched14+// - the markers [skip ci], [ci skip], [no ci], [skip actions] and
15-// without regard to case, anywhere in the message, subject or body. GitHub15+// [actions skip] skip anywhere in the subject or the body, and are
16-// documents the trailer as "two empty lines followed by skip-checks: true";16+// case-sensitive: [SKIP CI] and [Skip Ci] do not skip. Near misses do
17-// here one blank line is enough, the trailer must be in the last paragraph17+// not skip either: [skip-ci], "skip ci" without brackets, [skip ci please]
18-// and not the first one, its key and value are case-insensitive, and other18+// and the markers ***NO_CI*** and ***SKIP_CI***;
19-// trailers may share its paragraph. Whether GitHub looks at the head commit19+// - the trailer "skip-checks: true" skips only as the end of the message,
20-// only, or at every commit of the push, is not settled by its documentation.20+// preceded by two blank lines ("x\n\n\nskip-checks: true"). The space
21+// after the colon is optional and trailing white space is ignored. It does
22+// not skip with one blank line or none, with text after it, after another
23+// trailer, with another value ("false", "yes"), or with any change of case
24+// in the key or the value.
25+//
26+// Not recorded, so assumed: more than two blank lines before the trailer
27+// count like two, trailing blank lines and CRLF line ends are ignored, more
28+// than one space after the colon is not accepted, and a message
29+// made of the trailer alone does not skip.
21 func SkipCI(headCommitMessage string) bool {30 func SkipCI(headCommitMessage string) bool {
22- lower := strings.ToLower(headCommitMessage)
23 for _, m := range skipMarkers {31 for _, m := range skipMarkers {
24- if strings.Contains(lower, m) {32+ if strings.Contains(headCommitMessage, m) {
25 return true33 return true
26 }34 }
27 }35 }
28 return hasSkipChecksTrailer(headCommitMessage)36 return hasSkipChecksTrailer(headCommitMessage)
29 }37 }
30 38
39+// hasSkipChecksTrailer reports whether the message ends with a
40+// "skip-checks: true" line preceded by two empty lines and some text.
31 func hasSkipChecksTrailer(msg string) bool {41 func hasSkipChecksTrailer(msg string) bool {
32 msg = strings.ReplaceAll(msg, "\r\n", "\n")42 msg = strings.ReplaceAll(msg, "\r\n", "\n")
33- var paras [][]string43+ msg = strings.TrimRight(msg, " \t\n")
34- var cur []string44+ lines := strings.Split(msg, "\n")
35- for _, line := range strings.Split(msg, "\n") {45+ n := len(lines)
36- if strings.TrimSpace(line) == "" {46+ if n < 4 {
37- if len(cur) > 0 {
38- paras = append(paras, cur)
39- cur = nil
40- }
41- continue
42- }
43- cur = append(cur, line)
44- }
45- if len(cur) > 0 {
46- paras = append(paras, cur)
47- }
48- if len(paras) < 2 {
49 return false47 return false
50 }48 }
51- for _, line := range paras[len(paras)-1] {49+ val, ok := strings.CutPrefix(lines[n-1], "skip-checks:")
52- key, val, ok := strings.Cut(line, ":")50+ if !ok || strings.TrimPrefix(val, " ") != "true" {
53- if ok && strings.EqualFold(strings.TrimSpace(key), "skip-checks") && strings.EqualFold(strings.TrimSpace(val), "true") {51+ return false
54- return true
55- }
56 }52 }
57- return false53+ return lines[n-2] == "" && lines[n-3] == "" && strings.TrimSpace(strings.Join(lines[:n-3], "")) != ""
58 }54 }
M workflow/skip_test.go +55 -32
@@ -4,44 +4,67 @@ package workflow
44
55 import "testing"
66
7+// TestSkipCI replays the commit messages pushed by the recorded scenarios
8+// skip-directives, skip-directives-placement, skip-checks-trailer and
9+// skip-directive-tag. skip is true when GitHub started no run.
710 func TestSkipCI(t *testing.T) {
811 tests := []struct {
9- name, msg string
10- want bool
12+ scenario, msg string
13+ skip bool
1114 }{
12- {"empty", "", false},
13- {"plain", "fix: a bug", false},
14- {"skip ci", "docs: typo [skip ci]", true},
15- {"ci skip", "docs: typo [ci skip]", true},
16- {"no ci", "docs: typo [no ci]", true},
17- {"skip actions", "docs: typo [skip actions]", true},
18- {"actions skip", "docs: typo [actions skip]", true},
19- {"in body", "docs: typo\n\nlong text [skip ci] more", true},
20- {"in subject start", "[skip ci] docs", true},
21- {"no brackets", "skip ci", false},
22- {"other brackets", "[skip tests]", false},
23- {"partial", "[skip ci]", false},
24- // TODO(verify with recording): case of the markers.
25- {"upper case marker", "docs [SKIP CI]", true},
26- {"mixed case marker", "docs [Skip Ci]", true},
15+ // skip-directives
16+ {"skip-directives 1", "change one [skip ci]", true},
17+ {"skip-directives 2", "change two [ci skip]", true},
18+ {"skip-directives 3", "change three [no ci]", true},
19+ {"skip-directives 4", "change four [skip actions]", true},
20+ {"skip-directives 5", "change five [actions skip]", true},
21+ {"skip-directives 6", "change six, no directive", false},
22+
23+ // skip-directives-placement
24+ {"placement 1 body", "subject\n\nbody line [skip ci] more body", true},
25+ {"placement 2 mid-subject", "prefix [skip ci] suffix", true},
26+ {"placement 3 upper case", "upper case [SKIP CI]", false},
27+ {"placement 4 mixed case", "mixed case [Skip Ci]", false},
28+ {"placement 5 near miss dash", "near miss [skip-ci]", false},
29+ {"placement 6 no brackets", "near miss skip ci without brackets", false},
30+ {"placement 7 extra word", "near miss [skip ci please]", false},
31+ {"placement 8 NO_CI", "marker ***NO_CI*** in subject", false},
32+ {"placement 9 SKIP_CI", "marker ***SKIP_CI*** in subject", false},
33+
34+ // skip-checks-trailer
35+ {"trailer 1 one blank line", "trailer true\n\nskip-checks: true", false},
36+ {"trailer 2 false, one blank line", "trailer false\n\nskip-checks: false", false},
37+ {"trailer 3 upper, one blank line", "trailer upper\n\nSkip-Checks: true", false},
38+ {"trailer 4 other trailer first", "trailer after other trailer\n\nSigned-off-by: A Person <a@example.invalid>\nskip-checks: true", false},
39+ {"trailer 5 in the subject", "not a trailer, in the subject skip-checks: true", false},
40+ {"trailer 6 no blank line", "trailer without blank line\nskip-checks: true", false},
41+ {"trailer 7 two blank lines", "two blank lines true\n\n\nskip-checks: true", true},
42+ {"trailer 8 no space", "two blank lines no space\n\n\nskip-checks:true", true},
43+ {"trailer 9 false", "two blank lines false\n\n\nskip-checks: false", false},
44+ {"trailer 10 value upper", "two blank lines upper\n\n\nSkip-Checks: TRUE", false},
45+ {"trailer 11 text after", "two blank lines then more text\n\n\nskip-checks: true\nand more", false},
46+ {"trailer 12 after other trailer", "two blank lines after other trailer\n\n\nSigned-off-by: A Person <a@example.invalid>\nskip-checks: true", false},
47+ {"trailer 13 trailing spaces", "two blank lines trailing spaces\n\n\nskip-checks: true ", true},
48+ {"trailer 14 yes", "two blank lines yes\n\n\nskip-checks: yes", false},
49+ {"trailer 15 key upper", "two blank lines key upper value lower\n\n\nSkip-Checks: true", false},
50+ {"trailer 16 value upper", "two blank lines key lower value upper\n\n\nskip-checks: TRUE", false},
51+ {"trailer 17 key mixed", "two blank lines key mixed\n\n\nSkip-checks: true", false},
52+ {"trailer 18 plain", "plain commit", false},
2753
28- // skip-checks trailer.
29- {"trailer", "feat: x\n\nskip-checks: true", true},
30- {"trailer no space", "feat: x\n\nskip-checks:true", true},
31- {"trailer with body", "feat: x\n\nbody text\n\nskip-checks: true", true},
32- {"trailer after two blank lines", "feat: x\n\n\nskip-checks: true\n", true},
33- {"trailer crlf", "feat: x\r\n\r\nskip-checks: true\r\n", true},
34- {"trailer with other trailers", "feat: x\n\nSigned-off-by: A <a@x>\nskip-checks: true", true},
35- {"trailer false", "feat: x\n\nskip-checks: false", false},
36- {"trailer not last paragraph", "feat: x\n\nskip-checks: true\n\nmore text", false},
37- {"trailer in first paragraph only", "skip-checks: true", false},
38- {"trailer in subject", "feat: x skip-checks: true", false},
39- // TODO(verify with recording): case of key and value.
40- {"trailer upper", "feat: x\n\nSkip-Checks: TRUE", true},
54+ // skip-directive-tag: the message of the commit a tag points at.
55+ {"tag 1", "tagged [skip ci]", true},
56+ {"tag 2", "tagged normally", false},
57+
58+ // Not recorded: see the doc comment of SkipCI.
59+ {"trailing newline", "x\n\n\nskip-checks: true\n", true},
60+ {"crlf", "x\r\n\r\n\r\nskip-checks: true\r\n", true},
61+ {"three blank lines", "x\n\n\n\nskip-checks: true", true},
62+ {"trailer alone", "\n\n\nskip-checks: true", false},
63+ {"empty", "", false},
4164 }
4265 for _, tc := range tests {
43- if got := SkipCI(tc.msg); got != tc.want {
44- t.Errorf("%s: SkipCI(%q) = %v, want %v", tc.name, tc.msg, got, tc.want)
66+ if got := SkipCI(tc.msg); got != tc.skip {
67+ t.Errorf("%s: SkipCI(%q) = %v, want %v", tc.scenario, tc.msg, got, tc.skip)
4568 }
4669 }
4770 }
@@ -4,44 +4,67 @@ package workflow
4 4
5 import "testing"5 import "testing"
6 6
7+// TestSkipCI replays the commit messages pushed by the recorded scenarios
8+// skip-directives, skip-directives-placement, skip-checks-trailer and
9+// skip-directive-tag. skip is true when GitHub started no run.
7 func TestSkipCI(t *testing.T) {10 func TestSkipCI(t *testing.T) {
8 tests := []struct {11 tests := []struct {
9- name, msg string12+ scenario, msg string
10- want bool13+ skip bool
11 }{14 }{
12- {"empty", "", false},15+ // skip-directives
13- {"plain", "fix: a bug", false},16+ {"skip-directives 1", "change one [skip ci]", true},
14- {"skip ci", "docs: typo [skip ci]", true},17+ {"skip-directives 2", "change two [ci skip]", true},
15- {"ci skip", "docs: typo [ci skip]", true},18+ {"skip-directives 3", "change three [no ci]", true},
16- {"no ci", "docs: typo [no ci]", true},19+ {"skip-directives 4", "change four [skip actions]", true},
17- {"skip actions", "docs: typo [skip actions]", true},20+ {"skip-directives 5", "change five [actions skip]", true},
18- {"actions skip", "docs: typo [actions skip]", true},21+ {"skip-directives 6", "change six, no directive", false},
19- {"in body", "docs: typo\n\nlong text [skip ci] more", true},22+
20- {"in subject start", "[skip ci] docs", true},23+ // skip-directives-placement
21- {"no brackets", "skip ci", false},24+ {"placement 1 body", "subject\n\nbody line [skip ci] more body", true},
22- {"other brackets", "[skip tests]", false},25+ {"placement 2 mid-subject", "prefix [skip ci] suffix", true},
23- {"partial", "[skip ci]", false},26+ {"placement 3 upper case", "upper case [SKIP CI]", false},
24- // TODO(verify with recording): case of the markers.27+ {"placement 4 mixed case", "mixed case [Skip Ci]", false},
25- {"upper case marker", "docs [SKIP CI]", true},28+ {"placement 5 near miss dash", "near miss [skip-ci]", false},
26- {"mixed case marker", "docs [Skip Ci]", true},29+ {"placement 6 no brackets", "near miss skip ci without brackets", false},
30+ {"placement 7 extra word", "near miss [skip ci please]", false},
31+ {"placement 8 NO_CI", "marker ***NO_CI*** in subject", false},
32+ {"placement 9 SKIP_CI", "marker ***SKIP_CI*** in subject", false},
33+
34+ // skip-checks-trailer
35+ {"trailer 1 one blank line", "trailer true\n\nskip-checks: true", false},
36+ {"trailer 2 false, one blank line", "trailer false\n\nskip-checks: false", false},
37+ {"trailer 3 upper, one blank line", "trailer upper\n\nSkip-Checks: true", false},
38+ {"trailer 4 other trailer first", "trailer after other trailer\n\nSigned-off-by: A Person <a@example.invalid>\nskip-checks: true", false},
39+ {"trailer 5 in the subject", "not a trailer, in the subject skip-checks: true", false},
40+ {"trailer 6 no blank line", "trailer without blank line\nskip-checks: true", false},
41+ {"trailer 7 two blank lines", "two blank lines true\n\n\nskip-checks: true", true},
42+ {"trailer 8 no space", "two blank lines no space\n\n\nskip-checks:true", true},
43+ {"trailer 9 false", "two blank lines false\n\n\nskip-checks: false", false},
44+ {"trailer 10 value upper", "two blank lines upper\n\n\nSkip-Checks: TRUE", false},
45+ {"trailer 11 text after", "two blank lines then more text\n\n\nskip-checks: true\nand more", false},
46+ {"trailer 12 after other trailer", "two blank lines after other trailer\n\n\nSigned-off-by: A Person <a@example.invalid>\nskip-checks: true", false},
47+ {"trailer 13 trailing spaces", "two blank lines trailing spaces\n\n\nskip-checks: true ", true},
48+ {"trailer 14 yes", "two blank lines yes\n\n\nskip-checks: yes", false},
49+ {"trailer 15 key upper", "two blank lines key upper value lower\n\n\nSkip-Checks: true", false},
50+ {"trailer 16 value upper", "two blank lines key lower value upper\n\n\nskip-checks: TRUE", false},
51+ {"trailer 17 key mixed", "two blank lines key mixed\n\n\nSkip-checks: true", false},
52+ {"trailer 18 plain", "plain commit", false},
27 53
28- // skip-checks trailer.54+ // skip-directive-tag: the message of the commit a tag points at.
29- {"trailer", "feat: x\n\nskip-checks: true", true},55+ {"tag 1", "tagged [skip ci]", true},
30- {"trailer no space", "feat: x\n\nskip-checks:true", true},56+ {"tag 2", "tagged normally", false},
31- {"trailer with body", "feat: x\n\nbody text\n\nskip-checks: true", true},57+
32- {"trailer after two blank lines", "feat: x\n\n\nskip-checks: true\n", true},58+ // Not recorded: see the doc comment of SkipCI.
33- {"trailer crlf", "feat: x\r\n\r\nskip-checks: true\r\n", true},59+ {"trailing newline", "x\n\n\nskip-checks: true\n", true},
34- {"trailer with other trailers", "feat: x\n\nSigned-off-by: A <a@x>\nskip-checks: true", true},60+ {"crlf", "x\r\n\r\n\r\nskip-checks: true\r\n", true},
35- {"trailer false", "feat: x\n\nskip-checks: false", false},61+ {"three blank lines", "x\n\n\n\nskip-checks: true", true},
36- {"trailer not last paragraph", "feat: x\n\nskip-checks: true\n\nmore text", false},62+ {"trailer alone", "\n\n\nskip-checks: true", false},
37- {"trailer in first paragraph only", "skip-checks: true", false},63+ {"empty", "", false},
38- {"trailer in subject", "feat: x skip-checks: true", false},
39- // TODO(verify with recording): case of key and value.
40- {"trailer upper", "feat: x\n\nSkip-Checks: TRUE", true},
41 }64 }
42 for _, tc := range tests {65 for _, tc := range tests {
43- if got := SkipCI(tc.msg); got != tc.want {66+ if got := SkipCI(tc.msg); got != tc.skip {
44- t.Errorf("%s: SkipCI(%q) = %v, want %v", tc.name, tc.msg, got, tc.want)67+ t.Errorf("%s: SkipCI(%q) = %v, want %v", tc.scenario, tc.msg, got, tc.skip)
45 }68 }
46 }69 }
47 }70 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close