From 4eaeb2e76ab66e0029d61400732dee7ebed5f43d Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 22 Jul 2026 21:23:58 +0000 Subject: [PATCH 1/3] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/python-ai-driven-development-pipeline-template/issues/35 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..d6eafdf --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-07-22T21:23:58.378Z for PR creation at branch issue-35-da5affce804e for issue https://github.com/link-foundation/python-ai-driven-development-pipeline-template/issues/35 \ No newline at end of file From 0f5b705619a3e32df09fcb763052a0c4a61e0ae5 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 22 Jul 2026 21:27:58 +0000 Subject: [PATCH 2/3] fix(ci): harden pull request validation --- .github/workflows/release.yml | 70 +++++++++++++++---- .gitkeep | 1 - .../20260722_issue_35_ci_release_guards.md | 9 +++ scripts/simulate-fresh-merge.sh | 29 ++++++++ tests/test_simulate_fresh_merge.py | 24 +++++++ tests/test_workflows.py | 50 ++++++++++++- 6 files changed, 169 insertions(+), 14 deletions(-) delete mode 100644 .gitkeep create mode 100644 changelog.d/20260722_issue_35_ci_release_guards.md create mode 100644 scripts/simulate-fresh-merge.sh create mode 100644 tests/test_simulate_fresh_merge.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9485fa4..d21f877 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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' || @@ -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 @@ -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: @@ -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' || @@ -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' || ( @@ -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) @@ -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' && diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index d6eafdf..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-07-22T21:23:58.378Z for PR creation at branch issue-35-da5affce804e for issue https://github.com/link-foundation/python-ai-driven-development-pipeline-template/issues/35 \ No newline at end of file diff --git a/changelog.d/20260722_issue_35_ci_release_guards.md b/changelog.d/20260722_issue_35_ci_release_guards.md new file mode 100644 index 0000000..9b59f84 --- /dev/null +++ b/changelog.d/20260722_issue_35_ci_release_guards.md @@ -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. diff --git a/scripts/simulate-fresh-merge.sh b/scripts/simulate-fresh-merge.sh new file mode 100644 index 0000000..d807e2e --- /dev/null +++ b/scripts/simulate-fresh-merge.sh @@ -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 diff --git a/tests/test_simulate_fresh_merge.py b/tests/test_simulate_fresh_merge.py new file mode 100644 index 0000000..27375f9 --- /dev/null +++ b/tests/test_simulate_fresh_merge.py @@ -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 diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 122edf2..d693f26 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -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, } @@ -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) @@ -210,6 +211,53 @@ 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 + ) + + +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") From 29d65597dbd78bde939b2015a3056f6bfa9729c9 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 22 Jul 2026 21:30:03 +0000 Subject: [PATCH 3/3] fix(ci): configure secretlint rules --- .secretlintrc.json | 7 +++++++ tests/test_workflows.py | 2 ++ 2 files changed, 9 insertions(+) create mode 100644 .secretlintrc.json diff --git a/.secretlintrc.json b/.secretlintrc.json new file mode 100644 index 0000000..7a1a5df --- /dev/null +++ b/.secretlintrc.json @@ -0,0 +1,7 @@ +{ + "rules": [ + { + "id": "@secretlint/secretlint-rule-preset-recommend" + } + ] +} diff --git a/tests/test_workflows.py b/tests/test_workflows.py index d693f26..2d56114 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -237,6 +237,8 @@ def test_release_workflow_checks_fresh_merge_and_secrets() -> None: "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: