ci: Lint GitHub Actions workflows with actionlint (#34729)

Peter Tripp created

Added [rhysd/actionlint](https://github.com/rhysd/actionlint/) a static
checker for GitHub Actions workflow files.
Install locally with `brew install actionlint` the run with
`actionlint`.

Inspired by: https://github.com/zed-industries/zed/pull/34704 which
yielded this observation:

> In github actions: 
> 1. strings are truthy
> 2. `${{ }}` will become a string if it doesn't wrap the whole value.
>
> So `if: false && true` becomes `false`
> and `if: ${{ false && true }}` becomes `false`
> but `if: false && ${{ true }}` becomes `"false && true"` which
evaluates true
> The reason you sometimes need `${{ }}` is because YAML doesn't like
`!`
> so `if: !false` is invalid yaml
> and `if: ${{ !false }}` works just fine.

Changes:
- Add `actionlint` job
- Refactor `job_spec` job to be more readable
- Fix all `actionlint` and `shellcheck` errors in Actions workflows (62
in all)
- Add `self-mini-macos` and `self-32vcpu-windows-2022` labels to
self-hosted runners. Not strictly related, but useful if you need to
take a runner out of the rotation (since `macOS`, `self-hosted`, and
`ARM64` are auto-set and cannot be added/removed).
- Change ci.yml macos_relase to target `self-mini-macos` instead of
`bundle` which was previously deprecated.

This would've caught the error fixed in
https://github.com/zed-industries/zed/pull/34704. Here's what that [job
failure](https://github.com/zed-industries/zed/actions/runs/16376993944/job/46279281842?pr=34729)
would've looked like.

Release Notes:

- N/A

Change summary

.github/actionlint.yml                          |  30 +++++
.github/workflows/bump_patch_version.yml        |   8 
.github/workflows/ci.yml                        | 100 +++++++++++-------
.github/workflows/community_release_actions.yml |   6 
.github/workflows/deploy_collab.yml             |   9 
.github/workflows/eval.yml                      |   2 
.github/workflows/nix.yml                       |  10 +
.github/workflows/release_nightly.yml           |   5 
.github/workflows/unit_evals.yml                |   2 
9 files changed, 111 insertions(+), 61 deletions(-)

Detailed changes

.github/actionlint.yml 🔗

@@ -0,0 +1,30 @@
+# Configuration related to self-hosted runner.
+self-hosted-runner:
+  # Labels of self-hosted runner in array of strings.
+  labels:
+    # GitHub-hosted Runners
+    - github-8vcpu-ubuntu-2404
+    - github-16vcpu-ubuntu-2404
+    - windows-2025-16
+    - windows-2025-32
+    - windows-2025-64
+    # Buildjet Ubuntu 20.04 - AMD x86_64
+    - buildjet-2vcpu-ubuntu-2004
+    - buildjet-4vcpu-ubuntu-2004
+    - buildjet-8vcpu-ubuntu-2004
+    - buildjet-16vcpu-ubuntu-2004
+    - buildjet-32vcpu-ubuntu-2004
+    # Buildjet Ubuntu 22.04 - AMD x86_64
+    - buildjet-2vcpu-ubuntu-2204
+    - buildjet-4vcpu-ubuntu-2204
+    - buildjet-8vcpu-ubuntu-2204
+    - buildjet-16vcpu-ubuntu-2204
+    - buildjet-32vcpu-ubuntu-2204
+    # Buildjet Ubuntu 22.04 - Graviton aarch64
+    - buildjet-8vcpu-ubuntu-2204-arm
+    - buildjet-16vcpu-ubuntu-2204-arm
+    - buildjet-32vcpu-ubuntu-2204-arm
+    - buildjet-64vcpu-ubuntu-2204-arm
+    # Self Hosted Runners
+    - self-mini-macos
+    - self-32vcpu-windows-2022

.github/workflows/bump_patch_version.yml 🔗

@@ -28,7 +28,7 @@ jobs:
         run: |
           set -eux
 
-          channel=$(cat crates/zed/RELEASE_CHANNEL)
+          channel="$(cat crates/zed/RELEASE_CHANNEL)"
 
           tag_suffix=""
           case $channel in
@@ -43,9 +43,9 @@ jobs:
               ;;
           esac
           which cargo-set-version > /dev/null || cargo install cargo-edit
-          output=$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')
+          output="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')"
           export GIT_COMMITTER_NAME="Zed Bot"
           export GIT_COMMITTER_EMAIL="hi@zed.dev"
           git commit -am "Bump to $output for @$GITHUB_ACTOR" --author "Zed Bot <hi@zed.dev>"
-          git tag v${output}${tag_suffix}
-          git push origin HEAD v${output}${tag_suffix}
+          git tag "v${output}${tag_suffix}"
+          git push origin HEAD "v${output}${tag_suffix}"

.github/workflows/ci.yml 🔗

@@ -34,6 +34,7 @@ jobs:
       run_license: ${{ steps.filter.outputs.run_license }}
       run_docs: ${{ steps.filter.outputs.run_docs }}
       run_nix: ${{ steps.filter.outputs.run_nix }}
+      run_actionlint: ${{ steps.filter.outputs.run_actionlint }}
     runs-on:
       - ubuntu-latest
     steps:
@@ -47,39 +48,40 @@ jobs:
         run: |
           if [ -z "$GITHUB_BASE_REF" ]; then
             echo "Not in a PR context (i.e., push to main/stable/preview)"
-            COMPARE_REV=$(git rev-parse HEAD~1)
+            COMPARE_REV="$(git rev-parse HEAD~1)"
           else
             echo "In a PR context comparing to pull_request.base.ref"
             git fetch origin "$GITHUB_BASE_REF" --depth=350
-            COMPARE_REV=$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)
+            COMPARE_REV="$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)"
           fi
-          # Specify anything which should skip full CI in this regex:
+          CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" ${{ github.sha }})"
+
+          # Specify anything which should potentially skip full test suite in this regex:
           # - docs/
           # - script/update_top_ranking_issues/
           # - .github/ISSUE_TEMPLATE/
           # - .github/workflows/  (except .github/workflows/ci.yml)
           SKIP_REGEX='^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!ci)))'
-          if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -vP "$SKIP_REGEX") ]]; then
-            echo "run_tests=true" >> $GITHUB_OUTPUT
-          else
-            echo "run_tests=false" >> $GITHUB_OUTPUT
-          fi
-          if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep '^docs/') ]]; then
-            echo "run_docs=true" >> $GITHUB_OUTPUT
-          else
-            echo "run_docs=false" >> $GITHUB_OUTPUT
-          fi
-          if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -P '^(Cargo.lock|script/.*licenses)') ]]; then
-            echo "run_license=true" >> $GITHUB_OUTPUT
-          else
-            echo "run_license=false" >> $GITHUB_OUTPUT
-          fi
-          NIX_REGEX='^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)'
-          if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -P "$NIX_REGEX") ]]; then
-            echo "run_nix=true" >> $GITHUB_OUTPUT
-          else
-            echo "run_nix=false" >> $GITHUB_OUTPUT
-          fi
+
+          echo "$CHANGED_FILES" | grep -qvP "$SKIP_REGEX" && \
+            echo "run_tests=true" >> "$GITHUB_OUTPUT" || \
+            echo "run_tests=false" >> "$GITHUB_OUTPUT"
+
+          echo "$CHANGED_FILES" | grep -qP '^docs/' && \
+            echo "run_docs=true" >> "$GITHUB_OUTPUT" || \
+            echo "run_docs=false" >> "$GITHUB_OUTPUT"
+
+          echo "$CHANGED_FILES" | grep -qP '^\.github/(workflows/|actions/|actionlint.yml)' && \
+            echo "run_actionlint=true" >> "$GITHUB_OUTPUT" || \
+            echo "run_actionlint=false" >> "$GITHUB_OUTPUT"
+
+          echo "$CHANGED_FILES" | grep -qP '^(Cargo.lock|script/.*licenses)' && \
+            echo "run_license=true" >> "$GITHUB_OUTPUT" || \
+            echo "run_license=false" >> "$GITHUB_OUTPUT"
+
+          echo "$CHANGED_FILES" | grep -qP '^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)' && \
+            echo "run_nix=true" >> "$GITHUB_OUTPUT" || \
+            echo "run_nix=false" >> "$GITHUB_OUTPUT"
 
   migration_checks:
     name: Check Postgres and Protobuf migrations, mergability
