.github/.github/workflows/ci-python-sam.yaml
Adam Moussa 5889d52333
ci: enable actionlint's shellcheck integration in self-CI
The self-CI gate ran `./actionlint -shellcheck=`, and the empty value
silently disabled the shell-linting half of the check — so every `run:`
body in the reusable workflows this repo publishes was unlinted, on the
exact path that deploys to AWS.

Measured against the pinned actionlint 1.7.12 and the shellcheck the
ubuntu-latest runner ships (0.9.0-1), the real backlog was 5 findings,
not the 4 the old comment claimed. Three were genuine and are fixed in
the shell:

- cd-cdk.yaml "Publish .NET project" (SC2046): the project path was
  interpolated inline and `$(dirname ...)` was unquoted, so a path
  containing whitespace split into several arguments. Now passed via
  env indirection and quoted, which also removes the last inline
  expression interpolation from that step.
- cd-cdk.yaml / ci-python-sam.yaml "Install Python dependencies"
  (SC2044 x2): `for req in $(find ...)` word-split and globbed every
  path found. Replaced with a NUL-delimited `while read` loop.

Two are deliberate and are suppressed per-line, with the reasoning in a
comment directly above:

- cd-sam.yaml `sam deploy ... $PARAMS` and cd-cdk.yaml
  `cdk deploy $STACKS` (SC2086 x2) rely on word-splitting so multiple
  parameter overrides / stack selectors reach the CLI as separate argv
  entries. Quoting them would collapse each into a single argument and
  break every parameterised or multi-stack deploy, so they keep the
  unquoted expansion and carry a scoped `# shellcheck disable=SC2086`.

The gate now runs plain `./actionlint` (shellcheck defaults to the
binary on PATH) and prints `shellcheck --version` first, so the check
fails loudly if a future runner image drops it instead of quietly
linting less.
2026-07-28 12:46:17 -04:00

160 lines
5.4 KiB
YAML

name: CI — Python / SAM
on:
workflow_call:
inputs:
python-version:
description: "Python version to use"
type: string
default: "3.12"
source-dirs:
description: "Space-separated directories for ruff (default: repo root)"
type: string
default: "."
run-tests:
description: "Run pytest"
type: boolean
default: false
run-sam-validate:
description: "Run sam validate --lint"
type: boolean
default: true
sam-template:
description: "Path to SAM template file"
type: string
default: "template.yaml"
run-cdk-synth:
description: "Run cdk synth (for Python CDK repos)"
type: boolean
default: false
cdk-dir:
description: "Directory containing cdk.json"
type: string
default: "cdk"
node-version:
description: "Node.js version for CDK CLI"
type: string
default: "24"
enable-qemu:
description: "Enable QEMU so cdk synth can bundle arm64 Lambda assets on x86 runners"
type: boolean
default: false
run-conventions-check:
description: "Run lightweight conventions audit"
type: boolean
default: true
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: ci-python-sam-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ inputs.python-version }}
- name: Install ruff
run: pip install 'ruff==0.15.22'
- name: Ruff check
run: ruff check ${{ inputs.source-dirs }}
- name: Ruff format check
run: ruff format --check ${{ inputs.source-dirs }}
- name: Install Python dependencies
if: ${{ inputs.run-tests || inputs.run-cdk-synth }}
# NUL-delimited read loop rather than `for req in $(find ...)`: the
# command-substitution form word-splits and globs every path it finds.
shell: bash
run: |
if [ "${{ inputs.run-tests }}" = "true" ]; then
pip install pytest
fi
while IFS= read -r -d '' req; do
pip install -r "$req"
done < <(find . -name requirements.txt -not -path './.aws-sam/*' -print0)
- name: Run tests
if: ${{ inputs.run-tests }}
run: pytest
- name: Setup Node.js
if: ${{ inputs.run-cdk-synth }}
uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
- name: Set up QEMU
if: ${{ inputs.run-cdk-synth && inputs.enable-qemu }}
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
- name: CDK synth
if: ${{ inputs.run-cdk-synth }}
working-directory: ${{ inputs.cdk-dir }}
run: npx -y cdk synth --quiet
- name: Conventions check
if: ${{ inputs.run-conventions-check }}
run: |
errors=0
warn() { echo "::warning::$1"; }
fail() { echo "::error::$1"; errors=$((errors + 1)); }
# README must exist
if [[ ! -f README.md ]]; then
fail "Missing README.md"
fi
# .gitignore must cover .env
if [[ -f .gitignore ]]; then
if ! grep -qE '^\.env$|^\.env\b' .gitignore; then
fail ".gitignore does not include .env"
fi
else
fail "Missing .gitignore"
fi
# SAM: check template for non-arm64 and missing log retention
TEMPLATE="${{ inputs.sam-template }}"
if [[ -f "$TEMPLATE" ]]; then
if grep -qi 'x86_64' "$TEMPLATE" 2>/dev/null; then
fail "SAM template: Lambda using x86_64 instead of arm64"
fi
if grep -qi 'AWS::Serverless::Function' "$TEMPLATE" 2>/dev/null; then
if ! grep -qi 'RetentionInDays\|AWS::Logs::LogGroup' "$TEMPLATE" 2>/dev/null; then
warn "SAM template: Lambda found but no explicit log retention"
fi
fi
# Flag HARDCODED secret values in the template. Secrets Manager
# references (e.g. SLACK_BOT_TOKEN_SECRET: my-app/slack-token) and
# intrinsic functions (!Ref/!Sub/{{resolve:...}}) are the correct
# pattern, so match on the value's shape — not the key name, which
# legitimately contains words like TOKEN/SECRET when pointing at a
# Secrets Manager id.
if grep -qiE '(xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|gh[posu]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9]{20,}|-----BEGIN[[:space:]][A-Z ]*PRIVATE KEY-----)' "$TEMPLATE" 2>/dev/null; then
fail "SAM template: hardcoded secret in template — use Secrets Manager"
fi
fi
if [[ $errors -gt 0 ]]; then
echo "Conventions check failed with $errors error(s)."
exit 1
fi
echo "Conventions check passed."
- name: Setup SAM CLI
if: ${{ inputs.run-sam-validate }}
uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3
- name: SAM validate
if: ${{ inputs.run-sam-validate }}
run: sam validate --lint --template ${{ inputs.sam-template }}