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 new file mode 100644 index 0000000..6c2db17 --- /dev/null +++ b/.github/workflows/regen-dockerfiles-comment.yml @@ -0,0 +1,121 @@ +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: | + # 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) + if: steps.drift.outputs.drift == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + { + echo "" + 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 + "${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: | + { + 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 new file mode 100644 index 0000000..0e921b0 --- /dev/null +++ b/.github/workflows/regen-dockerfiles-push.yml @@ -0,0 +1,52 @@ +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: | + # 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 + 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 }}