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
70 changes: 58 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [detect-changes]
# always() && !cancelled() lets this job evaluate even though detect-changes
# is skipped for workflow_dispatch; without it, the skipped dependency would
# propagate and skip lint despite the workflow_dispatch condition below.
# !cancelled() lets this job evaluate even though detect-changes is skipped
# for workflow_dispatch while still propagating workflow cancellation.
if: |
always() && !cancelled() && (
!cancelled() && (
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.py-changed == 'true' ||
Expand All @@ -116,6 +115,14 @@ jobs:
)
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Simulate fresh merge with base branch (PR only)
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: bash scripts/simulate-fresh-merge.sh

- name: Setup Python
uses: actions/setup-python@v6
Expand Down Expand Up @@ -173,6 +180,9 @@ jobs:
cd "${{ steps.python_layout.outputs.root }}"
python scripts/check_file_size.py

- name: Check for secrets
run: npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint "**/*"

# === TEST ===
# Test on latest Python version only
test:
Expand All @@ -182,11 +192,10 @@ jobs:
needs: [detect-changes]
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# always() && !cancelled() lets this job evaluate even though detect-changes
# is skipped for workflow_dispatch; without it, the skipped dependency would
# propagate and skip test despite the workflow_dispatch condition below.
# !cancelled() lets this job evaluate even though detect-changes is skipped
# for workflow_dispatch while still propagating workflow cancellation.
if: |
always() && !cancelled() && (
!cancelled() && (
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.py-changed == 'true' ||
Expand Down Expand Up @@ -260,7 +269,7 @@ jobs:
needs: [detect-changes, lint, test]
# Run if: push/dispatch event, OR lint/test succeeded, OR lint/test were skipped (docs-only PR)
if: |
always() && (
!cancelled() && (
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
(
Expand Down Expand Up @@ -404,6 +413,43 @@ jobs:

echo "✓ Changelog check passed"

# === DOCKER IMAGE BUILD CHECK (pull requests) ===
# Build without pushing so a broken Dockerfile fails before package publish.
# Skip cleanly when a generated repository does not include a Dockerfile.
docker-build:
name: Docker Image Build Check
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [detect-changes]
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true'
steps:
- uses: actions/checkout@v6

- name: Detect Dockerfile
id: dockerfile
run: |
if [ -f Dockerfile ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping Docker build because no Dockerfile is present"
fi

- name: Set up Docker Buildx
if: steps.dockerfile.outputs.exists == 'true'
uses: docker/setup-buildx-action@v4

- name: Build Docker image (no push)
if: steps.dockerfile.outputs.exists == 'true'
uses: docker/build-push-action@v7
with:
context: .
push: false
load: true
tags: app:pr-check
cache-from: type=gha
cache-to: type=gha,mode=max

# RELEASE JOBS - Only run after all CI checks pass

# Automatic release on push to main (if version changed)
Expand Down Expand Up @@ -513,11 +559,11 @@ jobs:
manual-release:
name: Manual Release
needs: [lint, test, build]
# always() && !cancelled() prevents the skipped detect-changes dependency from
# propagating through lint/test/build and silently skipping the release.
# !cancelled() prevents the skipped detect-changes dependency from propagating
# through lint/test/build while still propagating workflow cancellation.
# The explicit result checks ensure release only proceeds after CI passes.
if: |
always() && !cancelled() &&
!cancelled() &&
github.event_name == 'workflow_dispatch' &&
needs.lint.result == 'success' &&
needs.test.result == 'success' &&
Expand Down
7 changes: 7 additions & 0 deletions .secretlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"rules": [
{
"id": "@secretlint/secretlint-rule-preset-recommend"
}
]
}
9 changes: 9 additions & 0 deletions changelog.d/20260722_issue_35_ci_release_guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Added

- Validate pull requests with a fresh base-branch merge, a secrets scan, and a
cached Docker image build when a Dockerfile is present.

### Fixed

- Propagate workflow cancellation through dependent CI and release jobs instead
of combining contradictory `always()` and `!cancelled()` guards.
29 changes: 29 additions & 0 deletions scripts/simulate-fresh-merge.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Simulate merging the latest PR base so CI validates the result that will land.

set -euo pipefail

if [ -z "${BASE_REF:-}" ]; then
echo "BASE_REF is required" >&2
exit 2
fi

echo "Synchronizing PR with latest $BASE_REF"

git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git fetch origin "$BASE_REF"

behind_count=$(git rev-list --count "HEAD..origin/$BASE_REF")
if [ "$behind_count" -eq 0 ]; then
echo "Merge preview is up to date with $BASE_REF"
exit 0
fi

echo "Base branch has $behind_count new commit(s); simulating a fresh merge"
if git merge "origin/$BASE_REF" --no-edit; then
echo "Fresh merge simulation succeeded"
else
echo "::error::Merge conflict detected with latest $BASE_REF" >&2
exit 1
fi
24 changes: 24 additions & 0 deletions tests/test_simulate_fresh_merge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Tests for the fresh-merge simulation used by pull-request CI."""

from __future__ import annotations

import subprocess
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "simulate-fresh-merge.sh"


def test_fresh_merge_script_requires_base_ref() -> None:
"""The script should fail clearly instead of fetching an empty branch name."""
result = subprocess.run(
["bash", str(SCRIPT)],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)

assert result.returncode != 0
assert "BASE_REF is required" in result.stderr
52 changes: 51 additions & 1 deletion tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def test_release_workflow_jobs_have_explicit_timeouts() -> None:
"test": 30,
"build": 20,
"changelog": 10,
"docker-build": 60,
"auto-release": 30,
"manual-release": 30,
}
Expand All @@ -105,7 +106,7 @@ def test_release_workflow_action_versions_are_current() -> None:
"""Release workflow actions should use the current major versions."""
release_workflow = read_workflow("release.yml")

assert_action_pin_count(release_workflow, "actions/checkout", "v6", 7)
assert_action_pin_count(release_workflow, "actions/checkout", "v6", 8)
assert_action_pin_count(release_workflow, "actions/setup-python", "v6", 7)
assert_action_pin_count(release_workflow, "actions/upload-artifact", "v7", 1)
assert_action_pin_count(release_workflow, "actions/download-artifact", "v7", 1)
Expand Down Expand Up @@ -211,6 +212,55 @@ def test_dispatch_dependent_jobs_use_status_check_function() -> None:
)


def test_release_workflow_propagates_cancellation() -> None:
"""Dependent jobs must stop when a workflow run is cancelled."""
workflow = read_workflow("release.yml")

assert "always() && !cancelled()" not in workflow
for job_name in ("lint", "test", "build", "manual-release"):
condition = job_condition(workflow, job_name)
assert "!cancelled()" in condition
assert "always()" not in condition


def test_release_workflow_checks_fresh_merge_and_secrets() -> None:
"""Pull requests must test a fresh base merge and scan for secrets."""
workflow = read_workflow("release.yml")
lint = workflow_job_block(workflow, "lint")

assert "fetch-depth: 0" in lint
assert "- name: Simulate fresh merge with base branch (PR only)" in lint
assert "if: github.event_name == 'pull_request'" in lint
assert "BASE_REF: ${{ github.base_ref }}" in lint
assert "run: bash scripts/simulate-fresh-merge.sh" in lint
assert "- name: Check for secrets" in lint
assert (
"npx --yes -p secretlint -p "
'@secretlint/secretlint-rule-preset-recommend secretlint "**/*"' in lint
)
secretlint_config = (ROOT / ".secretlintrc.json").read_text(encoding="utf-8")
assert '"id": "@secretlint/secretlint-rule-preset-recommend"' in secretlint_config


def test_release_workflow_builds_docker_images_on_pull_requests() -> None:
"""Docker regressions must fail before packages are published."""
workflow = read_workflow("release.yml")
block = workflow_job_block(workflow, "docker-build")

assert "name: Docker Image Build Check" in block
assert "needs: [detect-changes]" in block
assert (
"if: github.event_name == 'pull_request' && "
"needs.detect-changes.outputs.any-code-changed == 'true'" in block
)
assert "uses: docker/setup-buildx-action@v4" in block
assert "uses: docker/build-push-action@v7" in block
assert "push: false" in block
assert "load: true" in block
assert "cache-from: type=gha" in block
assert "cache-to: type=gha,mode=max" in block


def test_manual_release_requires_required_checks_to_succeed() -> None:
"""Manual release must only run after lint, test, and build succeed."""
workflow = read_workflow("release.yml")
Expand Down
Loading