@@ -89,8 +91,7 @@ jobs:
       needs.job_spec.outputs.run_tests == 'true'
     timeout-minutes: 60
     runs-on:
-      - self-hosted
-      - macOS
+      - self-mini-macos
     steps:
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -112,11 +113,11 @@ jobs:
         run: |
           if [ -z "$GITHUB_BASE_REF" ];
           then
-            echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> $GITHUB_ENV
+            echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> "$GITHUB_ENV"
           else
             git checkout -B temp
-            git merge -q origin/$GITHUB_BASE_REF -m "merge main into temp"
-            echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> $GITHUB_ENV
+            git merge -q "origin/$GITHUB_BASE_REF" -m "merge main into temp"
+            echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> "$GITHUB_ENV"
           fi
 
       - uses: bufbuild/buf-setup-action@v1
@@ -140,7 +141,7 @@ jobs:
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
       - name: Add Rust to the PATH
-        run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+        run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
       - name: Install cargo-hakari
         uses: clechasseur/rs-cargo@8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386 # v2
         with:
@@ -178,7 +179,7 @@ jobs:
       - name: Prettier Check on /docs
         working-directory: ./docs
         run: |
-          pnpm dlx prettier@${PRETTIER_VERSION} . --check || {
+          pnpm dlx "prettier@${PRETTIER_VERSION}" . --check || {
             echo "To fix, run from the root of the Zed repo:"
             echo "  cd docs && pnpm dlx prettier@${PRETTIER_VERSION} . --write && cd .."
             false
@@ -188,7 +189,7 @@ jobs:
 
       - name: Prettier Check on default.json
         run: |
-          pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --check || {
+          pnpm dlx "prettier@${PRETTIER_VERSION}" assets/settings/default.json --check || {
             echo "To fix, run from the root of the Zed repo:"
             echo "  pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --write"
             false
@@ -234,6 +235,20 @@ jobs:
       - name: Build docs
         uses: ./.github/actions/build_docs
 
+  actionlint:
+    runs-on: ubuntu-latest
+    if: github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_actionlint == 'true'
+    needs: [job_spec]
+    steps:
+      - uses: actions/checkout@v4
+      - name: Download actionlint
+        id: get_actionlint
+        run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
+        shell: bash
+      - name: Check workflow files
+        run: ${{ steps.get_actionlint.outputs.executable }} -color
+        shell: bash
+
   macos_tests:
     timeout-minutes: 60
     name: (macOS) Run Clippy and tests
@@ -242,8 +257,7 @@ jobs:
       github.repository_owner == 'zed-industries' &&
       needs.job_spec.outputs.run_tests == 'true'
     runs-on:
-      - self-hosted
-      - macOS
+      - self-mini-macos
     steps:
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -312,7 +326,7 @@ jobs:
       - buildjet-16vcpu-ubuntu-2204
     steps:
       - name: Add Rust to the PATH
-        run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+        run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
 
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -364,7 +378,7 @@ jobs:
       - buildjet-8vcpu-ubuntu-2204
     steps:
       - name: Add Rust to the PATH
-        run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+        run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
 
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -444,6 +458,7 @@ jobs:
       - job_spec
       - style
       - check_docs
+      - actionlint
       - migration_checks
       # run_tests: If adding required tests, add them here and to script below.
       - workspace_hack
@@ -465,6 +480,11 @@ jobs:
           if [[ "${{ needs.job_spec.outputs.run_docs }}" == "true" ]]; then
             [[ "${{ needs.check_docs.result }}" != 'success' ]] && { RET_CODE=1; echo "docs checks failed"; }
           fi
+
+          if [[ "${{ needs.job_spec.outputs.run_actionlint }}" == "true" ]]; then
+            [[ "${{ needs.actionlint.result }}" != 'success' ]] && { RET_CODE=1; echo "actionlint checks failed"; }
+          fi
+
           # Only check test jobs if they were supposed to run
           if [[ "${{ needs.job_spec.outputs.run_tests }}" == "true" ]]; then
             [[ "${{ needs.workspace_hack.result }}"       != 'success' ]] && { RET_CODE=1; echo "Workspace Hack failed"; }
@@ -484,8 +504,7 @@ jobs:
     timeout-minutes: 120
     name: Create a macOS bundle
     runs-on:
-      - self-hosted
-      - bundle
+      - self-mini-macos
     if: |
       startsWith(github.ref, 'refs/tags/v')
       || contains(github.event.pull_request.labels.*.name, 'run-bundling')
@@ -802,10 +821,9 @@ jobs:
       && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')
     needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, bundle-windows-x64]
     runs-on:
-      - self-hosted
-      - bundle
+      - self-mini-macos
     steps:
       - name: gh release
-        run: gh release edit $GITHUB_REF_NAME --draft=false
+        run: gh release edit "$GITHUB_REF_NAME" --draft=false
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/community_release_actions.yml 🔗

@@ -18,7 +18,7 @@ jobs:
               URL="https://zed.dev/releases/stable/latest"
           fi
 
-          echo "URL=$URL" >> $GITHUB_OUTPUT
+          echo "URL=$URL" >> "$GITHUB_OUTPUT"
       - name: Get content
         uses: 2428392/gh-truncate-string-action@b3ff790d21cf42af3ca7579146eedb93c8fb0757 # v1.4.1
         id: get-content
@@ -50,9 +50,9 @@ jobs:
           PREVIEW_TAG="${VERSION}-pre"
 
           if git rev-parse "$PREVIEW_TAG" > /dev/null 2>&1; then
-              echo "was_promoted_from_preview=true" >> $GITHUB_OUTPUT
+              echo "was_promoted_from_preview=true" >> "$GITHUB_OUTPUT"
           else
-              echo "was_promoted_from_preview=false" >> $GITHUB_OUTPUT
+              echo "was_promoted_from_preview=false" >> "$GITHUB_OUTPUT"
           fi
 
       - name: Send release notes email

.github/workflows/deploy_collab.yml 🔗

@@ -79,12 +79,12 @@ jobs:
       - name: Build docker image
         run: |
           docker build -f Dockerfile-collab \
-            --build-arg GITHUB_SHA=$GITHUB_SHA \
-            --tag registry.digitalocean.com/zed/collab:$GITHUB_SHA \
+            --build-arg "GITHUB_SHA=$GITHUB_SHA" \
+            --tag "registry.digitalocean.com/zed/collab:$GITHUB_SHA" \
             .
 
       - name: Publish docker image
-        run: docker push registry.digitalocean.com/zed/collab:${GITHUB_SHA}
+        run: docker push "registry.digitalocean.com/zed/collab:${GITHUB_SHA}"
 
       - name: Prune Docker system
         run: docker system prune  --filter 'until=72h' -f
@@ -131,7 +131,8 @@ jobs:
           source script/lib/deploy-helpers.sh
           export_vars_for_environment $ZED_KUBE_NAMESPACE
 
-          export ZED_DO_CERTIFICATE_ID=$(doctl compute certificate list --format ID --no-header)
+          ZED_DO_CERTIFICATE_ID="$(doctl compute certificate list --format ID --no-header)"
+          export ZED_DO_CERTIFICATE_ID
           export ZED_IMAGE_ID="registry.digitalocean.com/zed/collab:${GITHUB_SHA}"
 
           export ZED_SERVICE_NAME=collab

.github/workflows/eval.yml 🔗

@@ -35,7 +35,7 @@ jobs:
       - buildjet-16vcpu-ubuntu-2204
     steps:
       - name: Add Rust to the PATH
-        run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+        run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
 
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4

.github/workflows/nix.yml 🔗

@@ -43,8 +43,8 @@ jobs:
       - name: Set path
         if: ${{ ! matrix.system.install_nix }}
         run: |
-          echo "/nix/var/nix/profiles/default/bin" >> $GITHUB_PATH
-          echo "/Users/administrator/.nix-profile/bin" >> $GITHUB_PATH
+          echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
+          echo "/Users/administrator/.nix-profile/bin" >> "$GITHUB_PATH"
 
       - uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f # v31
         if: ${{ matrix.system.install_nix }}
@@ -56,11 +56,13 @@ jobs:
           name: zed
           authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
           pushFilter: "${{ inputs.cachix-filter }}"
-          cachixArgs: '-v'
+          cachixArgs: "-v"
 
       - run: nix build .#${{ inputs.flake-output }} -L --accept-flake-config
 
       - name: Limit /nix/store to 50GB on macs
         if: ${{ ! matrix.system.install_nix }}
         run: |
-          [ $(du -sm /nix/store | cut -f1) -gt 50000 ] && nix-collect-garbage -d || :
+          if [ "$(du -sm /nix/store | cut -f1)" -gt 50000 ]; then
+            nix-collect-garbage -d || true
+          fi

.github/workflows/release_nightly.yml 🔗

@@ -85,8 +85,7 @@ jobs:
     name: Create a macOS bundle
     if: github.repository_owner == 'zed-industries'
     runs-on:
-      - self-hosted
-      - bundle
+      - self-mini-macos
     needs: tests
     env:
       MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
@@ -132,7 +131,7 @@ jobs:
           clean: false
 
       - name: Add Rust to the PATH
-        run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+        run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
 
       - name: Install Linux dependencies
         run: ./script/linux && ./script/install-mold 2.34.0

.github/workflows/unit_evals.yml 🔗

@@ -26,7 +26,7 @@ jobs:
       - buildjet-16vcpu-ubuntu-2204
     steps:
       - name: Add Rust to the PATH
-        run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+        run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
 
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4