From 648a92e72aeb24d223117768d683ba0241ef55ae Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Thu, 21 May 2026 20:58:14 +1000 Subject: [PATCH 1/2] ci: auto-regenerate Dockerfiles on PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two GitHub Actions workflows that react when a PR changes docker/snippets/, docker/images.json, or docker/generate-dockerfiles.ps1: * regen-dockerfiles-push.yml — same-repo PRs only. Regenerates the Dockerfiles and pushes the result back to the PR branch. Permission scope: `contents: write`. * regen-dockerfiles-comment.yml — fork PRs only. Runs the base branch's trusted generator against the head's snippet data (never executes head code), detects drift, and posts a comment telling the contributor what to run. Refuses entirely if the PR modifies generate-dockerfiles.ps1. Permission scope: `contents: read` + `pull-requests: write`. The existing verify-generated-dockerfiles job in publish.yml stays as the hard CI gate — these workflows are the auto-fixer / nudge layered on top, so PRs like #112 (golang bump without regen) self-heal instead of needing a maintainer to push the regen commit manually. Co-authored-by: Claude Co-authored-by: GitButler --- .../workflows/regen-dockerfiles-comment.yml | 104 ++++++++++++++++++ .github/workflows/regen-dockerfiles-push.yml | 48 ++++++++ 2 files changed, 152 insertions(+) create mode 100644 .github/workflows/regen-dockerfiles-comment.yml create mode 100644 .github/workflows/regen-dockerfiles-push.yml diff --git a/.github/workflows/regen-dockerfiles-comment.yml b/.github/workflows/regen-dockerfiles-comment.yml new file mode 100644 index 0000000..04ea4d5 --- /dev/null +++ b/.github/workflows/regen-dockerfiles-comment.yml @@ -0,0 +1,104 @@ +name: Regenerate Dockerfiles (fork comment) + +# For fork PRs: pull_request_target runs in base context with pull-requests +# write, so we can comment. Head code is never executed — we run the base +# branch's trusted generator against the head's snippet data, and refuse +# entirely if the generator script itself was modified in the PR. + +on: + pull_request_target: + types: [opened, synchronize, reopened] + paths: + - "docker/snippets/**" + - "docker/images.json" + - "docker/generate-dockerfiles.ps1" + +permissions: {} + +concurrency: + group: regen-dockerfiles-comment-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + comment: + if: github.event.pull_request.head.repo.full_name != github.repository + name: Diff and comment + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout base (trusted generator) + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Checkout PR head into ./pr (data only, never executed) + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + path: pr + persist-credentials: false + + - name: Detect generator-script modification + id: gen + run: | + if ! diff -q docker/generate-dockerfiles.ps1 pr/docker/generate-dockerfiles.ps1 >/dev/null 2>&1; then + echo "generator_changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Use PR's data inputs with base's generator + if: steps.gen.outputs.generator_changed != 'true' + run: | + rsync -a --delete pr/docker/snippets/ docker/snippets/ + cp pr/docker/images.json docker/images.json + + - name: Run trusted generator + if: steps.gen.outputs.generator_changed != 'true' + run: pwsh docker/generate-dockerfiles.ps1 + + - name: Detect drift + if: steps.gen.outputs.generator_changed != 'true' + id: drift + run: | + if git diff --quiet -- docker/generated; then + echo "drift=false" >> "$GITHUB_OUTPUT" + else + echo "drift=true" >> "$GITHUB_OUTPUT" + git diff --stat docker/generated > /tmp/diffstat.txt + fi + + - name: Comment (drift) + if: steps.drift.outputs.drift == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + { + echo "Generated Dockerfiles in this PR are out of date with the snippet config." + echo "" + echo "Please run:" + echo "" + echo '```' + echo "pwsh docker/generate-dockerfiles.ps1" + echo '```' + echo "" + echo "and commit \`docker/generated/\` to your branch." + echo "" + echo "
What's stale" + echo "" + echo '```' + cat /tmp/diffstat.txt + echo '```' + echo "" + echo "
" + } > /tmp/body.md + gh pr comment ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --body-file /tmp/body.md + + - name: Comment (generator changed) + if: steps.gen.outputs.generator_changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr comment ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --body "This PR modifies \`docker/generate-dockerfiles.ps1\`. Auto-regeneration is skipped for safety — please run \`pwsh docker/generate-dockerfiles.ps1\` locally and commit any changes under \`docker/generated/\`." diff --git a/.github/workflows/regen-dockerfiles-push.yml b/.github/workflows/regen-dockerfiles-push.yml new file mode 100644 index 0000000..696f847 --- /dev/null +++ b/.github/workflows/regen-dockerfiles-push.yml @@ -0,0 +1,48 @@ +name: Regenerate Dockerfiles (push) + +# Auto-regenerates docker/generated/* when a same-repo PR changes snippet +# inputs or the generator script, and pushes the result back to the PR +# branch. Fork PRs are handled by regen-dockerfiles-comment.yml because +# pull_request's GITHUB_TOKEN is read-only for forks. + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - "docker/snippets/**" + - "docker/images.json" + - "docker/generate-dockerfiles.ps1" + +permissions: {} + +concurrency: + group: regen-dockerfiles-push-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + push: + if: github.event.pull_request.head.repo.full_name == github.repository + name: Regenerate and push + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Regenerate + run: pwsh docker/generate-dockerfiles.ps1 + + - name: Commit and push if drift + run: | + if git diff --quiet -- docker/generated; then + echo "No drift." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add docker/generated + git commit -m "chore(docker): regenerate Dockerfiles after snippet/config change" + git push origin HEAD:${{ github.event.pull_request.head.ref }} From c7d5a60746295d5014b85bbcae5904b82be8c05e Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Thu, 21 May 2026 21:18:50 +1000 Subject: [PATCH 2/2] fix(ci): detect untracked Dockerfiles and avoid spam comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review feedback from Codex and Copilot: * git diff --quiet ignored untracked files, so a new image entry in images.json would generate a brand-new docker/generated/Dockerfile.* that the drift check would miss — "No drift" would fire, the new file would never get committed (push workflow) or surfaced (comment workflow), and the existing verify-generated-dockerfiles job would fail without explanation. Switch both workflows to `git status --porcelain -- docker/generated`, which catches modified, deleted, and untracked entries in one shot. The comment workflow also appends a "Untracked new generated files" section to the diff stat so contributors see exactly what's new. * The fork-comment workflow posted a fresh PR comment on every synchronize event, which would spam long-running PRs. Introduce .github/scripts/sticky-pr-comment.sh — a 30-line helper that finds a prior bot comment by HTML marker and PATCHes it in place, creating a new one only when none exists. Both comment paths (drift and generator-changed) now use it with the same marker so the same comment is recycled. Co-authored-by: Claude Co-authored-by: GitButler --- .github/scripts/sticky-pr-comment.sh | 46 +++++++++++++++++++ .../workflows/regen-dockerfiles-comment.yml | 31 ++++++++++--- .github/workflows/regen-dockerfiles-push.yml | 6 ++- 3 files changed, 75 insertions(+), 8 deletions(-) create mode 100755 .github/scripts/sticky-pr-comment.sh diff --git a/.github/scripts/sticky-pr-comment.sh b/.github/scripts/sticky-pr-comment.sh new file mode 100755 index 0000000..eb570b5 --- /dev/null +++ b/.github/scripts/sticky-pr-comment.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Post or update a "sticky" PR comment, identified by an HTML marker comment +# in the first line of the body. Avoids spamming a long-running PR with a new +# comment on every push. +# +# Usage: sticky-pr-comment.sh +# +# The body file's FIRST line MUST be the HTML marker (e.g. +# +# ). Subsequent runs find the prior comment by exact-prefix match on that +# marker and PATCH it in place; if none exists yet, a new comment is created. +# +# Requires: gh (authenticated, with pull-requests:write), jq. + +set -euo pipefail + +repo=$1 +pr=$2 +body_file=$3 + +if [ ! -s "$body_file" ]; then + echo "sticky-pr-comment: body file '$body_file' is empty or missing" >&2 + exit 1 +fi + +marker=$(head -n 1 "$body_file") +case "$marker" in + '') ;; + *) + echo "sticky-pr-comment: first line of body file must be an HTML marker comment, got: $marker" >&2 + exit 1 + ;; +esac + +existing_id=$(gh api "repos/${repo}/issues/${pr}/comments" --paginate \ + --jq ".[] | select(.body | startswith(\"${marker}\")) | .id" \ + | head -n 1) + +if [ -n "$existing_id" ]; then + echo "sticky-pr-comment: updating existing comment $existing_id" + jq -n --rawfile body "$body_file" '{body: $body}' \ + | gh api -X PATCH "repos/${repo}/issues/comments/${existing_id}" --input - +else + echo "sticky-pr-comment: creating new comment" + gh pr comment "$pr" --repo "$repo" --body-file "$body_file" +fi diff --git a/.github/workflows/regen-dockerfiles-comment.yml b/.github/workflows/regen-dockerfiles-comment.yml index 04ea4d5..6c2db17 100644 --- a/.github/workflows/regen-dockerfiles-comment.yml +++ b/.github/workflows/regen-dockerfiles-comment.yml @@ -59,11 +59,21 @@ jobs: if: steps.gen.outputs.generator_changed != 'true' id: drift run: | - if git diff --quiet -- docker/generated; then + # git diff alone misses untracked files — a new image in images.json + # produces a brand-new Dockerfile.* that's untracked, not "modified". + # git status --porcelain covers modified, deleted, AND untracked in + # one shot. + if [ -z "$(git status --porcelain -- docker/generated)" ]; then echo "drift=false" >> "$GITHUB_OUTPUT" else echo "drift=true" >> "$GITHUB_OUTPUT" git diff --stat docker/generated > /tmp/diffstat.txt + untracked=$(git ls-files --others --exclude-standard -- docker/generated) + if [ -n "$untracked" ]; then + echo "" >> /tmp/diffstat.txt + echo "Untracked new generated files:" >> /tmp/diffstat.txt + echo "$untracked" >> /tmp/diffstat.txt + fi fi - name: Comment (drift) @@ -72,6 +82,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | { + echo "" echo "Generated Dockerfiles in this PR are out of date with the snippet config." echo "" echo "Please run:" @@ -90,15 +101,21 @@ jobs: echo "" echo "" } > /tmp/body.md - gh pr comment ${{ github.event.pull_request.number }} \ - --repo ${{ github.repository }} \ - --body-file /tmp/body.md + "${GITHUB_WORKSPACE}/.github/scripts/sticky-pr-comment.sh" \ + "${{ github.repository }}" \ + "${{ github.event.pull_request.number }}" \ + /tmp/body.md - name: Comment (generator changed) if: steps.gen.outputs.generator_changed == 'true' env: GH_TOKEN: ${{ github.token }} run: | - gh pr comment ${{ github.event.pull_request.number }} \ - --repo ${{ github.repository }} \ - --body "This PR modifies \`docker/generate-dockerfiles.ps1\`. Auto-regeneration is skipped for safety — please run \`pwsh docker/generate-dockerfiles.ps1\` locally and commit any changes under \`docker/generated/\`." + { + echo "" + echo "This PR modifies \`docker/generate-dockerfiles.ps1\`. Auto-regeneration is skipped for safety — please run \`pwsh docker/generate-dockerfiles.ps1\` locally and commit any changes under \`docker/generated/\`." + } > /tmp/body.md + "${GITHUB_WORKSPACE}/.github/scripts/sticky-pr-comment.sh" \ + "${{ github.repository }}" \ + "${{ github.event.pull_request.number }}" \ + /tmp/body.md diff --git a/.github/workflows/regen-dockerfiles-push.yml b/.github/workflows/regen-dockerfiles-push.yml index 696f847..0e921b0 100644 --- a/.github/workflows/regen-dockerfiles-push.yml +++ b/.github/workflows/regen-dockerfiles-push.yml @@ -37,7 +37,11 @@ jobs: - name: Commit and push if drift run: | - if git diff --quiet -- docker/generated; then + # git diff alone misses untracked files — if images.json adds a new + # image, the generator creates a brand-new Dockerfile.* that doesn't + # yet exist in the index. git status --porcelain catches modified, + # deleted, AND untracked entries in one shot. + if [ -z "$(git status --porcelain -- docker/generated)" ]; then echo "No drift." exit 0 fi