fix: guard against 0 / unfriendly font-scale editing (#49) #128
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build PR image | |
| # Builds a Docker image for every PR push and publishes it to GHCR | |
| # under `:pr-<N>` and `:<short-sha>` tags so it can be pulled and tested | |
| # on hardware (e.g. hector) without rebuilding manually on the Pi. Does | |
| # NOT touch `:latest` or `:vX.Y.Z` — those remain owned by release.yml | |
| # and only update on merge to main with a version bump. | |
| # | |
| # Each push also sweeps the predecessor image for the same PR: we | |
| # capture whatever version currently holds the `:pr-<N>` tag before | |
| # building, then delete it after the new image has been pushed (the | |
| # tag has by then moved). This keeps GHCR holding only the latest | |
| # image per active PR, instead of accumulating one per commit. | |
| # | |
| # On PR close (merged or not), a cleanup job deletes the final | |
| # `:pr-<N>` image so it doesn't linger in GHCR forever. | |
| # | |
| # Both sweep paths also delete the per-arch image manifests and | |
| # provenance attestation manifests referenced by the parent — GHCR | |
| # does not cascade-delete them. See `.github/scripts/sweep-ghcr-versions.sh`. | |
| # | |
| # Fork PRs are skipped — their GITHUB_TOKEN is read-only and can't | |
| # push to GHCR or delete packages. | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened, closed] | |
| # No `branches:` filter — stacked PRs (e.g. slice 2 targeting | |
| # slice 1's branch instead of main) need a buildable image too, | |
| # otherwise they can't be tested on hardware until the parent | |
| # PR merges. | |
| # Cancel any in-flight build when a new commit is pushed to the same | |
| # PR. Avoids racing parallel multi-arch builds to the same `:pr-<N>` | |
| # tag (last-finished-wins, not last-pushed-wins). | |
| concurrency: | |
| group: pr-image-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| env: | |
| REGISTRY: ghcr.io | |
| IMAGE_NAME: ${{ github.repository }} | |
| jobs: | |
| # Validate the image actually starts and the API responds before | |
| # publishing it — same reusable workflow that release.yml gates on. | |
| smoke-test: | |
| if: >- | |
| github.event.action != 'closed' && | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| uses: ./.github/workflows/_docker-smoke-test.yml | |
| build-and-push: | |
| needs: smoke-test | |
| if: >- | |
| github.event.action != 'closed' && | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| - name: Checkout PR head | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Log in to GitHub Container Registry | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Compute short SHA | |
| id: sha | |
| env: | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: echo "short=${HEAD_SHA:0:7}" >> "$GITHUB_OUTPUT" | |
| # Capture the version IDs currently tagged `:pr-<N>` *before* | |
| # we push. These are candidate predecessors; the actual delete | |
| # set is `pre - post` (see the post-push step), so a re-run on | |
| # the same commit (manifest digest unchanged → tag stays on the | |
| # same version ID) does not delete the only image for this PR. | |
| - name: Find predecessor PR image versions | |
| id: predecessors | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| PR_TAG: pr-${{ github.event.pull_request.number }} | |
| OWNER: ${{ github.repository_owner }} | |
| PACKAGE: ${{ github.event.repository.name }} | |
| run: | | |
| set -euo pipefail | |
| # The only expected failure here is HTTP 404 on the very | |
| # first build of a brand-new PR (package doesn't exist yet). | |
| # Other failures (auth/perms/rate-limit) must NOT degrade | |
| # silently into "no cleanup" — they fail the job. | |
| ERR=$(mktemp) | |
| trap 'rm -f "$ERR"' EXIT | |
| if IDS=$(gh api \ | |
| "/users/$OWNER/packages/container/$PACKAGE/versions" --paginate \ | |
| --jq ".[] | select(((.metadata.container.tags // []) | index(\"$PR_TAG\"))) | .id" \ | |
| 2>"$ERR"); then | |
| : | |
| elif grep -q "HTTP 404" "$ERR"; then | |
| echo "Package not found yet — first build for this PR." | |
| IDS="" | |
| else | |
| echo "::error::Listing package versions failed:" | |
| cat "$ERR" >&2 | |
| exit 1 | |
| fi | |
| # Write a truly empty value when there are no predecessors; | |
| # `echo "$IDS"` would emit a stray newline, making the step | |
| # output non-empty and tripping the `if: ... != ''` gate on | |
| # the delete step (which would then 404 on first builds). | |
| if [ -z "$IDS" ]; then | |
| echo "ids=" >> "$GITHUB_OUTPUT" | |
| else | |
| { | |
| echo "ids<<EOF" | |
| echo "$IDS" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Extract metadata (tags, labels) | |
| id: meta | |
| uses: docker/metadata-action@v5 | |
| with: | |
| images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} | |
| tags: | | |
| type=raw,value=pr-${{ github.event.pull_request.number }} | |
| type=raw,value=${{ steps.sha.outputs.short }} | |
| - name: Build and push multi-platform | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| push: true | |
| tags: ${{ steps.meta.outputs.tags }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| build-args: | | |
| GIT_SHA=${{ steps.sha.outputs.short }} | |
| GIT_BRANCH=pr-${{ github.event.pull_request.number }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| platforms: linux/amd64,linux/arm64 | |
| # Delete versions that were tagged `:pr-<N>` pre-build AND no | |
| # longer hold the tag post-push, along with their per-arch and | |
| # provenance child manifests (GHCR does not cascade-delete | |
| # them). Re-querying post-push covers the same-digest-rebuild | |
| # case where the tag stays on the same version ID — that ID is | |
| # the current image and must not be touched. | |
| - name: Delete superseded PR image versions (and children) | |
| if: steps.predecessors.outputs.ids != '' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| PR_TAG: pr-${{ github.event.pull_request.number }} | |
| OWNER: ${{ github.repository_owner }} | |
| PACKAGE: ${{ github.event.repository.name }} | |
| IMAGE_NAME: ${{ github.repository }} | |
| GITHUB_ACTOR: ${{ github.actor }} | |
| PRE_IDS: ${{ steps.predecessors.outputs.ids }} | |
| run: | | |
| set -euo pipefail | |
| SNAPSHOT=$(mktemp --suffix=.json) | |
| export SNAPSHOT | |
| gh api "/users/$OWNER/packages/container/$PACKAGE/versions" --paginate \ | |
| --jq '[.[] | {id, name, tags: (.metadata.container.tags // [])}]' \ | |
| > "$SNAPSHOT" | |
| jq -r --arg tag "$PR_TAG" '.[] | select(.tags | index($tag)) | .id' "$SNAPSHOT" \ | |
| | sort -u > /tmp/current-ids.txt | |
| echo "$PRE_IDS" | grep -v '^$' | sort -u > /tmp/pre-ids.txt | |
| if ! [ -s /tmp/pre-ids.txt ]; then | |
| echo "No predecessor IDs to consider" | |
| exit 0 | |
| fi | |
| PARENTS=$(comm -23 /tmp/pre-ids.txt /tmp/current-ids.txt) | |
| if [ -z "$PARENTS" ]; then | |
| echo "No superseded versions — manifest digest unchanged across rebuild." | |
| exit 0 | |
| fi | |
| echo "$PARENTS" | bash .github/scripts/sweep-ghcr-versions.sh | |
| - name: Annotate run summary | |
| run: | | |
| cat >> "$GITHUB_STEP_SUMMARY" <<EOF | |
| ## PR image published | |
| - \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }}\` | |
| - \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}\` | |
| Pull and test on hector: | |
| \`\`\`bash | |
| docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }} | |
| docker run -d --rm --name labelle-pr-test --privileged \\ | |
| -v /dev/bus/usb:/dev/bus/usb -p 5001:5000 \\ | |
| ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }} | |
| curl http://localhost:5001/api/health | |
| \`\`\` | |
| EOF | |
| # Delete the final `:pr-<N>` image version when the PR closes (merged | |
| # or not). Predecessors are already swept by `build-and-push` on each | |
| # push, so under normal conditions only one version is tagged `:pr-<N>` | |
| # at close time. | |
| cleanup: | |
| if: >- | |
| github.event.action == 'closed' && | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| # Need the sweep script. Use github.sha (the resolved ref that | |
| # triggered this run) so the script always matches the workflow | |
| # file we're executing — important for stacked PRs whose base | |
| # branch may not yet contain the script. Sparse-checkout keeps | |
| # this cheap on a job that otherwise touches nothing local. | |
| - name: Checkout sweep script | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.sha }} | |
| sparse-checkout: .github/scripts | |
| - name: Delete PR image versions (and children) | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| PR_TAG: pr-${{ github.event.pull_request.number }} | |
| OWNER: ${{ github.repository_owner }} | |
| PACKAGE: ${{ github.event.repository.name }} | |
| IMAGE_NAME: ${{ github.repository }} | |
| GITHUB_ACTOR: ${{ github.actor }} | |
| run: | | |
| set -euo pipefail | |
| # Null-safe + fail-on-non-404: a missing package (PR closed | |
| # without a single successful build) is the only swallowable | |
| # error; anything else fails the job rather than silently | |
| # leaving orphans. | |
| SNAPSHOT=$(mktemp --suffix=.json) | |
| export SNAPSHOT | |
| ERR=$(mktemp) | |
| trap 'rm -f "$ERR"' EXIT | |
| if RAW=$(gh api \ | |
| "/users/$OWNER/packages/container/$PACKAGE/versions" --paginate \ | |
| --jq '[.[] | {id, name, tags: (.metadata.container.tags // [])}]' \ | |
| 2>"$ERR"); then | |
| echo "$RAW" > "$SNAPSHOT" | |
| elif grep -q "HTTP 404" "$ERR"; then | |
| echo "Package not found — nothing to clean up." | |
| exit 0 | |
| else | |
| echo "::error::Listing package versions failed:" | |
| cat "$ERR" >&2 | |
| exit 1 | |
| fi | |
| PARENTS=$(jq -r --arg tag "$PR_TAG" '.[] | select(.tags | index($tag)) | .id' "$SNAPSHOT") | |
| if [ -z "$PARENTS" ]; then | |
| echo "No versions tagged $PR_TAG to delete" | |
| exit 0 | |
| fi | |
| echo "$PARENTS" | bash .github/scripts/sweep-ghcr-versions.sh |