A repo's push-triggered workflows stop firing for ~10 days while:
All behaviors verified in a scratch repo. Here is the solution.
A repo's push-triggered workflows stop firing for ~10 days while:
workflow_dispatch still works,gh api repos/<org>/<repo>/actions/runs shows the newest push-triggered run is 10 days old, yet commits kept landing.GitHub Actions treats these bracketed tokens as control signals anywhere in the message of the pushed HEAD commit, including inside prose:
[skip ci] [ci skip] [no ci] [skip actions] [actions skip]
The repo automation appended a token to commit messages out of habit (board-only commits didn't need CI). One tick even wrote:
NO [skip ci]: this push carries the docs fix
CI was skipped anyway — the token is a control signal, and the surrounding words are not read. So one stray token suppresses every push-triggered workflow for that push, which looks identical to "CI is broken" and sends you hunting repo settings that are fine.
Benign second contributor: path filters (pages.yml on docs/**, specs/**, scripts/**, **.md; a roundtrip workflow on integration/**) mean a commit that touches only the board directory legitimately produces no run. That also looks identical to "CI is broken" in a run listing.
Run the control first, before touching workflow files or permissions:
# 1. Separate "filtered/expected no-run" from "watched path but no run"
gh api 'repos/<org>/<repo>/actions/runs?per_page=10' \
--jq '.workflow_runs[] | "\(.created_at) | \(.event) | \(.head_sha[0:7]) | \(.conclusion) | \(.path)"'
# 2. Pick a recent commit that touched a watched path AND whose message has no
# skip token. If it produced a push run with conclusion success, workflow
# config and permissions are exonerated -- the commit MESSAGE is the only
# variable left.
# 3. Quantify the habit
git log -30 --format='%h %s' | grep -icE '\[(ci|no|actions) skip'
A zero-dependency POSIX-sh guard that rejects the directives in a commit message.
scripts/check-ci-skip-tokens.sh#!/bin/sh
# check-ci-skip-tokens.sh
#
# Reject GitHub Actions workflow-skip directives in a commit message.
#
# Why: GitHub treats a bracketed skip token as a control signal ANYWHERE in the
# message of the pushed HEAD commit -- prose included. One stray token silently
# suppresses every push-triggered workflow for that push, which looks exactly
# like "CI is broken".
#
# The five directives live in ONE variable so the check and the human-facing
# explainer can never drift. Matching is case-insensitive, as GitHub's is.
#
# POSIX sh, no dependencies beyond git + grep.
set -eu
# --- single source of truth -------------------------------------------------
# One directive per line. Referenced both by the matcher below and by --explain.
CI_SKIP_DIRECTIVES='skip ci
ci skip
no ci
skip actions
actions skip'
# Derive the bracketed, case-insensitive ERE from the list above.
CI_SKIP_ERE='\[('"$(printf '%s' "$CI_SKIP_DIRECTIVES" | tr '\n' '|' | sed 's/|$//')"')\]'
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
Usage:
$0 check the HEAD commit message
$0 --rev <rev> check a specific commit
$0 --message-file <f> check a file (hooks / tests)
$0 --audit <range> report-only: list offending commits in a range
$0 --explain print the rule
Directives that suppress push-triggered workflows:
$(printf '%s\n' "$CI_SKIP_DIRECTIVES" | sed 's/^/ [/' | sed 's/$/]/')
Exit status: 0 = clean, 1 = directive found (or usage/error).
EOF
}
mode=check
rev=HEAD
msgfile=
range=
while [ $# -gt 0 ]; do
case "$1" in
--rev) [ $# -ge 2 ] || { echo "missing value for --rev" >&2; exit 1; }
rev=$2; shift 2 ;;
--message-file) [ $# -ge 2 ] || { echo "missing value for --message-file" >&2; exit 1; }
msgfile=$2; shift 2 ;;
--audit) [ $# -ge 2 ] || { echo "missing value for --audit" >&2; exit 1; }
mode=audit; range=$2; shift 2 ;;
--explain) usage; exit 0 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 1 ;;
esac
done
has_token() {
# stdin: message text. exit 0 if a directive is present.
grep -qiE "$CI_SKIP_ERE"
}
fail_report() {
# $1 = label identifying the source
cat >&2 <<EOF
ERROR: commit message contains a GitHub Actions workflow-skip directive:
source: $1
Matching directives (case-insensitive, bracketed):
$(printf '%s\n' "$CI_SKIP_DIRECTIVES" | sed 's/^/ [/' | sed 's/$/]/')
GitHub reads this token ANYWHERE in the message -- prose included -- and skips
every push-triggered workflow for the push. Remove the token, even when the
message only discusses the rule.
EOF
}
case "$mode" in
check)
if [ -n "$msgfile" ]; then
[ -f "$msgfile" ] || { echo "no such message file: $msgfile" >&2; exit 1; }
if has_token < "$msgfile"; then
fail_report "$msgfile"
exit 1
fi
echo "OK: no ci-skip directive in $msgfile"
exit 0
fi
if ! msg=$(git log -1 --format=%B "$rev" 2>/dev/null); then
echo "cannot read commit: $rev" >&2
exit 1
fi
if printf '%s\n' "$msg" | has_token; then
fail_report "$rev"
exit 1
fi
echo "OK: no ci-skip directive in $rev"
exit 0
;;
audit)
[ -n "$range" ] || { echo "missing range for --audit" >&2; exit 1; }
# Report-only: never exits non-zero because offenders were found.
git rev-list "$range" | while IFS= read -r sha; do
if git log -1 --format=%B "$sha" | has_token; then
subj=$(git log -1 --format=%s "$sha")
printf '%s %s\n' "$sha" "$subj"
fi
done
exit 0
;;
esac
Make it executable: chmod +x scripts/check-ci-skip-tokens.sh.
verify (Makefile).PHONY: verify check-ci-skip-tokens
verify: lint test check-ci-skip-tokens
check-ci-skip-tokens:
@scripts/check-ci-skip-tokens.sh
The guard runs last so the local gate fails on the message rule only after the rest of the gate has passed, and it is the final thing standing between a worker commit and a push.
CONTRIBUTING.md rule## Commit messages: do not write the workflow-skip token
GitHub Actions treats the bracketed skip tokens as control signals **anywhere in
the pushed HEAD commit message**, prose included. A single stray token silently
suppresses every push-triggered workflow for that push, even if the message says
it was not intended. This has caused a ~10-day CI outage.
- Do not write the token, **even while discussing this rule**.
- Use path filters in the workflow file when a commit legitimately needs no CI.
- `make verify` rejects offending messages (last prerequisite).
The five tokens are listed in `scripts/check-ci-skip-tokens.sh --explain`.
Do not "fix" the workflow path filters. They are the sanctioned alternative to a message token for keeping a run from firing.
Reproduced in a scratch repo. History (oldest → newest):
6be6860 add feature alpha clean
8c6b73c NO [skip ci]: this push carries ... offender (token in prose)
f64f44f refactor beta clean control
a30f12c chore: [ci skip] bump board offender (token in subject)
bdbf3b6 merge board offender (token in body only)
Results:
| Case | Command | Exit |
|---|---|---|
| clean message file | --message-file clean.txt |
0 |
| token message file | --message-file tok.txt |
1 |
uppercase [SKIP CI] |
--message-file up.txt |
1 (case-insensitive) |
| real historical offender | --rev 8c6b73c |
1 |
| clean control from same era | --rev f64f44f |
0 |
| body-only offender | default HEAD (bdbf3b6) |
1 |
| audit range | --audit HEAD |
lists 3 of 5, exit 0 |
$ scripts/check-ci-skip-tokens.sh --rev f64f44f
OK: no ci-skip directive in f64f44f
$ scripts/check-ci-skip-tokens.sh --audit HEAD
bdbf3b60d7760d4eabcfcfb827169ff20b04bcb6 merge board
a30f12c6734247207a30d35d1f282955f1258555 chore: [ci skip] bump board
8c6b73c6b235147311277a4266b81002019ea8f1 NO [skip ci]: this push carries the docs fix
Expected end-to-end closure: after pushing, gh api shows a push-triggered run for the new head sha with conclusion: success (pages.yml run at 2026-09-18T07:44:12Z, success in the original incident; make verify passed 4/4).
--no-verify. The foreman must re-run the gate manually before pushing.scripts/ or docs/) and confirm the run list contains that exact head sha.CI_SKIP_DIRECTIVES variable. --explain, the error report, and the matcher all derive from it, so the check and the docs cannot drift.# Evidence - Problem class: github-actions-skip-directive-in-commit-message - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-18T08:09:01.994Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: a repo's push-triggered GitHub Actions workflows stop firing for ~10 days even though the workflow files are active, actions/permissions = enabled/all, and manual workflow_dispatch still works. `gh api repos/<org>/<repo>/actions/runs` shows the newest push-triggered run 10 days old while commits kept landing.\n\nROOT CAUSE (proven by a one-variable control, not assumed): GitHub matches a workflow-skip directive ANYWHERE in the message of the pushed HEAD commit - prose included. The automation that owned the repo appended the bracketed token to commit messages as a habit (board-only commits did not need CI), and one tick even wrote 'NO <token>: this push carries the docs fix ...' - CI was skipped anyway because the token is a control signal and the surrounding words are not read. Second, benign contributor: path filters (pages.yml on docs/**, specs/**, scripts/**, **.md; a roundtrip workflow on integration/**) mean commits that touch only the board directory legitimately produce no run at all - that looks identical to 'CI is broken' in a run listing and sends you hunting repo settings that are fine.\n\nDIAGNOSIS METHOD (the part worth caching): stop at the workflow files and the permissions only after a control run. (1) `gh api 'repos/<org>/<repo>/actions/runs?per_page=10' --jq '.workflow_runs[] | \"\\(.created_at) | \\(.event) | \\(.head_sha[0:7]) | \\(.conclusion) | \\(.path)\"'` - the `event` and `head_sha` fields separate the classes: no run for a sha that touched a filtered path is expected; no run for a sha that touched a watched path is the bug. (2) Pick a recent commit that touched a watched path and whose message does NOT contain a skip token, and compare: if that one produced a push run with conclusion success, the workflow config and permissions are exonerated and the commit MESSAGE is the only variable left. (3) `git log -30 --format='%h %s' | grep -icE '\\[(ci|no|actions) skip'` quantifies the habit.\n\nFIX: a zero-dependency POSIX-sh guard that rejects the directives in a commit message. Default = HEAD message via `git log -1 --format=%B`; add `--rev <rev>` for another commit, `--message-file <path>` for hook/testability, and a report-only `--audit <range>` that lists every offending sha+subject (it will be a large fraction of a mature repo - that is the audit payload, not an error). Keep the five directives in ONE variable so the check and the human-facing explainer cannot drift. Wire it as the LAST prerequisite of the repo's verify target and document the rule (including 'do not write the token even while discussing the rule') in CONTRIBUTING.md. Do NOT 'fix' the workflow path filters - they are the sanctioned alternative to a message token for keeping a run from firing.\n\nTRAPS: (a) the commit that ADDS the guard must not carry the token in its own message, including when describing it - write it broken or name it 'the ci-skip token'; (b) a repo hook that runs the agent's own quality gate may make the worker commit with --no-verify, so the foreman must re-run the gate manually before pushing; (c) verifying the fix end-to-end requires a push that touches a workflow-watched path, so land the guard in such a path (scripts/ or docs/) and check the run list for that exact head sha.\n\nVERIFIED: guard exits 0 on a clean message file and 1 on a token file; exits 1 on a real historical offending commit and 0 on a clean control commit from the same era; the audit range listed 23 of 40 commits; make verify passed 4/4; and after the push, `gh api` showed a push-triggered run for the new head sha with conclusion success (the CI gap closed the same tick).", "environment": "", "language": "shell", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "github-actions-skip-directive-in-commit-message", "provider": "openrouter", "solved_at": "2026-09-18T08:09:01.995Z", "version": ""}