From e259f80e90f388325907869f91eac79a3d3bd6bc Mon Sep 17 00:00:00 2001 From: Stoney Jackson Date: Mon, 27 Jul 2026 09:33:11 -0400 Subject: [PATCH 1/2] ci: make the release-check workflow parseable and non-wedging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --body continuation lines sat at column 0, below the `run: |` block indentation, which ends the YAML block scalar. The file has never been valid YAML, so GitHub could not load it — the Actions API still lists this workflow by path instead of by name. It has never run, which is why the missing 'automated' label never surfaced here. Alongside that, the same three defects fixed in plcc-ng-devcontainer: gh pr create resolved --label before creating the PR, so a label problem aborted the run after the branch was already pushed; the idempotency guard keyed on branch existence, turning that into a permanent no-op; and the commit type was hardcoded to `fix:`, so a new PLCC major would ship the image as a patch and move the :N tag courses pin onto a new major. Co-Authored-By: Claude Opus 5 --- .github/workflows/check-plcc-release.yml | 95 +++++++++++++++++------- 1 file changed, 69 insertions(+), 26 deletions(-) diff --git a/.github/workflows/check-plcc-release.yml b/.github/workflows/check-plcc-release.yml index b2dd8a2..b0476a2 100644 --- a/.github/workflows/check-plcc-release.yml +++ b/.github/workflows/check-plcc-release.yml @@ -39,40 +39,83 @@ jobs: BRANCH="chore/update-plcc-${LATEST}" - # Check whether a PR branch already exists (idempotency guard) - if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then - echo "Branch '$BRANCH' already exists. PR already open." + # The image's major tag is the stability contract for courses, so a + # new PLCC major has to release the image as a major too. Otherwise a + # patch release would move :N onto a new major of the tool. Tags are + # v-prefixed, so strip it before comparing. + # Deliberately `feat:` and not `feat!:` — the Angular preset that + # semantic-release uses does not parse the `!` shorthand, so a + # `feat!:` subject whose BREAKING CHANGE footer went missing yields + # no release at all. `feat:` degrades to a minor instead. + if [ "$(echo "${LATEST#v}" | cut -d. -f1)" != "$(echo "${CURRENT#v}" | cut -d. -f1)" ]; then + COMMIT_SUBJECT="feat: update PLCC to ${LATEST}" + COMMIT_BODY="BREAKING CHANGE: PLCC ${LATEST} is a new major version (was ${CURRENT}). Images already published under the previous major tag keep PLCC ${CURRENT}; this release publishes under a new major tag." + else + COMMIT_SUBJECT="fix: update PLCC to ${LATEST}" + COMMIT_BODY="" + fi + echo "Release type: $COMMIT_SUBJECT" + + # Idempotency guard: keyed on the PR, not the branch. A branch with no + # PR means an earlier run died between push and PR creation; that must + # be recoverable, not mistaken for work already done. + if [ -n "$(gh pr list --head "$BRANCH" --state all --json number --jq '.[].number')" ]; then + echo "A PR for '$BRANCH' already exists. No action needed." exit 0 fi - # Create update branch - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git checkout -b "$BRANCH" + if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then + echo "Branch '$BRANCH' exists with no PR. Recovering by opening the PR." + # actions/checkout configures a single-branch refspec, so no + # origin/$BRANCH tracking ref exists here — use FETCH_HEAD. + git fetch --depth=1 origin "$BRANCH" + git checkout -B "$BRANCH" FETCH_HEAD + else + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" - # Update the pinned PLCC version in devcontainer.json - jq --arg v "$LATEST" \ - '.features["./features/plcc"].version = $v' \ - .devcontainer/devcontainer.json > tmp.json - mv tmp.json .devcontainer/devcontainer.json + # Update the pinned PLCC version in devcontainer.json + jq --arg v "$LATEST" \ + '.features["./features/plcc"].version = $v' \ + .devcontainer/devcontainer.json > tmp.json + mv tmp.json .devcontainer/devcontainer.json - git add .devcontainer/devcontainer.json - git commit -m "fix: update PLCC to ${LATEST}" + git add .devcontainer/devcontainer.json + if [ -n "$COMMIT_BODY" ]; then + git commit -m "$COMMIT_SUBJECT" -m "$COMMIT_BODY" + else + git commit -m "$COMMIT_SUBJECT" + fi - # Push branch; treat push failure as no-op (race condition guard) - git push origin "$BRANCH" || { - echo "Push failed — branch may already exist. Exiting cleanly." - exit 0 - } + # Push branch; treat push failure as no-op (race condition guard) + git push origin "$BRANCH" || { + echo "Push failed — concurrent run. Exiting cleanly." + exit 0 + } + fi - # Open PR - gh pr create \ - --title "fix: update PLCC to ${LATEST}" \ - --label "automated" \ + # The PR title becomes the squash-merge commit subject, so it carries + # the release type. The body carries the BREAKING CHANGE footer for + # the same reason — on a major, both are what semantic-release reads. + # Every line stays indented to the run block. A line at column 0 ends + # the YAML block scalar, which is what made this file unparseable and + # kept the workflow from ever running. YAML strips this indentation, + # so the body text itself is unaffected. + PR_URL=$(gh pr create \ + --title "$COMMIT_SUBJECT" \ --body "Automated update: PLCC has a new release (${LATEST}). -CI will build and test the image on this PR. Merge if green — merging triggers the release workflow, which publishes a new versioned image automatically. + CI will build and test the image on this PR. Merge if green — merging triggers the release workflow, which publishes a new versioned image automatically. + + PLCC release: https://github.com/ourPLCC/plcc/releases/tag/${LATEST} -PLCC release: https://github.com/ourPLCC/plcc/releases/tag/${LATEST}" \ + ${COMMIT_BODY}" \ --base main \ - --head "$BRANCH" + --head "$BRANCH") + + echo "Opened $PR_URL" + + # Cosmetic only — never fail the run over a missing label or scope. + gh pr edit "$PR_URL" --add-label automated \ + || echo "::warning::Could not apply the 'automated' label to $PR_URL" From d96d0ebee475abda6e7346f3cc433e0a86a4025d Mon Sep 17 00:00:00 2001 From: Stoney Jackson Date: Mon, 27 Jul 2026 13:09:12 -0400 Subject: [PATCH 2/2] ci: add a stable ci-gate check for the ruleset to require MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruleset requires a check named "CI" — this workflow's top-level name. Actions posts one check per job and never one named after the workflow, so that check has never been reported and PRs hang on "Expected — waiting for status to be reported" while every real check is green. Requiring `build` instead would work today and break again as soon as that job gains a matrix, since its check would become `build (x)`. ci-gate's name never changes, so the ruleset never needs revisiting. It runs with `if: always()`; without that it would be skipped whenever build failed, report nothing, and reproduce the same hang. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddeba43..e54e8ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,3 +66,32 @@ jobs: repo: context.repo.repo, body: `🐳 **PR image built:** \`ghcr.io/ourplcc/plcc-devcontainer:pr-${{ github.event.pull_request.number }}\`\n\nTo test manually, update \`.devcontainer/devcontainer.json\` to use this image tag.` }) + + # The single check the ruleset should require. + # + # The ruleset previously required "CI" — this workflow's top-level name. + # Actions posts one check per job, never one named after the workflow, so + # that check was never reported and every PR hung on "Expected — waiting + # for status to be reported" while all real checks were green. Requiring + # `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 + # skipped on failure, reports nothing, and reproduces the same hang. + if: always() + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Require all CI jobs to have succeeded + env: + 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'." + exit 1 + fi + echo "All required CI jobs succeeded."