Harden GitHub Actions workflows
Drop workflow-level write-all default to permissions: {}, scope
each job to minimum required permissions with explanatory comments,
add job names, and set workflow-level concurrency to cancel stale
runs.

Assisted-by: Claude Opus 4.6 via pi
change nnqstqvwpuxzqqstpxsmwlzzrsopqsny
commit fcb52c3d5467e55ee7124be6d1cf3e4b98959063
author Alpha Chen <alpha@kejadlen.dev>
date
parent vlynpspt
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 98cdb54..8760ec1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,34 +6,45 @@ on:
   pull_request:
     branches: [main]
 
-permissions:
-  contents: write
-  id-token: write
-  packages: write
-  pages: write
+permissions: {}
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 
 jobs:
   test:
+    name: Test
     runs-on: ubuntu-latest
+    permissions:
+      contents: read # checkout
     steps:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+        with:
+          persist-credentials: false
       - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1
         with:
           bundler-cache: true
       - run: bundle exec rake test check
 
   build:
+    name: Build
     needs: test
     if: github.ref == 'refs/heads/main' && github.event_name == 'push'
     runs-on: ubuntu-latest
+    permissions:
+      contents: write # create GitHub release
+      packages: write # push container image to GHCR
     outputs:
       version: ${{ steps.meta.outputs.version }}
       image: ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.version }}
     steps:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+        with:
+          persist-credentials: false
       - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1
         with:
-          bundler-cache: true
+          bundler-cache: false
       - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
       - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
         with:
@@ -64,14 +75,20 @@ jobs:
           cache-to: type=gha,mode=max
       - env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        run: gh release create "${{ steps.meta.outputs.version }}" --generate-notes
+          STEPS_META_OUTPUTS_VERSION: ${{ steps.meta.outputs.version }}
+        run: gh release create "${STEPS_META_OUTPUTS_VERSION}" --generate-notes
 
   snapshots:
+    name: Snapshots
     needs: build
     if: github.ref == 'refs/heads/main' && github.event_name == 'push'
     runs-on: macos-latest
+    permissions:
+      contents: write # upload release assets
     steps:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+        with:
+          persist-credentials: false
       - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1
         with:
           bundler-cache: true
@@ -81,29 +98,39 @@ jobs:
           tar czf snapshots.tar.gz -C "$dir" .
       - env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        run: gh release upload "${{ needs.build.outputs.version }}" snapshots.tar.gz
+          NEEDS_BUILD_OUTPUTS_VERSION: ${{ needs.build.outputs.version }}
+        run: gh release upload "${NEEDS_BUILD_OUTPUTS_VERSION}" snapshots.tar.gz
 
   deploy:
     name: Deploy to Fly.io
     needs: build
     if: github.ref == 'refs/heads/main' && github.event_name == 'push'
     runs-on: ubuntu-latest
+    permissions:
+      contents: read
     environment: demo
     concurrency:
       group: fly-deploy
       cancel-in-progress: true
     steps:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+        with:
+          persist-credentials: false
       - uses: superfly/flyctl-actions/setup-flyctl@63da3ecc5e2793b98a3f2519b3d75d4f4c11cec2 # master
-      - run: flyctl deploy --image ${{ needs.build.outputs.image }}
+      - run: flyctl deploy --image ${NEEDS_BUILD_OUTPUTS_IMAGE}
         env:
           FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
+          NEEDS_BUILD_OUTPUTS_IMAGE: ${{ needs.build.outputs.image }}
 
   pages:
     name: Deploy to Pages
     needs: [build, snapshots]
     if: github.ref == 'refs/heads/main' && github.event_name == 'push'
     runs-on: ubuntu-latest
+    permissions:
+      contents: read # download release assets
+      id-token: write # OIDC token for Pages deployment
+      pages: write # publish to GitHub Pages
     environment:
       name: github-pages
       url: ${{ steps.deployment.outputs.page_url }}
@@ -112,14 +139,17 @@ jobs:
       cancel-in-progress: true
     steps:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+        with:
+          persist-credentials: false
       - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1
         with:
           bundler-cache: true
       - env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          NEEDS_BUILD_OUTPUTS_VERSION: ${{ needs.build.outputs.version }}
         run: |
           mkdir -p gallery
-          gh release download "${{ needs.build.outputs.version }}" --pattern "snapshots.tar.gz" --output snapshots.tar.gz
+          gh release download "${NEEDS_BUILD_OUTPUTS_VERSION}" --pattern "snapshots.tar.gz" --output snapshots.tar.gz
           tar xzf snapshots.tar.gz -C gallery
           bundle exec rake "snapshots:gallery[gallery,gallery/index.html]"
           cp public/favicon.svg gallery/
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
index 9c84c48..7d94edf 100644
--- a/.github/workflows/preview.yml
+++ b/.github/workflows/preview.yml
@@ -4,6 +4,12 @@ on:
   pull_request:
     types: [opened, reopened, synchronize, closed]
 
+permissions: {}
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.number }}
+  cancel-in-progress: true
+
 env:
   FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
   FLY_REGION: sjc
@@ -11,10 +17,15 @@ env:
 
 jobs:
   snapshots:
+    name: Snapshots
     if: github.event.action != 'closed'
     runs-on: macos-latest
+    permissions:
+      contents: read # checkout and download baseline from release
     steps:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+        with:
+          persist-credentials: false
       - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1
         with:
           bundler-cache: true
@@ -31,9 +42,12 @@ jobs:
           path: snapshots.tar.gz
 
   preview:
+    name: Preview
     needs: snapshots
     if: always()
     runs-on: ubuntu-latest
+    permissions:
+      contents: read # checkout
     concurrency:
       group: pr-${{ github.event.number }}
     environment:
@@ -41,6 +55,8 @@ jobs:
       url: ${{ steps.deploy.outputs.url }}
     steps:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+        with:
+          persist-credentials: false
 
       - if: github.event.action != 'closed'
         uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4