|
| 1 | +--- |
| 2 | +paths: |
| 3 | + - ".github/workflows/**.yml" |
| 4 | + - ".github/workflows/**.yaml" |
| 5 | +--- |
| 6 | + |
| 7 | +# GitHub Actions Workflows Formatting and Style Conventions |
| 8 | + |
| 9 | +This rule captures YAML and bash formatting rules to provide a consistent maintainer's experience across CI workflows. |
| 10 | + |
| 11 | +## File Structure |
| 12 | + |
| 13 | +**REQUIRED**: All github action workflows are organized as a flat structure beneath `.github/workflows/`. |
| 14 | + |
| 15 | +> GitHub does not support a hierarchical organization for workflows yet. |
| 16 | +
|
| 17 | +**REQUIRED**: YAML files are conventionally named `{workflow}.yml`, with the `.yml` extension. |
| 18 | + |
| 19 | +## Code Style & Formatting |
| 20 | + |
| 21 | +### Expression Spacing |
| 22 | + |
| 23 | +**REQUIRED**: All GitHub Actions expressions must have spaces inside the braces: |
| 24 | + |
| 25 | +```yaml |
| 26 | +# ✅ CORRECT |
| 27 | +env: |
| 28 | + PR_URL: ${{ github.event.pull_request.html_url }} |
| 29 | + TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 30 | + |
| 31 | +# ❌ WRONG |
| 32 | +env: |
| 33 | + PR_URL: ${{github.event.pull_request.html_url}} |
| 34 | + TOKEN: ${{secrets.GITHUB_TOKEN}} |
| 35 | +``` |
| 36 | +
|
| 37 | +> Provides a consistent formatting rule. |
| 38 | +
|
| 39 | +### Conditional Syntax |
| 40 | +
|
| 41 | +**REQUIRED**: Always use `${{ }}` in `if:` conditions: |
| 42 | + |
| 43 | +```yaml |
| 44 | +# ✅ CORRECT |
| 45 | +if: ${{ inputs.enable-signing == 'true' }} |
| 46 | +if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} |
| 47 | +
|
| 48 | +# ❌ WRONG (works but inconsistent) |
| 49 | +if: inputs.enable-signing == 'true' |
| 50 | +``` |
| 51 | + |
| 52 | +> Provides a consistent formatting rule. |
| 53 | + |
| 54 | +### GitHub Workflow Commands |
| 55 | + |
| 56 | +**REQUIRED**: Use workflow commands for status messages that should appear as annotations, with **double colon separator**: |
| 57 | + |
| 58 | +```bash |
| 59 | +# ✅ CORRECT - Double colon (::) separator after title |
| 60 | +echo "::notice title=build::Build completed successfully" |
| 61 | +echo "::warning title=race-condition::Merge already in progress" |
| 62 | +echo "::error title=deployment::Failed to deploy" |
| 63 | +
|
| 64 | +# ❌ WRONG - Single colon separator (won't render as annotation) |
| 65 | +echo "::notice title=build:Build completed" # Missing second ':' |
| 66 | +echo "::warning title=x:message" # Won't display correctly |
| 67 | +``` |
| 68 | + |
| 69 | +**Syntax pattern:** `::LEVEL title=TITLE::MESSAGE` |
| 70 | +- `LEVEL`: notice, warning, or error |
| 71 | +- Double `::` separator is required between title and message |
| 72 | + |
| 73 | +> Wrong syntax may raise untidy warnings and produce botched output. |
| 74 | + |
| 75 | +### YAML arrays formatting |
| 76 | + |
| 77 | +For steps, YAML arrays are formatted with the following indentation: |
| 78 | + |
| 79 | +```yaml |
| 80 | +# ✅ CORRECT - Clear spacing between steps |
| 81 | + steps: |
| 82 | + - |
| 83 | + name: Dependabot metadata |
| 84 | + id: metadata |
| 85 | + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 |
| 86 | + - |
| 87 | + name: Checkout repository |
| 88 | + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 89 | + with: |
| 90 | + fetch-depth: 0 |
| 91 | +
|
| 92 | +# ❌ WRONG - Dense format, more difficult to read |
| 93 | + steps: |
| 94 | + - name: Dependabot metadata |
| 95 | + id: metadata |
| 96 | + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 |
| 97 | + - name: Checkout repository |
| 98 | + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 99 | + with: |
| 100 | + fetch-depth: 0 |
| 101 | +
|
| 102 | +# ❌ WRONG - YAML comment or blank line could be avoided |
| 103 | + steps: |
| 104 | + # |
| 105 | + - name: Dependabot metadata |
| 106 | + id: metadata |
| 107 | + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 |
| 108 | +
|
| 109 | + - name: Checkout repository |
| 110 | + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 111 | + with: |
| 112 | + fetch-depth: 0 |
| 113 | +``` |
| 114 | + |
| 115 | +## Security Best Practices |
| 116 | + |
| 117 | +### Version Pinning using SHAs |
| 118 | + |
| 119 | +**REQUIRED**: Always pin action versions to commit SHAs: |
| 120 | + |
| 121 | +> Runs must be repeatable with known pinned version. Automated updates are pushed frequently (e.g. daily or weekly) |
| 122 | +> to keep pinned versions up-to-date. |
| 123 | + |
| 124 | +```yaml |
| 125 | +# ✅ CORRECT - Pinned to commit SHA with version comment |
| 126 | +uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 |
| 127 | +uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0 |
| 128 | +
|
| 129 | +# ❌ WRONG - Mutable tag reference |
| 130 | +uses: actions/checkout@v6 |
| 131 | +``` |
| 132 | + |
| 133 | +### Permission settings |
| 134 | + |
| 135 | +**REQUIRED**: Always set minimal permissions at the workflow level. |
| 136 | + |
| 137 | +```yaml |
| 138 | +# ✅ CORRECT - Workflow level permissions set to minimum |
| 139 | +permissions: |
| 140 | + contents: read |
| 141 | +
|
| 142 | +# ❌ WRONG - Workflow level permissions with undue privilege escalation |
| 143 | +permissions: |
| 144 | + contents: write |
| 145 | + pull-requests: write |
| 146 | +``` |
| 147 | + |
| 148 | +**REQUIRED**: Whenever a job needs elevated privileges, always raise required permissions at the job level. |
| 149 | + |
| 150 | +```yaml |
| 151 | +# ✅ CORRECT - Job level permissions set to the specific requirements for that job |
| 152 | +jobs: |
| 153 | + dependabot: |
| 154 | + permissions: |
| 155 | + contents: write |
| 156 | + pull-requests: write |
| 157 | + uses: ./.github/workflows/auto-merge.yml |
| 158 | + secrets: inherit |
| 159 | +
|
| 160 | +# ❌ WRONG - Same permissions but set at workflow level instead of job level |
| 161 | +permissions: |
| 162 | + contents: write |
| 163 | + pull-requests: write |
| 164 | +``` |
| 165 | + |
| 166 | +> (Security best practice detected by CodeQL analysis) |
| 167 | + |
| 168 | +### Undue secret exposure |
| 169 | + |
| 170 | +**NEVER** use `secrets[inputs.name]` — always use explicit secret parameters. |
| 171 | + |
| 172 | +> Using keyed access to secrets forces the runner to expose ALL secrets to the job, which causes a security risk |
| 173 | +> (caught and reported by CodeQL security analysis). |
| 174 | + |
| 175 | +```yaml |
| 176 | +# ❌ SECURITY VULNERABILITY |
| 177 | +# This exposes ALL organization and repository secrets to the runner |
| 178 | +on: |
| 179 | + workflow_call: |
| 180 | + inputs: |
| 181 | + secret-name: |
| 182 | + type: string |
| 183 | +jobs: |
| 184 | + my-job: |
| 185 | + steps: |
| 186 | + - uses: some-action@v1 |
| 187 | + with: |
| 188 | + token: ${{ secrets[inputs.secret-name] }} # ❌ DANGEROUS! |
| 189 | +``` |
| 190 | + |
| 191 | +**SOLUTION**: Use explicit secret parameters with fallback for defaults: |
| 192 | + |
| 193 | +```yaml |
| 194 | +# ✅ SECURE |
| 195 | +on: |
| 196 | + workflow_call: |
| 197 | + secrets: |
| 198 | + gpg-private-key: |
| 199 | + required: false |
| 200 | +jobs: |
| 201 | + my-job: |
| 202 | + steps: |
| 203 | + - uses: go-openapi/gh-actions/ci-jobs/bot-credentials@master |
| 204 | + with: |
| 205 | + # Falls back to go-openapi default if not explicitly passed |
| 206 | + gpg-private-key: ${{ secrets.gpg-private-key || secrets.CI_BOT_GPG_PRIVATE_KEY }} |
| 207 | +``` |
| 208 | + |
| 209 | +## Common Gotchas |
| 210 | + |
| 211 | +### Description fields containing parsable expressions |
| 212 | + |
| 213 | +**REQUIRED**: **DO NOT** use `${{ }}` expressions in description fields: |
| 214 | + |
| 215 | +> They may be parsed by the runner, wrongly interpreted or causing failure (e.g. "not defined in this context"). |
| 216 | + |
| 217 | +```yaml |
| 218 | +# ❌ WRONG - Can cause YAML parsing errors |
| 219 | +description: | |
| 220 | + Pass it as: gpg-private-key: ${{ secrets.MY_KEY }} |
| 221 | +
|
| 222 | +# ✅ CORRECT |
| 223 | +description: | |
| 224 | + Pass it as: secrets.MY_KEY |
| 225 | +``` |
| 226 | + |
| 227 | +### Boolean inputs |
| 228 | + |
| 229 | +**Boolean inputs are forbidden**: NEVER use `type: boolean` for workflow inputs due to unpredictable type coercion |
| 230 | + |
| 231 | +> gh-action expressions using boolean job inputs are hard to predict and come with many quirks. |
| 232 | + |
| 233 | + ```yaml |
| 234 | + # ❌ FORBIDDEN - Boolean inputs have type coercion issues |
| 235 | + on: |
| 236 | + workflow_call: |
| 237 | + inputs: |
| 238 | + enable-feature: |
| 239 | + type: boolean # ❌ NEVER USE THIS |
| 240 | + default: true |
| 241 | +
|
| 242 | + # The pattern `x == 'true' || x == true` seems safe but fails when: |
| 243 | + # - x is not a boolean: `x == true` evaluates to true if x != null |
| 244 | + # - Type coercion is unpredictable and error-prone |
| 245 | + |
| 246 | + # ✅ CORRECT - Always use string type for boolean-like inputs |
| 247 | + on: |
| 248 | + workflow_call: |
| 249 | + inputs: |
| 250 | + enable-feature: |
| 251 | + type: string # ✅ Use string instead |
| 252 | + default: 'true' # String value |
| 253 | + |
| 254 | + jobs: |
| 255 | + my-job: |
| 256 | + # Simple, reliable comparison |
| 257 | + if: ${{ inputs.enable-feature == 'true' }} |
| 258 | + |
| 259 | + # ✅ In bash, this works perfectly (inputs are always strings in bash): |
| 260 | + if [[ '${{ inputs.enable-feature }}' == 'true' ]]; then |
| 261 | + echo "Feature enabled" |
| 262 | + fi |
| 263 | + ``` |
| 264 | + |
| 265 | + **Rule**: Use `type: string` with values `'true'` or `'false'` for all boolean-like workflow inputs. |
| 266 | + |
| 267 | + **Note**: Step outputs and bash variables are always strings, so `x == 'true'` works fine for those. |
| 268 | + |
| 269 | +### YAML fold scalars in action inputs |
| 270 | + |
| 271 | +**NEVER** use `>` or `>-` (fold scalars) for `with:` input values: |
| 272 | + |
| 273 | +> The YAML spec says fold scalars replace newlines with spaces, but the GitHub Actions runner |
| 274 | +> does not reliably honor this for action inputs. The action receives the literal multi-line string |
| 275 | +> instead of a single folded line, which breaks flag parsing. |
| 276 | +
|
| 277 | +```yaml |
| 278 | +# ❌ BROKEN - Fold scalar, args received with embedded newlines |
| 279 | +- uses: goreleaser/goreleaser-action@... |
| 280 | + with: |
| 281 | + args: >- |
| 282 | + release |
| 283 | + --clean |
| 284 | + --release-notes /tmp/notes.md |
| 285 | +
|
| 286 | +# ✅ CORRECT - Single line |
| 287 | +- uses: goreleaser/goreleaser-action@... |
| 288 | + with: |
| 289 | + args: release --clean --release-notes /tmp/notes.md |
| 290 | + |
| 291 | +# ✅ CORRECT - Literal block scalar (|) is fine for run: scripts |
| 292 | +- run: | |
| 293 | + echo "line 1" |
| 294 | + echo "line 2" |
| 295 | +``` |
| 296 | +
|
| 297 | +**Rule**: Use single-line strings for `with:` inputs. Only use `|` (literal block scalar) for `run:` scripts where multi-line is intentional. |
0 commit comments