From 34fe29bf9e6e6f878959ef737c673c33b1416b8b Mon Sep 17 00:00:00 2001 From: Stoney Jackson Date: Mon, 27 Jul 2026 13:53:04 -0400 Subject: [PATCH 1/2] ci: skip the image build when a change cannot affect the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every PR rebuilt and pushed the devcontainer image regardless of what it touched — the App-token PR is doing exactly that right now for a change confined to release workflows. A cheap `changes` job classifies the diff and the expensive steps are guarded on it. The jobs themselves still run and still post their checks: skipping a job leaves its required check unreported, which is the failure this repo spent today stuck in. ci-gate now also gates on `changes`, so a failure there blocks rather than silently skipping the build. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 71 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e54e8ef..23577bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,13 +12,56 @@ permissions: pull-requests: write jobs: + # Every PR rebuilt the image, including ones touching only workflows or + # docs. This job classifies the diff; the expensive steps below are guarded + # on it. Note the jobs themselves still run and still post their checks — + # skipping a job leaves its check unreported, which is what hung PRs here + # before ci-gate existed. + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + image: ${{ steps.filter.outputs.image }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # base..head diff needs both endpoints + + - name: Detect changes that can affect the image + id: filter + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + CHANGED=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA") + echo "Changed files:" + echo "$CHANGED" + + # .devcontainer/ defines the image and the local plcc feature; + # test/ holds test-env.sh, which is the build's runCmd. The root + # devcontainer.json is the course template and is not built here. + # if/else, not `&&`, because this runs under bash -e. + if echo "$CHANGED" | grep -qE '^\.devcontainer/|^test/|^\.github/workflows/ci\.yml$'; then + IMAGE=true + else + IMAGE=false + fi + + echo "image=$IMAGE" >> "$GITHUB_OUTPUT" + echo "=> image build: $IMAGE" + build: + needs: changes runs-on: ubuntu-latest steps: - name: Checkout + if: needs.changes.outputs.image == 'true' uses: actions/checkout@v4 - name: Log in to GHCR + if: needs.changes.outputs.image == 'true' uses: docker/login-action@v3 with: registry: ghcr.io @@ -26,6 +69,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and test devcontainer image + if: needs.changes.outputs.image == 'true' uses: devcontainers/ci@v0.3 with: imageName: ghcr.io/ourplcc/plcc-devcontainer @@ -33,12 +77,14 @@ jobs: runCmd: bash test/test-env.sh - name: Read PLCC version + if: needs.changes.outputs.image == 'true' id: plcc_version run: | echo "version=$(jq -r '.features["./features/plcc"].version' .devcontainer/devcontainer.json)" \ >> "$GITHUB_OUTPUT" - name: Apply OCI labels and push PR image + if: needs.changes.outputs.image == 'true' # devcontainers/ci built the image locally as ghcr.io/ourplcc/plcc-devcontainer:latest. # We re-tag it with OCI labels and push as the PR-specific tag. run: | @@ -57,6 +103,8 @@ jobs: --push . - name: Comment image tag on PR + # No image was built, so there is no tag to advertise. + if: needs.changes.outputs.image == 'true' uses: actions/github-script@v7 with: script: | @@ -76,8 +124,8 @@ jobs: # `build` would fix it today but break again the moment that job gains a # matrix and its check becomes `build (x)`. This name never changes. ci-gate: - needs: [build] - # Must run even when build fails — without this the gate is itself + needs: [changes, build] + # Must run even when a dependency fails — without this the gate is itself # skipped on failure, reports nothing, and reproduces the same hang. if: always() runs-on: ubuntu-latest @@ -85,13 +133,22 @@ jobs: steps: - name: Require all CI jobs to have succeeded env: + CHANGES: ${{ needs.changes.result }} BUILD: ${{ needs.build.result }} run: | - echo "build=$BUILD" - # Anything other than success — failure, cancelled, or skipped — - # must block the merge. - if [ "$BUILD" != "success" ]; then - echo "::error::CI did not fully succeed — build was '$BUILD'." + failed=0 + for entry in "changes=$CHANGES" "build=$BUILD"; do + echo "$entry" + # Anything other than success — failure, cancelled, or skipped + # because an upstream job failed — must block the merge. + case "${entry#*=}" in + success) ;; + *) failed=1 ;; + esac + done + + if [ "$failed" -ne 0 ]; then + echo "::error::CI did not fully succeed — see the job results above." exit 1 fi echo "All required CI jobs succeeded." From a43c2f60e06cb6bf568f803bfbc68aa9483234b8 Mon Sep 17 00:00:00 2001 From: Stoney Jackson Date: Mon, 27 Jul 2026 14:12:19 -0400 Subject: [PATCH 2/2] ci: cancel superseded PR runs instead of building twice Pushing a follow-up commit to an open PR left the previous run building and pushing the image to completion, so a branch could have two full image pushes to the same GHCR package in flight at once. Only the newest commit's result gates the merge, so the older run is cancelled. Scoped per PR, so runs on different PRs never cancel each other. Matches plcc-ng-devcontainer. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23577bb..72b7eb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,13 @@ permissions: packages: write pull-requests: write +# Pushing to an open PR otherwise leaves the previous run building and +# pushing an image that is already superseded — two full image pushes to the +# same GHCR package at once. Only the newest commit's result gates the merge. +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: # Every PR rebuilt the image, including ones touching only workflows or # docs. This job classifies the diff; the expensive steps below are guarded