From 4113b8cfb0c3fd82c5b37a9b2a2e3451d8ccc285 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:32:42 -0400 Subject: [PATCH] ci: gate releases on CI evidence, stand down on stale SHA, SHA-pin actions, group dependabot - #290: new gate job requires Validate success on the pushed SHA and, for PR merges, every PR check green (Blender Smoke is PR-only); release job needs it. Job-level permissions (workflow default is read); explicit git add paths instead of -A - #226: release exits cleanly when origin/main moved past the checked-out SHA or the bump push is rejected; the queued newer run publishes the whole range - #306: every workflow action pinned to a full commit SHA with a # vX.Y comment; Validate fails on any unpinned uses: - #308: dependabot weekly, grouped, chore(deps) prefix Closes #290, closes #226, closes #306, closes #308 Co-Authored-By: Claude Sonnet 5.5 --- .github/dependabot.yml | 14 +++++- .github/scripts/release-gate.sh | 59 ++++++++++++++++++++++++++ .github/workflows/blender-smoke.yml | 2 +- .github/workflows/drift-check.yml | 4 +- .github/workflows/label-sync.yml | 2 +- .github/workflows/pages.yml | 10 ++--- .github/workflows/release.yml | 66 +++++++++++++++++++++++++---- .github/workflows/stale.yml | 2 +- .github/workflows/validate.yml | 24 ++++++++--- 9 files changed, 156 insertions(+), 27 deletions(-) create mode 100644 .github/scripts/release-gate.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5409f003..2bee01dd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,10 +3,22 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" + interval: "weekly" target-branch: "main" + commit-message: + prefix: "chore(deps)" + groups: + github-actions: + patterns: + - "*" - package-ecosystem: "pip" directory: "/scripts/site" schedule: interval: "weekly" target-branch: "main" + commit-message: + prefix: "chore(deps)" + groups: + site-build: + patterns: + - "*" diff --git a/.github/scripts/release-gate.sh b/.github/scripts/release-gate.sh new file mode 100644 index 00000000..34ee9891 --- /dev/null +++ b/.github/scripts/release-gate.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Release gate: exit non-zero unless CI evidence for $SHA is green. +# +# 1. Validate (push event) finished and succeeded on exactly this SHA. +# 2. If the SHA was merged from a PR, every check on that PR passed or was +# skipped. Blender Smoke runs on PRs only, so this is its evidence. +# +# Env: GH_TOKEN, SHA, GITHUB_REPOSITORY. Optional: GATE_EVENT (default push), +# GATE_TIMEOUT seconds (default 1500), GATE_POLL seconds (default 20). +set -euo pipefail + +repo="${GITHUB_REPOSITORY:?}" +sha="${SHA:?}" +event="${GATE_EVENT:-push}" +deadline=$((SECONDS + ${GATE_TIMEOUT:-1500})) + +while :; do + read -r status conclusion < <( + gh run list --repo "$repo" --workflow validate.yml --commit "$sha" \ + --event "$event" --limit 1 --json status,conclusion \ + --jq '.[0] // {} | "\(.status // "none") \(.conclusion // "none")"' + ) + [ "$status" = "completed" ] && break + if [ "$SECONDS" -ge "$deadline" ]; then + echo "::error::Validate did not finish for $sha (last status: $status)" + exit 1 + fi + echo "Validate status for $sha: $status; waiting" + sleep "${GATE_POLL:-20}" +done + +if [ "$conclusion" != "success" ]; then + echo "::error::Validate concluded '$conclusion' for $sha; not releasing" + exit 1 +fi +echo "Validate: success" + +pr=$(gh api "repos/$repo/commits/$sha/pulls" --jq '.[0].number // empty') +if [ -z "$pr" ]; then + echo "No PR is associated with $sha (direct push); Validate alone gates this release" + exit 0 +fi + +# gh exits non-zero while checks are pending or failing even with --json, so +# judge the JSON itself (gh's built-in --jq, no jq binary needed) and treat +# unreadable output as a failed gate. +count=$(gh pr checks "$pr" --repo "$repo" --json name --jq 'length' 2>/dev/null) || true +case "$count" in + ''|*[!0-9]*|0) + echo "::error::could not read checks for PR #$pr; not releasing" + exit 1 + ;; +esac +bad=$(gh pr checks "$pr" --repo "$repo" --json name,bucket --jq '[.[] | select(.bucket != "pass" and .bucket != "skipping") | "\(.name)=\(.bucket)"] | join(", ")' 2>/dev/null) || true +if [ -n "$bad" ]; then + echo "::error::PR #$pr has checks that did not pass: $bad; not releasing" + exit 1 +fi +echo "PR #$pr: $count checks passed or were skipped" diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 3c4cf660..6dc77317 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -85,7 +85,7 @@ jobs: matrix: series: ${{ fromJSON(needs.resolve-matrix.outputs.series) }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Harness protocol unit tests run: | diff --git a/.github/workflows/drift-check.yml b/.github/workflows/drift-check.yml index cad8434f..51248285 100644 --- a/.github/workflows/drift-check.yml +++ b/.github/workflows/drift-check.yml @@ -14,8 +14,8 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v7 - - uses: TMHSDigital/Developer-Tools-Directory/.github/actions/drift-check@v1.15 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: TMHSDigital/Developer-Tools-Directory/.github/actions/drift-check@9be79799df00ed42a0c4cff39e74a383a493296c # v1.15 with: mode: self format: gh-summary diff --git a/.github/workflows/label-sync.yml b/.github/workflows/label-sync.yml index 6442eb36..4cf1aec8 100644 --- a/.github/workflows/label-sync.yml +++ b/.github/workflows/label-sync.yml @@ -13,7 +13,7 @@ jobs: name: Auto-label by path runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Get changed files id: changed diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 8ad8d4da..afd8fee3 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -67,13 +67,13 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # Full history: the landing page's "Recently added" dates come from # the commit that first added each example (a shallow clone hides it). fetch-depth: 0 - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.12" cache: pip @@ -92,7 +92,7 @@ jobs: - name: Check every internal link, anchor and image alt run: python tests/check_site_links.py - - uses: actions/configure-pages@v6 + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 - name: Stage the public site (leave out internal docs and unlinked sheets) # Everything stays in the repo; only the Pages artifact shrinks. No @@ -103,9 +103,9 @@ jobs: rm -rf _site/gallery/contact-sheets _site/gallery/asset-sheets _site/gallery/DESIGN_NOTES.md rm -f _site/*.md - - uses: actions/upload-pages-artifact@v5 + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: path: _site - - uses: actions/deploy-pages@v5 + - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5 id: deployment diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb300863..10e4b030 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,20 +6,45 @@ on: workflow_dispatch: {} permissions: - contents: write - actions: write + contents: read concurrency: group: release cancel-in-progress: false jobs: + gate: + name: Gate on CI evidence + # A push to main must not publish unless Validate passed on this exact SHA and, + # when it was merged from a PR, every check on that PR passed (Blender Smoke + # runs on PRs only). Direct pushes have no PR; Validate alone gates them. + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip ci]')" + permissions: + contents: read + actions: read + checks: read + pull-requests: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + sparse-checkout: .github/scripts + - name: Require green Validate and PR checks + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.sha }} + run: bash .github/scripts/release-gate.sh + version-and-release: name: Bump version, tag, and release + needs: gate runs-on: ubuntu-latest if: "!contains(github.event.head_commit.message, '[skip ci]')" + permissions: + contents: write + actions: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -117,7 +142,7 @@ jobs: - name: Sync release docs if: steps.check.outputs.skip == 'false' && steps.bump.outputs.release == 'true' - uses: TMHSDigital/Developer-Tools-Directory/.github/actions/release-doc-sync@v1 + uses: TMHSDigital/Developer-Tools-Directory/.github/actions/release-doc-sync@7886cbe6ef93d57cc73c020b98268fa0dc0bdc98 # v1 with: plugin-version: ${{ steps.new.outputs.version }} previous-version: ${{ steps.current.outputs.version }} @@ -156,20 +181,43 @@ jobs: PYEOF - name: Commit version bump + id: commit if: steps.check.outputs.skip == 'false' && steps.bump.outputs.release == 'true' run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A + + # Another PR may have merged after this run checked out (#226). The + # queued run for the newer SHA scans the whole range since the last + # tag and releases everything, so stand down cleanly instead of + # failing with a non-fast-forward push. + git fetch -q origin main + if [ "$(git rev-parse origin/main)" != "$(git rev-parse HEAD)" ]; then + echo "::notice::origin/main moved past $GITHUB_SHA; the newer release run will publish this range" + echo "stale=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Explicit paths only: the files this workflow owns, never `git add -A`. + git add -- VERSION CHANGELOG.md CLAUDE.md ROADMAP.md \ + .cursor-plugin/plugin.json .claude-plugin/plugin.json .claude-plugin/marketplace.json + if [ -n "$(git status --porcelain)" ]; then + echo "::warning::files changed that the release commit does not own:" + git status --porcelain + fi if git diff --cached --quiet; then echo "No changes to commit" else git commit -s -m "chore: bump version to ${{ steps.new.outputs.version }} [skip ci]" - git push origin main + if ! git push origin main; then + echo "::notice::push rejected (main moved); the newer release run will publish this range" + echo "stale=true" >> "$GITHUB_OUTPUT" + exit 0 + fi fi - name: Create and push tag - if: steps.check.outputs.skip == 'false' && steps.bump.outputs.release == 'true' + if: steps.check.outputs.skip == 'false' && steps.bump.outputs.release == 'true' && steps.commit.outputs.stale != 'true' run: | new_version="${{ steps.new.outputs.version }}" IFS='.' read -r major minor _patch <<< "$new_version" @@ -183,7 +231,7 @@ jobs: git push origin "v$major.$minor" --force - name: Create GitHub Release - if: steps.check.outputs.skip == 'false' && steps.bump.outputs.release == 'true' + if: steps.check.outputs.skip == 'false' && steps.bump.outputs.release == 'true' && steps.commit.outputs.stale != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -192,7 +240,7 @@ jobs: --generate-notes - name: Dispatch Pages - if: steps.check.outputs.skip == 'false' && steps.bump.outputs.release == 'true' + if: steps.check.outputs.skip == 'false' && steps.bump.outputs.release == 'true' && steps.commit.outputs.stale != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh workflow run pages.yml --ref main diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index fc951026..241a7cca 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v10 + - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10 with: stale-issue-message: "This issue has been automatically marked as stale due to inactivity. It will be closed in 7 days if no further activity occurs." stale-pr-message: "This PR has been automatically marked as stale due to inactivity. It will be closed in 7 days if no further activity occurs." diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index cf0d5a33..e53a1142 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -14,7 +14,7 @@ jobs: name: Validate structure and frontmatter runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Check referenced paths exist run: | @@ -192,9 +192,9 @@ jobs: # so a template error or a dead link otherwise surfaces in production. runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.12" cache: pip @@ -212,7 +212,7 @@ jobs: name: Validate plugin manifest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Check plugin.json matches filesystem and VERSION run: | @@ -281,7 +281,7 @@ jobs: name: Validate Claude Code packaging runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Check .claude-plugin manifests match VERSION and the skills on disk run: | @@ -323,6 +323,16 @@ jobs: print('Claude Code packaging verified') PYEOF + - name: Check every workflow action is pinned to a full commit SHA + run: | + bad=$(grep -rnE '^\s*(-\s+)?uses:\s+[^./ ]' .github/workflows \ + | grep -vE 'uses:\s+\S+@[0-9a-f]{40}(\s|$)' || true) + if [ -n "$bad" ]; then + echo "$bad" + echo "::error::pin these actions to a full commit SHA with a trailing '# vX.Y' comment (#306)" + exit 1 + fi + - name: Check CLAUDE.md carries no personal plugin routing block run: | if grep -nE 'context-mode|MANDATORY routing rules|ctx_(execute|batch_execute|search|fetch_and_index)' CLAUDE.md; then @@ -337,7 +347,7 @@ jobs: name: Validate content counts runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Check content counts match README env: @@ -440,7 +450,7 @@ jobs: name: Validate smoke harness protocol runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Harness unit tests run: python3 tests/smoke/test_harness.py -v