Skip to content

Commit f809a7a

Browse files
authored
fix(ci): prevent transitive skip propagation in release pipeline (#266)
* fix(ci): prevent transitive skip propagation in release pipeline - Make gate job always run instead of skipping on non-PR events - Add early exit for non-PR events with allow_heavy=true output - Simplify docker-build condition to check gate output only - Revert !cancelled() approach from #264 (insufficient fix) Fixes #265 * fix(ci): move gate check into docker-build, remove standalone gate job - Delete separate gate job that broke needs chain on push to main - Add gate step as first step of docker-build (runs on pull requests only) - docker-build now depends only on quality-checks - Simplify summary job status reporting Fixes #265 * fix(ci): fail-closed on incomplete pagination, rename quality-checks job - Fail with exit 1 on inconsistent pagination (empty page or missing cursor with hasNextPage=true) instead of silently breaking the loop - Rename quality-checks display name to "Quality Checks" to avoid collision with legacy "Lint, Test & Build" expected-status job * fix(ci): fix off-by-one in pagination guard, remove unused output - Only fail on MAX_PAGES when hasNextPage is still true (avoids false positive when scan completes exactly on the last page) - Remove unused image-built output from docker-build job - Add comment explaining intentional fail-on-unresolved design * fix(ci): add timeout-minutes to docker-build job - Set 30-minute timeout for docker-build (multi-arch build ~8min + margin for cache misses and gate check) * fix(ci): document always() behavior on summary job - Add comment explaining why needs lists all jobs (output access) and why always() guarantees execution despite skipped dependencies
1 parent 59b8cbe commit f809a7a

File tree

1 file changed

+50
-106
lines changed

1 file changed

+50
-106
lines changed

.github/workflows/ci-cd.yml

Lines changed: 50 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ env:
3131
jobs:
3232
# Job 1: Quality checks (lint, test, build)
3333
quality-checks:
34-
name: Lint, Test & Build
34+
name: Quality Checks
3535
runs-on: ubuntu-latest
3636

3737
steps:
@@ -94,43 +94,55 @@ jobs:
9494
path: dist/
9595
retention-days: 1
9696

97-
review-thread-gate:
98-
name: Review Threads Gate
97+
# Legacy required status check - reports quality-checks result for branch protection
98+
# Added to support migration from old "Lint, Test & Build" ruleset requirement
99+
expected-status:
100+
name: Lint, Test & Build
101+
runs-on: ubuntu-latest
102+
needs: [quality-checks]
103+
if: always()
104+
105+
steps:
106+
- name: Report status
107+
run: |
108+
if [ "${{ needs.quality-checks.result }}" = "success" ]; then
109+
echo "Lint/Test/Build passed."
110+
exit 0
111+
fi
112+
echo "Lint/Test/Build failed."
113+
exit 1
114+
115+
# Job 2: Build and test Docker image
116+
docker-build:
117+
name: Build Docker Image
99118
runs-on: ubuntu-latest
100-
timeout-minutes: 5
101-
# Only relevant for PRs; non-PR events skip the gate entirely.
102-
if: github.event_name == 'pull_request'
119+
timeout-minutes: 30
120+
needs: [quality-checks]
103121

104122
permissions:
105-
pull-requests: read
106123
contents: read
107-
108-
outputs:
109-
allow_heavy: ${{ steps.gate.outputs.allow_heavy }}
124+
pull-requests: read # For review thread check on PRs
110125

111126
steps:
112-
- name: Check unresolved review threads
113-
id: gate
127+
- name: Check for unresolved review threads
128+
if: github.event_name == 'pull_request'
114129
env:
115130
GH_TOKEN: ${{ github.token }}
116131
PR_NUMBER: ${{ github.event.pull_request.number }}
117132
REPO: ${{ github.repository }}
118133
run: |
119134
if ! command -v gh >/dev/null 2>&1; then
120-
echo "gh CLI not available; allowing heavy jobs to proceed (fail-open)." >> "$GITHUB_STEP_SUMMARY"
121-
echo "allow_heavy=true" >> "$GITHUB_OUTPUT"
135+
echo "gh CLI not available; skipping thread check (fail-open)." >> "$GITHUB_STEP_SUMMARY"
122136
exit 0
123137
fi
124138
if [ -z "$PR_NUMBER" ]; then
125-
echo "allow_heavy=true" >> "$GITHUB_OUTPUT"
126-
echo "PR_NUMBER is empty; allowing heavy jobs to proceed (fail-open)." >> "$GITHUB_STEP_SUMMARY"
139+
echo "PR_NUMBER is empty; skipping thread check (fail-open)." >> "$GITHUB_STEP_SUMMARY"
127140
exit 0
128141
fi
129142
OWNER="${REPO%/*}"
130143
NAME="${REPO#*/}"
131144
UNRESOLVED=0
132145
AFTER=""
133-
# Hard cap to avoid unbounded pagination; 100 pages * 100 threads = 10,000 threads max.
134146
MAX_PAGES=100
135147
PAGE_COUNT=0
136148
while [ "$PAGE_COUNT" -lt "$MAX_PAGES" ]; do
@@ -151,9 +163,7 @@ jobs:
151163
-f owner="$OWNER" \
152164
-f name="$NAME" \
153165
-F number="$PR_NUMBER") || {
154-
# Fail-open on API errors to avoid blocking contributor workflows.
155-
echo "GitHub GraphQL API query for review threads failed; allowing heavy jobs to proceed (fail-open)." >> "$GITHUB_STEP_SUMMARY"
156-
echo "allow_heavy=true" >> "$GITHUB_OUTPUT"
166+
echo "GraphQL query failed; skipping thread check (fail-open)." >> "$GITHUB_STEP_SUMMARY"
157167
exit 0
158168
}
159169
else
@@ -173,103 +183,41 @@ jobs:
173183
-f name="$NAME" \
174184
-F number="$PR_NUMBER" \
175185
-f after="$AFTER") || {
176-
# Fail-open on API errors to avoid blocking contributor workflows.
177-
echo "GitHub GraphQL API query for review threads failed; allowing heavy jobs to proceed (fail-open)." >> "$GITHUB_STEP_SUMMARY"
178-
echo "allow_heavy=true" >> "$GITHUB_OUTPUT"
186+
echo "GraphQL query failed; skipping thread check (fail-open)." >> "$GITHUB_STEP_SUMMARY"
179187
exit 0
180188
}
181189
fi
182-
# Count only active, unresolved, non-outdated review threads.
183-
# GitHub marks a thread as "outdated" when the underlying code has changed
184-
# (e.g., after new commits). For this gate, we deliberately ignore outdated
185-
# threads so that only current, actionable discussions block heavy jobs.
186-
# This keeps the gate aligned to actionable feedback and avoids blocking
187-
# heavy jobs on stale discussions that no longer apply to the diff.
188-
#
189-
# If you want unresolved outdated threads to also block heavy jobs, remove
190-
# "and .isOutdated == false" from the jq filter below.
190+
# Count only active, unresolved, non-outdated threads.
191+
# Outdated threads (code changed since comment) are excluded.
191192
PAGE_UNRESOLVED=$(echo "$RESP" | jq '[.data.repository.pullRequest.reviewThreads.nodes // [] | .[] | select(.isResolved == false and .isOutdated == false)] | length')
192193
PAGE_NODE_COUNT=$(echo "$RESP" | jq '(.data.repository.pullRequest.reviewThreads.nodes // []) | length')
193-
if ! [[ "$PAGE_UNRESOLVED" =~ ^[0-9]+$ ]]; then
194-
PAGE_UNRESOLVED=0
195-
fi
196-
if ! [[ "$PAGE_NODE_COUNT" =~ ^[0-9]+$ ]]; then
197-
PAGE_NODE_COUNT=0
198-
fi
194+
if ! [[ "$PAGE_UNRESOLVED" =~ ^[0-9]+$ ]]; then PAGE_UNRESOLVED=0; fi
195+
if ! [[ "$PAGE_NODE_COUNT" =~ ^[0-9]+$ ]]; then PAGE_NODE_COUNT=0; fi
199196
UNRESOLVED=$((UNRESOLVED + PAGE_UNRESOLVED))
200-
# Only fetch the fields we need to evaluate the gate, to reduce payload size.
201197
HAS_NEXT=$(echo "$RESP" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')
202-
if [ "$HAS_NEXT" != "true" ]; then
203-
break
204-
fi
198+
if [ "$HAS_NEXT" != "true" ]; then break; fi
205199
AFTER=$(echo "$RESP" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor')
206-
if [ "$AFTER" = "null" ]; then
207-
AFTER=""
208-
fi
209-
# If endCursor is missing despite hasNextPage=true, stop to avoid looping.
200+
if [ "$AFTER" = "null" ]; then AFTER=""; fi
210201
if [ "$PAGE_NODE_COUNT" -eq 0 ] && [ "$HAS_NEXT" = "true" ]; then
211-
echo "Warning: empty page while hasNextPage=true; stopping pagination to avoid loops." >> "$GITHUB_STEP_SUMMARY"
212-
break
202+
echo "::error::Thread scan incomplete: empty page with hasNextPage=true. Blocking build for safety."
203+
exit 1
213204
fi
214205
if [ "$HAS_NEXT" = "true" ] && [ -z "$AFTER" ]; then
215-
echo "Warning: missing endCursor while hasNextPage=true; stopping pagination to avoid infinite loop." >> "$GITHUB_STEP_SUMMARY"
216-
break
206+
echo "::error::Thread scan incomplete: missing endCursor with hasNextPage=true. Blocking build for safety."
207+
exit 1
217208
fi
218209
done
219-
if [ "$PAGE_COUNT" -ge "$MAX_PAGES" ]; then
220-
# Fail-closed when pagination is incomplete: we cannot guarantee the thread count.
221-
echo "Warning: reached maximum page limit while scanning review threads." >> "$GITHUB_STEP_SUMMARY"
222-
echo "Review thread scan incomplete; blocking heavy jobs for safety." >> "$GITHUB_STEP_SUMMARY"
223-
echo "allow_heavy=false" >> "$GITHUB_OUTPUT"
224-
exit 0
210+
if [ "$PAGE_COUNT" -ge "$MAX_PAGES" ] && [ "$HAS_NEXT" = "true" ]; then
211+
echo "::error::Thread scan incomplete (exceeded $MAX_PAGES pages). Blocking build for safety."
212+
exit 1
225213
fi
214+
# Intentionally fail (not skip) — PRs with unresolved threads should not show green CI
226215
if [ "$UNRESOLVED" -gt 0 ]; then
227-
echo "allow_heavy=false" >> "$GITHUB_OUTPUT"
228-
echo "Unresolved review threads: $UNRESOLVED" >> "$GITHUB_STEP_SUMMARY"
229-
exit 0
216+
echo "::error::$UNRESOLVED unresolved review thread(s). Resolve them before building."
217+
exit 1
230218
fi
231-
echo "allow_heavy=true" >> "$GITHUB_OUTPUT"
232219
echo "Unresolved review threads: 0" >> "$GITHUB_STEP_SUMMARY"
233220
234-
# Legacy required status check - reports quality-checks result for branch protection
235-
# Added to support migration from old "Lint, Test & Build" ruleset requirement
236-
expected-status:
237-
name: Lint, Test & Build
238-
runs-on: ubuntu-latest
239-
needs: [quality-checks]
240-
if: always()
241-
242-
steps:
243-
- name: Report status
244-
run: |
245-
if [ "${{ needs.quality-checks.result }}" = "success" ]; then
246-
echo "Lint/Test/Build passed."
247-
exit 0
248-
fi
249-
echo "Lint/Test/Build failed."
250-
exit 1
251-
252-
# Job 2: Build and test Docker image
253-
docker-build:
254-
name: Build Docker Image
255-
runs-on: ubuntu-latest
256-
needs: [quality-checks, review-thread-gate]
257-
# For PRs, only run heavy jobs if the gate succeeded and explicitly allowed them.
258-
# Non-PR events (push/workflow_dispatch) run unconditionally.
259-
# Note: !cancelled() is required because review-thread-gate is skipped on non-PR events,
260-
# and GitHub Actions skips dependent jobs by default when any needs job is skipped.
261-
if: |
262-
!cancelled() &&
263-
needs.quality-checks.result == 'success' &&
264-
(github.event_name != 'pull_request' || (needs.review-thread-gate.result == 'success' && needs.review-thread-gate.outputs.allow_heavy == 'true'))
265-
266-
permissions:
267-
contents: read
268-
269-
outputs:
270-
image-built: ${{ steps.build.outputs.digest }}
271-
272-
steps:
273221
- name: Checkout code
274222
uses: actions/checkout@v6
275223

@@ -534,7 +482,9 @@ jobs:
534482
name: Pipeline Summary
535483
runs-on: ubuntu-latest
536484
permissions: {}
537-
needs: [quality-checks, review-thread-gate, docker-build, semantic-release, mcpb-bundle, docker-publish, mcp-registry-publish]
485+
# All jobs listed so summary can read their outputs/results.
486+
# if: always() ensures this runs even when needs jobs are skipped/failed.
487+
needs: [quality-checks, docker-build, semantic-release, mcpb-bundle, docker-publish, mcp-registry-publish]
538488
if: always()
539489

540490
steps:
@@ -551,12 +501,6 @@ jobs:
551501
552502
if [[ "${{ needs.docker-build.result }}" == "success" ]]; then
553503
echo "✅ Docker build: Passed" >> $GITHUB_STEP_SUMMARY
554-
elif [[ "${{ needs.docker-build.result }}" == "skipped" && "${{ needs.review-thread-gate.result }}" == "skipped" ]]; then
555-
echo "⏭️ Docker build: Skipped (review gate not applicable)" >> $GITHUB_STEP_SUMMARY
556-
elif [[ "${{ needs.docker-build.result }}" == "skipped" && "${{ needs.review-thread-gate.result }}" == "failure" ]]; then
557-
echo "⏭️ Docker build: Skipped (review gate failed)" >> $GITHUB_STEP_SUMMARY
558-
elif [[ "${{ needs.docker-build.result }}" == "skipped" && "${{ needs.review-thread-gate.outputs.allow_heavy }}" == "false" ]]; then
559-
echo "⏭️ Docker build: Skipped (unresolved review threads)" >> $GITHUB_STEP_SUMMARY
560504
elif [[ "${{ needs.docker-build.result }}" == "skipped" ]]; then
561505
echo "⏭️ Docker build: Skipped" >> $GITHUB_STEP_SUMMARY
562506
else

0 commit comments

Comments
 (0)