diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 900c230..511e755 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,3 +87,298 @@ jobs: - name: Publish to PyPI (Trusted Publisher / OIDC) uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + + # ---- Lambda Layer publishing ---------------------------------------- + # See layer/MAINTAINER.md for one-time AWS setup. Three jobs: + # build-layer-zip: build the layer ZIP and attach to the GH Release. + # publish-layer: per-region matrix; assume OIDC role; publish layer + # version with public read. + # aggregate-arns: collect per-region ARNs into a JSON manifest + + # markdown table; upload manifest as a release asset + # and append the table to the release notes. + # All three are gated on `release_created == 'true'` from release-please. + + build-layer-zip: + name: build Lambda Layer ZIP + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + runs-on: ubuntu-24.04 + permissions: + contents: write # for `gh release upload` + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + + - name: Install uv with Python 3.12 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: '3.12' + enable-cache: true + + - name: Build wheel + run: uv build --wheel + + - name: Repackage wheel as Lambda Layer ZIP + env: + TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + # Strip leading 'v' from the tag (v1.2.0 -> 1.2.0). + version="${TAG#v}" + mkdir -p build/python + unzip -q dist/cfn_handler-*.whl -d build/python + # Lambda doesn't need pip's bookkeeping. + rm -rf build/python/*.dist-info + (cd build && zip -qr "../dist/cfn_handler-${version}-layer.zip" python/) + ls -la dist/ + + - name: Upload Layer ZIP to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ needs.release-please.outputs.tag_name }}" dist/cfn_handler-*-layer.zip \ + --repo "${{ github.repository }}" \ + --clobber + + set-layer-matrix: + name: build Layer publish matrix + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + runs-on: ubuntu-24.04 + outputs: + regions: ${{ steps.regions.outputs.regions }} + runtimes: ${{ steps.runtimes.outputs.runtimes }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + + - name: Read layer/regions.txt into a JSON array + id: regions + run: | + set -euo pipefail + # Strip blank lines and # comments; jq -R -s -c assembles the + # array. Output as `regions=[...]` for the matrix to consume. + regions=$(grep -vE '^\s*(#|$)' layer/regions.txt | jq -R -s -c 'split("\n") | map(select(length > 0))') + echo "regions=${regions}" >> "$GITHUB_OUTPUT" + echo "Matrix regions: ${regions}" + + - name: Derive Lambda --compatible-runtimes from pyproject.toml + id: runtimes + run: | + set -euo pipefail + # pyproject.toml's classifiers are the canonical statement of + # supported Python versions. Extract the X.Y entries (skip the + # bare "Python :: 3" classifier) and emit Lambda runtime names. + # ubuntu-24.04 ships python3 >= 3.11, so tomllib is available. + runtimes=$(python3 - <<'PY' + import tomllib + with open("pyproject.toml", "rb") as fp: + data = tomllib.load(fp) + versions = [ + c.rsplit(":", 1)[-1].strip() + for c in data["project"]["classifiers"] + if c.startswith("Programming Language :: Python :: 3.") + and c.rsplit(":", 1)[-1].strip().count(".") == 1 + ] + print(" ".join(f"python{v}" for v in versions)) + PY + ) + echo "runtimes=${runtimes}" >> "$GITHUB_OUTPUT" + echo "Compatible runtimes: ${runtimes}" + + publish-layer: + name: publish Layer (${{ matrix.region }}) + needs: [release-please, build-layer-zip, set-layer-matrix] + if: ${{ needs.release-please.outputs.release_created == 'true' }} + runs-on: ubuntu-24.04 + environment: layer-publisher + permissions: + id-token: write # OIDC token to assume the publisher role + contents: read + strategy: + fail-fast: false + max-parallel: 10 # Be polite to AWS APIs; matrix has ~17 entries today. + matrix: + region: ${{ fromJSON(needs.set-layer-matrix.outputs.regions) }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + + - name: Download Layer ZIP from GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + gh release download "$TAG" \ + --repo "${{ github.repository }}" \ + --pattern 'cfn_handler-*-layer.zip' \ + --dir dist/ + ls -la dist/ + + - name: Configure AWS credentials via OIDC + uses: aws-actions/configure-aws-credentials@00943011d9042930efac3dcd3a170e4273319bc8 # v5.1.0 + with: + role-to-assume: ${{ secrets.LAYER_PUBLISHER_ROLE_ARN }} + aws-region: ${{ matrix.region }} + role-session-name: cfn-handler-layer-publisher-${{ matrix.region }} + role-duration-seconds: 1200 + + - name: Publish Layer + grant public read + env: + REGION: ${{ matrix.region }} + TAG: ${{ needs.release-please.outputs.tag_name }} + RUNTIMES: ${{ needs.set-layer-matrix.outputs.runtimes }} + run: | + set -euo pipefail + version="${TAG#v}" + # Resolve the layer zip via globbing (find handles non-alphanumerics + # better than `ls | head`). + zip_file=$(find dist -maxdepth 1 -name 'cfn_handler-*-layer.zip' -print -quit) + if [[ -z "$zip_file" ]]; then + echo "ERROR: no layer zip found in dist/" >&2 + exit 1 + fi + + echo "Publishing layer in ${REGION} from ${zip_file}..." + echo "Compatible runtimes: ${RUNTIMES}" + # RUNTIMES is space-separated (e.g. "python3.10 python3.11 ...") + # and intentionally word-split here so each version becomes its + # own --compatible-runtimes argument. + # shellcheck disable=SC2086 + arn=$(aws lambda publish-layer-version \ + --region "$REGION" \ + --layer-name cfn-handler \ + --description "cfn-handler ${version}" \ + --license-info "Apache-2.0" \ + --zip-file "fileb://${zip_file}" \ + --compatible-runtimes ${RUNTIMES} \ + --compatible-architectures x86_64 arm64 \ + --query LayerVersionArn --output text) + echo "Published: $arn" + version_number=$(echo "$arn" | awk -F: '{print $NF}') + + echo "Granting public read on version ${version_number}..." + # add-layer-version-permission is only idempotent for the + # ResourceConflictException case (statement-id already exists on + # this layer version). All OTHER errors — throttling, permission, + # service errors — must fail the job, otherwise we'd publish a + # layer ARN without the public read grant and the job would + # falsely report green while the user-facing API breaks. + set +e + add_output=$(aws lambda add-layer-version-permission \ + --region "$REGION" \ + --layer-name cfn-handler \ + --version-number "$version_number" \ + --statement-id PublicRead \ + --action lambda:GetLayerVersion \ + --principal '*' 2>&1) + add_exit=$? + set -e + + if [[ $add_exit -eq 0 ]]; then + echo " public read granted" + elif echo "$add_output" | grep -q 'ResourceConflictException'; then + echo " (PublicRead already granted; idempotent)" + else + echo "ERROR: failed to grant public read on ${REGION} layer version ${version_number} (exit ${add_exit}):" >&2 + echo "$add_output" >&2 + exit 1 + fi + + # Emit per-region artifact for the aggregate-arns job. + mkdir -p artifacts + jq -nc --arg region "$REGION" --arg arn "$arn" \ + '{region: $region, arn: $arn}' > "artifacts/arn-${REGION}.json" + + - name: Upload per-region ARN artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: layer-arn-${{ matrix.region }} + path: artifacts/arn-${{ matrix.region }}.json + if-no-files-found: error + retention-days: 7 + + aggregate-arns: + name: aggregate Layer ARNs into release notes + manifest + needs: [release-please, publish-layer] + if: ${{ always() && needs.release-please.outputs.release_created == 'true' }} + runs-on: ubuntu-24.04 + permissions: + contents: write # gh release edit + gh release upload + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + + - name: Download all per-region ARN artifacts + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + path: artifacts/ + pattern: layer-arn-* + merge-multiple: false + + - name: Build layer-arns.json + ARN markdown table + env: + TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + version="${TAG#v}" + + # Each per-region artifact is a directory containing arn-.json. + # Collect into an array, sort by region, build the manifest. + regions_json=$(find artifacts -type f -name 'arn-*.json' \ + -exec cat {} + \ + | jq -s 'sort_by(.region) | map({(.region): .arn}) | add // {}') + jq -n --arg version "$version" --argjson regions "$regions_json" '{ + version: $version, + layer_name: "cfn-handler", + regions: $regions + }' > layer-arns.json + cat layer-arns.json + + # Markdown table of region -> ARN, sorted alphabetically by region. + { + printf '| Region | ARN |\n' + printf '|---|---|\n' + jq -r '.regions | to_entries | sort_by(.key)[] | "| `\(.key)` | `\(.value)` |"' layer-arns.json + } > arns-table.md + cat arns-table.md + + - name: Upload layer-arns.json to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + gh release upload "$TAG" layer-arns.json \ + --repo "${{ github.repository }}" \ + --clobber + + - name: Append ARN table to GitHub Release notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + # Read existing release notes; append our section if not already + # present (so re-runs are idempotent). + gh release view "$TAG" --repo "${{ github.repository }}" --json body --jq .body > existing-notes.md + if ! grep -q '^## Lambda Layer ARNs' existing-notes.md; then + { + cat existing-notes.md + printf '\n\n## Lambda Layer ARNs\n\n' + cat arns-table.md + printf '\n\n_Layer ZIP also attached as a release asset; programmatic manifest at_ `layer-arns.json`. _See `layer/README.md` for usage._\n' + } > new-notes.md + gh release edit "$TAG" --repo "${{ github.repository }}" --notes-file new-notes.md + echo "Release notes updated with ARN table." + else + echo "Release notes already contain ARN table; skipping append." + fi diff --git a/README.md b/README.md index 2c5fc8a..c9eeef3 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![CI](https://github.com/igorlg/cfn-handler/actions/workflows/ci.yml/badge.svg)](https://github.com/igorlg/cfn-handler/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/cfn-handler.svg)](https://pypi.org/project/cfn-handler/) [![Python Versions](https://img.shields.io/pypi/pyversions/cfn-handler.svg)](https://pypi.org/project/cfn-handler/) +[![Lambda Layer](https://img.shields.io/github/v/release/igorlg/cfn-handler?label=lambda%20layer&color=ff9900&logo=amazonaws)](https://github.com/igorlg/cfn-handler/releases/latest) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) A modern, well-engineered Python library for writing AWS CloudFormation @@ -71,6 +72,35 @@ uv add cfn-handler Polling support uses `boto3` lazily; `boto3` ships preinstalled in the AWS Lambda Python runtimes, so no extra install is needed there. +### Or use the AWS Lambda Layer + +Every release publishes a public Lambda Layer in ~17 commercial regions. To +use it, reference the ARN in your function definition — no `pip install` +during deploy, no vendoring into your function package: + +```yaml +# SAM +Resources: + MyFunction: + Type: AWS::Serverless::Function + Properties: + Runtime: python3.12 + Layers: + - arn:aws:lambda:us-east-1::layer:cfn-handler:N +``` + +Find the right ARN for your region in the [latest release notes][latest-release] +(per-region table) or the JSON manifest: + +```sh +curl -fsSL https://github.com/igorlg/cfn-handler/releases/latest/download/layer-arns.json +``` + +See [`layer/README.md`](layer/README.md) for SAM/CDK snippets, the alternative +deploy-it-yourself path, and the maintainer's account ID. + +[latest-release]: https://github.com/igorlg/cfn-handler/releases/latest + ## Comparison to crhelper If you're coming from [`crhelper`][upstream], the model will feel familiar: diff --git a/docs/CI.md b/docs/CI.md index 85a91db..d6bf5c0 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -12,7 +12,7 @@ Six workflows under `.github/workflows/`: - **`codeql.yml`** — Python security-and-quality scan. - **`dependency-review.yml`** — license + vulnerability gate on PRs. - **`examples-lint.yml`** — cfn-lint over example SAM templates (PR-only, path-filtered). -- **`release.yml`** — release-please + PyPI Trusted Publishing OIDC. +- **`release.yml`** — release-please + PyPI Trusted Publishing OIDC; also drives the Lambda Layer publish across ~17 commercial regions via OIDC into a maintainer-owned AWS account. - **`secure-workflows.yml`** — enforces commit-SHA pinning of every action. Plus a Dependabot config (`.github/dependabot.yml`) and an `act` runner @@ -34,7 +34,7 @@ with no API tokens stored anywhere. | `codeql.yml` | `pull_request: main`, `push: main`, weekly cron | `analyze (python)` | yes | | `dependency-review.yml` | `pull_request: main` | `review dependencies` | yes | | `examples-lint.yml` | `pull_request: main` (paths: `examples/**`) | `cfn-lint over examples` | no (informational) | -| `release.yml` | `push: main`, `workflow_dispatch` | `release-please bot`, `build + attach release artifacts`, `publish to PyPI` | n/a (post-merge) | +| `release.yml` | `push: main`, `workflow_dispatch` | `release-please bot`, `build + attach release artifacts`, `publish to PyPI`, `build Lambda Layer ZIP`, `build Layer publish matrix`, `publish Layer (matrix over regions)`, `aggregate Layer ARNs into release notes + manifest` | n/a (post-merge) | | `secure-workflows.yml` | `pull_request: main` | `ensure SHA-pinned actions` | yes | Branch protection on `main` requires the four "yes" checks above (the @@ -198,7 +198,7 @@ feat: / fix: / feat!: commit ▼ creates git tag + GH Release │ - ┌───────────────────┴────────────────────┐ + ┌───────────────────┴────────────────────┐ ▼ ▼ publish-artifacts publish-pypi (uv build → gh release upload) (uv build → pypa/gh-action-pypi-publish) @@ -211,6 +211,41 @@ feat: / fix: / feat!: commit wheel + sdist on PyPI ``` +In parallel with the PyPI publish, the Lambda Layer publishing surface +runs (see [`layer/MAINTAINER.md`](../layer/MAINTAINER.md)): + +```text +release-please ──► build-layer-zip ──► publish-layer (matrix over ~17 regions) + (wheel → ZIP) (assume OIDC role; aws lambda + publish-layer-version; public read; + per-region artifact) + │ + ▼ + aggregate-arns + (download per-region artifacts; + build layer-arns.json; upload as + release asset; append ARN table to + GitHub Release notes) +``` + +The layer pipeline is gated on the `layer-publisher` GitHub environment +which holds `LAYER_PUBLISHER_ROLE_ARN`. Per-region failures are isolated +(`fail-fast: false`); the rest of the release succeeds even if a few +regions fail. The `aggregate-arns` job runs `if: always()` so a partial +publish still produces an inventory of the regions that did succeed. + +Three public ARN-discovery surfaces (no AWS credentials required): + +1. **GitHub Release body** — the `aggregate-arns` job appends a per-region + ARN markdown table to the release notes. +2. **`layer-arns.json` release asset** — structured manifest at + `https://github.com/igorlg/cfn-handler/releases/latest/download/layer-arns.json`. +3. **README badge** — current Layer version inline with the existing + PyPI / Python / License badges (uses GitHub-native release endpoint). + +See [`layer/README.md`](../layer/README.md) for consumer usage (SAM/CDK +snippets, ARN format, alternative deploy in your own account). + ### Conventional Commits → version bump | Commit prefix | Version bump | diff --git a/layer/MAINTAINER.md b/layer/MAINTAINER.md new file mode 100644 index 0000000..16359cd --- /dev/null +++ b/layer/MAINTAINER.md @@ -0,0 +1,242 @@ +# Lambda Layer publishing — maintainer setup + +This document is for the maintainer (`igorlg`) configuring the AWS account +and GitHub environment that the release pipeline uses to publish the +`cfn-handler` Lambda Layer. **End-users consuming the layer should read +[README.md](README.md) instead.** + +## Overview + +Every successful release of `cfn-handler` triggers three layer-related +GitHub Actions jobs in `release.yml`: + +1. `build-layer-zip` — builds `cfn_handler--layer.zip` and uploads + it to the GitHub Release. +2. `publish-layer` — matrix over the regions in [`regions.txt`](regions.txt); + per region, assumes the IAM role via OIDC, calls `lambda:PublishLayerVersion`, + grants public read. +3. `aggregate-arns` — gathers the per-region ARNs, uploads + `layer-arns.json` as a release asset, and edits the GitHub Release body + to append a per-region ARN markdown table. + +Steps 2 and 3 require the IAM role and the GitHub environment configured +below. Without them, the jobs fail at credential acquisition; the rest of +the release pipeline (PyPI publish, etc.) is unaffected. + +## One-time AWS account setup + +These steps must be completed **before** the first release that includes +the layer-publishing jobs. After that, no further AWS-side action is needed +unless you rotate the role or add regions. + +### 1. Deploy the IAM role CloudFormation stack + +The CloudFormation template at [`iam-publisher.cfn.yaml`](iam-publisher.cfn.yaml) +creates: + +- An OIDC identity provider for GitHub Actions + (`token.actions.githubusercontent.com`). +- An IAM role named `cfn-handler-layer-publisher` whose trust policy + permits assumption only by GitHub Actions runs of `igorlg/cfn-handler` + inside the `layer-publisher` environment. +- An inline policy scoping the role to `cfn-handler*` named layers; nothing + else. + +```bash +# Pick any commercial region for the stack — the role is global. +# Add `--profile ` if you use named AWS profiles. +aws cloudformation deploy \ + --stack-name cfn-handler-layer-publisher \ + --template-file layer/iam-publisher.cfn.yaml \ + --capabilities CAPABILITY_NAMED_IAM \ + --region us-east-1 +``` + +If the OIDC provider already exists in your account (from a previous project), +re-run with `--parameter-overrides CreateOidcProvider=false` to skip creating it: + +```bash +aws cloudformation deploy \ + --stack-name cfn-handler-layer-publisher \ + --template-file layer/iam-publisher.cfn.yaml \ + --capabilities CAPABILITY_NAMED_IAM \ + --region us-east-1 \ + --parameter-overrides CreateOidcProvider=false +``` + +To check whether the OIDC provider already exists: + +```bash +aws iam list-open-id-connect-providers \ + --query 'OpenIDConnectProviderList[?contains(Arn, `token.actions.githubusercontent.com`)]' +``` + +Empty array → safe to use the first `deploy` command (creates the provider). +Non-empty → use the `CreateOidcProvider=false` variant. + +Capture the role ARN from the stack outputs: + +```bash +aws cloudformation describe-stacks \ + --stack-name cfn-handler-layer-publisher \ + --region us-east-1 \ + --query 'Stacks[0].Outputs[?OutputKey==`RoleArn`].OutputValue' \ + --output text +``` + +The output looks like `arn:aws:iam:::role/cfn-handler-layer-publisher`. + +### 2. Create the `layer-publisher` GitHub environment + +Two equivalent paths — UI or `gh` CLI. + +#### CLI (recommended; reproducible): + +```bash +gh api -X PUT /repos/igorlg/cfn-handler/environments/layer-publisher +``` + +This creates an environment with **no protection rules** (the default +when no body is sent). Required-reviewer or wait-timer rules would block +the release-please bot from triggering the publish pipeline; we +deliberately leave them off. + +#### UI: + +1. Settings → Environments → **New environment** → name it `layer-publisher`. +2. Leave **all** protection rules unchecked. The release pipeline runs from + release-please bot PRs; required-reviewers / wait-timer rules would block + bot-driven releases. +3. Save. + +### 3. Add the role ARN as a secret + +#### CLI (recommended): + +```bash +gh secret set LAYER_PUBLISHER_ROLE_ARN \ + --env layer-publisher \ + --repo igorlg/cfn-handler \ + --body 'arn:aws:iam:::role/cfn-handler-layer-publisher' +``` + +(Replace the ARN with the value captured from step 1.) + +#### UI: + +Inside the new `layer-publisher` environment: + +1. **Add secret** → name `LAYER_PUBLISHER_ROLE_ARN`, value = the ARN from + step 1. +2. Save. + +#### Verify + +```bash +gh api /repos/igorlg/cfn-handler/environments/layer-publisher --jq '{name, protection_rules}' +gh secret list --env layer-publisher --repo igorlg/cfn-handler +``` + +The release pipeline references the secret as +`${{ secrets.LAYER_PUBLISHER_ROLE_ARN }}` inside the `publish-layer` job +(which has `environment: layer-publisher`). + +### 4. (Optional) Verify the OIDC provider exists + +```bash +aws iam list-open-id-connect-providers \ + --query 'OpenIDConnectProviderList[?contains(Arn, `token.actions.githubusercontent.com`)]' +``` + +Expected: a single ARN matching the canonical GitHub Actions provider. + +## Adding a region + +The release matrix is generated from [`regions.txt`](regions.txt). To add +a region: + +1. Confirm the region is supported by AWS Lambda *and* enabled in your + account (some regions are opt-in: AWS Console → IAM Identity Center → + Account → Regions; or `aws account enable-region --region-name `). +2. Add the region to `regions.txt` on a new line (alphabetically sorted to + keep diffs clean). +3. Open a PR; `secure-workflows.yml` runs against the workflow file, but + the new region won't actually be used until the PR merges and a release + triggers. + +## Removing a region + +Delete the line from `regions.txt`. The release pipeline will stop publishing +new layer versions to that region. **Existing layer versions remain published +in the region** — `aws lambda` doesn't support layer-version deletion via the +default policy. If you want to remove them, add `lambda:DeleteLayerVersion` +to the role's policy temporarily and run a one-off cleanup script. + +## Rotating the IAM role + +Re-deploying the CloudFormation stack with `aws cloudformation deploy` is +idempotent and updates the role in place. To change the role name (which +forces recreation): + +1. Update `RoleName` in `iam-publisher.cfn.yaml` (or pass via + `--parameter-overrides`). +2. Re-deploy. +3. Update the `LAYER_PUBLISHER_ROLE_ARN` secret in the GitHub environment + to the new ARN. + +## Disabling layer publishing + +If you need to pause layer publishing (e.g. during AWS account migration): + +- **Quick stop** — delete the GitHub `layer-publisher` environment. The + `publish-layer` jobs will fail at environment activation; the rest of + the release pipeline (PyPI publish, GH Release ZIP) continues normally. +- **Full removal** — also delete the CFN stack: + `aws cloudformation delete-stack --stack-name cfn-handler-layer-publisher --region us-east-1`. + +## Troubleshooting + +### `publish-layer` fails at "configure-aws-credentials" + +Likely causes: +- The `layer-publisher` GitHub environment doesn't exist (create per step 2 above). +- `LAYER_PUBLISHER_ROLE_ARN` secret missing or wrong (check inside the + `layer-publisher` environment, not at the repository level). +- Trust policy mismatch — re-deploy the CFN stack or inspect the role's + trust policy in the IAM console; the `sub` condition must match + `repo:igorlg/cfn-handler:environment:layer-publisher`. + +### `lambda:PublishLayerVersion` fails with `AccessDenied` on a specific region + +The region is probably opt-in and not enabled in your account. Enable it +(`aws account enable-region --region-name `) and re-run the failed +matrix entry via `gh run rerun --failed`. + +### `lambda:AddLayerVersionPermission` fails with `ResourceConflictException` + +The `PublicRead` statement was added on a previous run with the same +`StatementId`. Lambda doesn't allow adding a duplicate statement. The +workflow handles this idempotently — it ignores the conflict and continues +because the policy is already in place. + +### Region count drift (regions.txt has N, AWS account has M enabled) + +`regions.txt` is the source of truth. If a region is in `regions.txt` but +the account hasn't opted into it, that matrix entry fails (transient until +opt-in completes; permanent without it). If a region is enabled in the +account but not in `regions.txt`, no layer is published there — add the +region to `regions.txt`. + +## Cost + +- IAM role + OIDC provider: free. +- Lambda layers: storage is free; per-region replication is by re-publishing + (no egress cost). + +Total expected monthly cost: $0. + +## See also + +- [README.md](README.md) — consumer-facing usage of the published layer. +- [`../docs/CI.md`](../docs/CI.md) — broader CI/release pipeline reference. +- [`../openspec/changes/archive/2026-05-21-publish-lambda-layer/`](../openspec/changes/archive/) — original proposal and design rationale (after this change is archived). diff --git a/layer/README.md b/layer/README.md new file mode 100644 index 0000000..de21e3f --- /dev/null +++ b/layer/README.md @@ -0,0 +1,199 @@ +# cfn-handler — Lambda Layer + +`cfn-handler` is published as a public AWS Lambda Layer in every release +across ~17 commercial regions. Reference the layer in your Lambda function +to use `cfn-handler` without bundling it into your function package. + +[![Lambda Layer](https://img.shields.io/github/v/release/igorlg/cfn-handler?label=lambda%20layer&color=ff9900&logo=amazonaws)](https://github.com/igorlg/cfn-handler/releases/latest) + +## Why use the layer? + +- **No vendoring** — your function package stays small (~20 KB lighter). +- **No build step** — skip `pip install -t ./` during deploy. +- **Same code as PyPI** — the layer is the wheel, repackaged for Lambda. + +## ARN format + +``` +arn:aws:lambda:::layer:cfn-handler: +``` + +The maintainer's account ID and the version number for a specific release +appear in the inventory surfaces below. + +## Find an ARN + +### Option 1: GitHub Release page (browser) + +Visit `https://github.com/igorlg/cfn-handler/releases/latest` (or any +specific release). Each release's notes include a per-region ARN markdown +table. + +### Option 2: Programmatic JSON manifest + +Every release uploads a `layer-arns.json` asset to the GitHub Release. +Fetch the latest: + +```bash +curl -fsSL https://github.com/igorlg/cfn-handler/releases/latest/download/layer-arns.json +``` + +Pin a specific version: + +```bash +curl -fsSL https://github.com/igorlg/cfn-handler/releases/download/v1.2.0/layer-arns.json +``` + +Schema: + +```json +{ + "version": "1.2.0", + "layer_name": "cfn-handler", + "regions": { + "us-east-1": "arn:aws:lambda:us-east-1::layer:cfn-handler:N", + "us-east-2": "arn:aws:lambda:us-east-2::layer:cfn-handler:N", + "...": "..." + } +} +``` + +### Option 3: AWS CLI direct query + +If you know the layer name (`cfn-handler`) and the maintainer's account ID, +list versions in your region: + +```bash +aws lambda list-layer-versions \ + --layer-name arn:aws:lambda:us-east-1::layer:cfn-handler \ + --region us-east-1 +``` + +This works **only** because the layer has a public read grant (anyone in +any AWS account can `GetLayerVersion`/`ListLayerVersions`). + +## Use in SAM + +```yaml +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 + +Resources: + MyCustomResourceFunction: + Type: AWS::Serverless::Function + Properties: + Runtime: python3.12 + Handler: index.handler + CodeUri: src/ + Layers: + - arn:aws:lambda:us-east-1::layer:cfn-handler:1 +``` + +In your handler: + +```python +from cfn_handler import CustomResource + +resource = CustomResource() + +@resource.create +def on_create(event, context): + return {"Endpoint": "..."} + +@resource.update +def on_update(event, context): + return {"Endpoint": "..."} + +@resource.delete +def on_delete(event, context): + return None + +def handler(event, context): + return resource(event, context) +``` + +## Use in CDK (Python) + +```python +from aws_cdk import aws_lambda as lambda_, Stack + +class MyStack(Stack): + def __init__(self, scope, id, **kwargs): + super().__init__(scope, id, **kwargs) + + cfn_handler_layer = lambda_.LayerVersion.from_layer_version_arn( + self, "CfnHandlerLayer", + f"arn:aws:lambda:{self.region}::layer:cfn-handler:1", + ) + + lambda_.Function( + self, "MyFunction", + runtime=lambda_.Runtime.PYTHON_3_12, + handler="index.handler", + code=lambda_.Code.from_asset("src"), + layers=[cfn_handler_layer], + ) +``` + +## Use in CDK (TypeScript) + +```ts +import { Stack, StackProps } from "aws-cdk-lib"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import { Construct } from "constructs"; + +export class MyStack extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + const cfnHandlerLayer = lambda.LayerVersion.fromLayerVersionArn( + this, "CfnHandlerLayer", + `arn:aws:lambda:${this.region}::layer:cfn-handler:1`, + ); + + new lambda.Function(this, "MyFunction", { + runtime: lambda.Runtime.PYTHON_3_12, + handler: "index.handler", + code: lambda.Code.fromAsset("src"), + layers: [cfnHandlerLayer], + }); + } +} +``` + +## Compatibility + +| | Supported | +|---|---| +| Lambda runtimes | `python3.10`, `python3.11`, `python3.12`, `python3.13`, `python3.14` | +| Lambda architectures | `x86_64`, `arm64` | +| Regions | See [`regions.txt`](regions.txt) | +| GovCloud (`us-gov-*`) | Not supported (out of scope for now) | +| China (`cn-*`) | Not supported (out of scope for now) | + +## Don't want to use the layer? + +`pip install cfn-handler` from PyPI continues to work. The layer is an +alternative; the wheel is the canonical artifact. + +If you want to deploy the layer **in your own AWS account** (e.g. for +GovCloud, China, an offline environment, or just personal preference), +download the ZIP from any GitHub Release: + +```bash +curl -fsSL https://github.com/igorlg/cfn-handler/releases/latest/download/cfn_handler-1.2.0-layer.zip -o layer.zip +aws lambda publish-layer-version \ + --layer-name cfn-handler \ + --zip-file fileb://layer.zip \ + --compatible-runtimes python3.10 python3.11 python3.12 python3.13 python3.14 \ + --compatible-architectures x86_64 arm64 \ + --region +``` + +The wheel contents are identical to the public layer's contents. + +## See also + +- [Library README](../README.md) for the full `cfn-handler` API. +- [MAINTAINER.md](MAINTAINER.md) for publishing operations (maintainer-only). +- [docs/CI.md](../docs/CI.md) for the broader release pipeline. diff --git a/layer/iam-publisher.cfn.yaml b/layer/iam-publisher.cfn.yaml new file mode 100644 index 0000000..5589413 --- /dev/null +++ b/layer/iam-publisher.cfn.yaml @@ -0,0 +1,112 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: > + IAM role assumed by GitHub Actions (via OIDC) to publish the cfn-handler + Lambda Layer across commercial regions. + + Trust is scoped to runs of the `release.yml` workflow inside the + `igorlg/cfn-handler` repository while the `layer-publisher` environment + is active. Permissions are scoped to `cfn-handler*` named layers; nothing + else. + +Parameters: + GitHubOrg: + Type: String + Default: igorlg + Description: GitHub organisation/username that owns the cfn-handler repository. + GitHubRepo: + Type: String + Default: cfn-handler + Description: GitHub repository name. + GitHubEnvironment: + Type: String + Default: layer-publisher + Description: | + GitHub Actions environment that gates assumption of this role. Must match + the `environment:` field on the publish-layer job in release.yml. + RoleName: + Type: String + Default: cfn-handler-layer-publisher + Description: | + Name of the IAM role created by this template. Output as the role ARN. + Save the ARN as the `LAYER_PUBLISHER_ROLE_ARN` secret of the GitHub + environment named in `GitHubEnvironment`. + CreateOidcProvider: + Type: String + Default: 'true' + AllowedValues: ['true', 'false'] + Description: | + Create the GitHub Actions OIDC provider in this account. Set to 'false' + if your account already has the OIDC provider configured (an account + can have at most one provider for a given URL). + +Conditions: + # The OIDC provider for GitHub Actions is account-global. If you've already + # deployed it (e.g. for another project), this stack will fail with + # "EntityAlreadyExists". Set CreateOidcProvider=false to skip creating it. + CreateOidcProviderCondition: !Equals [!Ref CreateOidcProvider, 'true'] + +Resources: + GitHubOidcProvider: + Type: AWS::IAM::OIDCProvider + Condition: CreateOidcProviderCondition + Properties: + Url: https://token.actions.githubusercontent.com + ClientIdList: + - sts.amazonaws.com + # GitHub publishes its OIDC certificate thumbprints; AWS now resolves + # them automatically as of June 2023, so the ThumbprintList field is + # no longer required (a placeholder is acceptable; AWS ignores it). + ThumbprintList: + - 6938fd4d98bab03faadb97b34396831e3780aea1 + + LayerPublisherRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Ref RoleName + Description: !Sub > + Assumed by GitHub Actions runs of release.yml in + ${GitHubOrg}/${GitHubRepo} (environment: ${GitHubEnvironment}) to + publish the cfn-handler Lambda Layer across commercial regions. + MaxSessionDuration: 3600 + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com + Action: sts:AssumeRoleWithWebIdentity + Condition: + StringEquals: + token.actions.githubusercontent.com:aud: sts.amazonaws.com + StringLike: + token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/${GitHubRepo}:environment:${GitHubEnvironment} + Policies: + - PolicyName: PublishLayerVersions + PolicyDocument: + Version: '2012-10-17' + Statement: + # Publish layer versions; grant public read; introspect + # existing versions and policies. Resource scope: any region, + # but only layers named cfn-handler* in this account. + - Sid: PublishCfnHandlerLayers + Effect: Allow + Action: + - lambda:PublishLayerVersion + - lambda:GetLayerVersion + - lambda:GetLayerVersionPolicy + - lambda:AddLayerVersionPermission + - lambda:RemoveLayerVersionPermission + - lambda:ListLayerVersions + Resource: + - !Sub arn:aws:lambda:*:${AWS::AccountId}:layer:cfn-handler + - !Sub arn:aws:lambda:*:${AWS::AccountId}:layer:cfn-handler:* + +Outputs: + RoleArn: + Description: | + ARN of the layer-publisher role. Save this as the + LAYER_PUBLISHER_ROLE_ARN secret of the `layer-publisher` GitHub + environment in igorlg/cfn-handler. + Value: !GetAtt LayerPublisherRole.Arn + Export: + Name: !Sub ${AWS::StackName}-RoleArn diff --git a/layer/regions.txt b/layer/regions.txt new file mode 100644 index 0000000..061f635 --- /dev/null +++ b/layer/regions.txt @@ -0,0 +1,35 @@ +# Lambda Layer publishing — region list. +# +# Each non-blank, non-comment line is a region the release pipeline +# publishes the cfn-handler layer to. Add or remove regions by editing +# this file; release.yml reads it directly via shell expansion to build +# its matrix. +# +# This list is the 17 commercial regions enabled by default in any AWS +# account (no opt-in required). Opt-in regions (ap-east-1, af-south-1, +# eu-south-1, eu-south-2, eu-central-2, me-south-1, me-central-1, +# il-central-1, ap-southeast-3, ap-southeast-4, ap-south-2, ap-northeast-3 +# variants, etc.) need to be enabled in the maintainer's AWS account +# first; see layer/MAINTAINER.md. +# +# GovCloud (us-gov-*) and China (cn-*) regions are out of scope for +# this iteration — they need a separate AWS account with different +# IAM partitions and OIDC audience handling. + +us-east-1 +us-east-2 +us-west-1 +us-west-2 +eu-west-1 +eu-west-2 +eu-west-3 +eu-central-1 +eu-north-1 +ap-northeast-1 +ap-northeast-2 +ap-northeast-3 +ap-southeast-1 +ap-southeast-2 +ap-south-1 +ca-central-1 +sa-east-1 diff --git a/openspec/changes/publish-lambda-layer/.openspec.yaml b/openspec/changes/publish-lambda-layer/.openspec.yaml new file mode 100644 index 0000000..7136965 --- /dev/null +++ b/openspec/changes/publish-lambda-layer/.openspec.yaml @@ -0,0 +1,6 @@ +schema: spec-driven +created: 2026-05-21 +goal: Users can install cfn-handler in their Lambda functions either by + downloading the layer ZIP from a GH Release, or (more conveniently) by + referencing a public per-region Layer ARN published from a maintainer-managed + AWS account diff --git a/openspec/changes/publish-lambda-layer/README.md b/openspec/changes/publish-lambda-layer/README.md new file mode 100644 index 0000000..5126b3e --- /dev/null +++ b/openspec/changes/publish-lambda-layer/README.md @@ -0,0 +1,3 @@ +# publish-lambda-layer + +Publish a Lambda Layer ZIP to GitHub Releases on every release; additionally deploy that layer to all commercial AWS regions with public read access via OIDC-federated GitHub Actions diff --git a/openspec/changes/publish-lambda-layer/design.md b/openspec/changes/publish-lambda-layer/design.md new file mode 100644 index 0000000..1047025 --- /dev/null +++ b/openspec/changes/publish-lambda-layer/design.md @@ -0,0 +1,273 @@ +# Design: Publish a Lambda Layer + +## Context + +Lambda Layers are a deploy-time mechanism for shipping shared code to Lambda functions. AWS hosts the layer; consumers reference it by ARN; the layer's contents are mounted at `/opt//` at function-invocation time. + +For pure-Python libraries, the conventional packaging is: + +``` + +└── python/ + └── cfn_handler/ + ├── __init__.py + ├── exceptions.py + ├── resource.py + ├── py.typed + └── _internal/ + └── ... +``` + +`/opt/python/` is on the Python path at invocation; `from cfn_handler import CustomResource` works without any further config. + +A layer is published in one specific AWS region. To make the layer available globally, the maintainer publishes it independently in each region and gives each layer version's resource policy a public-read grant. Consumers reference the region-local ARN. + +The reference implementation in this ecosystem is `aws-powertools/powertools-lambda-python`, which publishes per-region layers with public read across ~30 commercial regions, plus separate pipelines for GovCloud and China partitions. We're not matching that scale — see Non-goals — but the architecture is the same. + +## Goals / Non-Goals + +**Goals:** + +- Layer ZIP attached to every GitHub Release (cheap, available even if cross-region publishing fails). +- Public-read Layer ARNs in every commercial region the maintainer has enabled. +- ARN discovery via three public surfaces — augmented GitHub Release body, `layer-arns.json` release asset, and README shields.io badge — none of which require AWS credentials. +- One ZIP works for all supported Python versions (3.10–3.14) and architectures (x86_64, arm64) — pure Python, zero deps. +- OIDC-federated IAM role; no long-lived AWS credentials in GitHub secrets. +- `layer/regions.txt` is the single source of truth for which regions get published; release.yml's matrix is generated from it. +- `pyproject.toml`'s `Programming Language :: Python :: 3.X` classifiers are the single source of truth for `--compatible-runtimes`; release.yml derives the runtime list at publish time so the layer's declared support cannot drift from the package's. +- Failure in one region doesn't block other regions (`fail-fast: false` matrix). + +**Non-Goals:** + +- GovCloud (`us-gov-*`), China (`cn-*`), or SAR app publishing. +- Canary stack post-publish. +- SLSA provenance on the layer ZIP (out of scope; wheel already covers supply-chain attestation). +- Auto-opt-in to regions like `ap-east-1`, `me-south-1`, `af-south-1`. These need account-level opt-in; the `regions.txt` file lists explicitly-supported regions only. +- A Layer Construct library (CDK helper) — defer until users actually ask. + +## Decisions + +### D1 — Layer ZIP structure: one universal ZIP per release + +Pure-Python zero-deps means the ZIP is identical regardless of target Python version or architecture. We ship one `cfn_handler-X.Y.Z-layer.zip` whose top-level dir is `python/`, containing the unpacked wheel contents minus the `dist-info` metadata (the `dist-info` is for pip's bookkeeping; not needed at runtime). The published layer's `CompatibleRuntimes` is derived from `pyproject.toml`'s `Programming Language :: Python :: 3.X` classifiers (so the runtime list can never drift from the package's declared support); `CompatibleArchitectures` lists both `x86_64` and `arm64`. AWS validates these at function-association time but the actual content works on all combinations. + +Considered: per-Python-version layers (5 ZIPs per release). Powertools does this; reasoning is they have C extensions for some optional features. We don't have C extensions and never will (zero-dep policy). One ZIP is simpler and correct. + +### D2 — Region scope: `regions.txt` + non-opt-in regions only at first + +The list of regions the layer publishes to lives in `layer/regions.txt`, one per line, with `#` comments allowed. The release.yml matrix reads this file directly via `jq` or shell. Adding/removing a region is a one-line PR. + +Initial list is the ~17 commercial regions enabled by default in any AWS account (no opt-in required): + +``` +us-east-1 +us-east-2 +us-west-1 +us-west-2 +eu-west-1 +eu-west-2 +eu-west-3 +eu-central-1 +eu-north-1 +ap-northeast-1 +ap-northeast-2 +ap-northeast-3 +ap-southeast-1 +ap-southeast-2 +ap-south-1 +ca-central-1 +sa-east-1 +``` + +The proposal mentions ~30 regions; the gap is opt-in regions (`ap-east-1`, `af-south-1`, `eu-south-1`, `eu-south-2`, `eu-central-2`, `me-south-1`, `me-central-1`, `il-central-1`, `ap-southeast-3`, `ap-southeast-4`, `ap-south-2`, `ap-northeast-3`, etc.). These need account-level opt-in *and* AWS Lambda regional support. The maintainer can enable them in the AWS account and add to `regions.txt` as a follow-up. + +### D3 — IAM role: OIDC-federated, environment-scoped + +The IAM role lives in the maintainer's AWS account. Trust policy: + +```json +{ + "Effect": "Allow", + "Principal": { "Federated": "arn:aws:iam:::oidc-provider/token.actions.githubusercontent.com" }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" + }, + "StringLike": { + "token.actions.githubusercontent.com:sub": "repo:igorlg/cfn-handler:environment:layer-publisher" + } + } +} +``` + +The `sub` condition restricts assumption to GitHub Actions runs inside the `layer-publisher` environment of `igorlg/cfn-handler`. Branch protection enforces that environment-scoped jobs only run after `release-please` succeeds in `release.yml` — there's no other path for an attacker (with write access to the repo) to abuse the role. + +Permissions: + +```json +{ + "Effect": "Allow", + "Action": [ + "lambda:PublishLayerVersion", + "lambda:GetLayerVersion", + "lambda:GetLayerVersionPolicy", + "lambda:AddLayerVersionPermission", + "lambda:RemoveLayerVersionPermission", + "lambda:ListLayerVersions" + ], + "Resource": [ + "arn:aws:lambda:*::layer:cfn-handler", + "arn:aws:lambda:*::layer:cfn-handler:*" + ] +} +``` + +Resource scoping ensures the role can only touch `cfn-handler*` named layers; no escape hatch into the broader account, no permissions to non-Lambda services. CloudFormation template `layer/iam-publisher.cfn.yaml` codifies this. + +### D4 — Public read access via `lambda:AddLayerVersionPermission` + +After each `PublishLayerVersion`, the workflow invokes: + +``` +aws lambda add-layer-version-permission \ + --layer-name cfn-handler \ + --version-number \ + --statement-id PublicRead \ + --action lambda:GetLayerVersion \ + --principal '*' +``` + +This adds a resource policy granting any AWS principal `lambda:GetLayerVersion`. Without it, only the maintainer's account could reference the layer; anyone else gets `AccessDenied`. + +`StatementId: PublicRead` is the same identifier on every layer version (no need to vary; the resource policy is per-version). If the policy fails to apply, the workflow continues — the ARN is still valid for the maintainer's testing, just not for public consumption. A separate verification step asserts the public read grant. + +### D5 — Layer name: `cfn-handler` + +Same name as the PyPI package. Discoverable. Considered alternatives: + +- `cfn-handler-python` — disambiguates from a hypothetical TS layer. Rejected because we don't have a polyglot story; YAGNI. +- `cfn-handler-py3` — version-prefix. Rejected because the runtime version is part of `CompatibleRuntimes`; redundant. +- `igorlg-cfn-handler` — namespaces the layer by maintainer. Rejected because layer ARNs already include the account ID; the layer NAME doesn't need to. + +### D6 — Failure semantics: per-region failures are isolated + +Matrix uses `fail-fast: false`. A single bad region (e.g. transient AWS API hiccup, account opt-in lapse) reports failure but doesn't cancel the other matrix entries. Successful regions get their layer published; failed regions can be retried via `workflow_dispatch` on `release.yml` (which already exists for manual re-trigger). + +The release-pipeline as a whole reports green if `release-please` succeeded and the wheel/sdist published to PyPI even if a few region publishes failed — those are *additional* surfaces, not the canonical artifact. + +### D7 — Build the layer ZIP from the wheel + +The build job extracts `dist/*.whl` into `build/python/`, strips the `*.dist-info` metadata (not needed at runtime), and zips the result. This guarantees layer contents match the published wheel's contents exactly (same bytes, same `__version__`, same py.typed marker). + +```bash +mkdir -p build/python +unzip -q dist/cfn_handler-*.whl -d build/python +rm -rf build/python/*.dist-info +( cd build && zip -qr "../dist/cfn_handler-${VERSION}-layer.zip" python/ ) +``` + +Considered: building the layer from source directly (`pip install -t build/python/ -e .`). Rejected because the wheel is already the canonical built artifact; rebuilding from source risks divergence. + +### D8 — One-time maintainer setup is documented in `layer/MAINTAINER.md` + +The CFN template, the GitHub environment, and the secret all need to be created before the release pipeline can publish. `layer/MAINTAINER.md` walks through: + +1. Open the AWS account; deploy `layer/iam-publisher.cfn.yaml`: + ```bash + aws cloudformation deploy --stack-name cfn-handler-layer-publisher \ + --template-file layer/iam-publisher.cfn.yaml \ + --capabilities CAPABILITY_NAMED_IAM + ``` +2. Output is the role ARN. +3. In GitHub: Settings → Environments → New environment → `layer-publisher`. Add secret `LAYER_PUBLISHER_ROLE_ARN` = the ARN from step 2. +4. Push a `feat:` or `fix:` commit; release-please opens the next release PR; merge it; observe the per-region publish jobs. + +Idempotent: re-deploying the CFN template is a no-op; rotating the role ARN is one CFN update. + +### D9 — User-facing ARN discovery (three public surfaces) + +Three public surfaces let users discover ARNs without AWS credentials or cross-account trust: + +**1. GitHub Release body augmentation (primary).** The `aggregate-arns` job, after all per-region publishes complete, edits the GitHub Release notes via `gh release edit --notes-file -` to append a per-region ARN markdown table: + +```markdown +## Lambda Layer ARNs + +| Region | ARN | +|---|---| +| us-east-1 | `arn:aws:lambda:us-east-1::layer:cfn-handler:` | +| us-east-2 | `arn:aws:lambda:us-east-2::layer:cfn-handler:` | +| ... | +``` + +Anyone landing on `https://github.com/igorlg/cfn-handler/releases/tag/v` sees ARNs at a glance. + +**2. `layer-arns.json` release asset (programmatic).** Same job uploads a structured manifest: + +```json +{ + "version": "1.2.0", + "layer_name": "cfn-handler", + "regions": { + "us-east-1": "arn:aws:lambda:us-east-1::layer:cfn-handler:N", + "us-east-2": "arn:aws:lambda:us-east-2::layer:cfn-handler:N", + ... + } +} +``` + +Fetch via `curl -L https://github.com/igorlg/cfn-handler/releases/latest/download/layer-arns.json` for "always latest" or pin a specific version with `…/releases/download/v1.2.0/layer-arns.json`. + +**3. shields.io badge (visual signal).** README adds: + +```markdown +[![Lambda Layer](https://img.shields.io/github/v/release/igorlg/cfn-handler?label=lambda%20layer&color=ff9900&logo=amazonaws)](https://github.com/igorlg/cfn-handler/releases/latest) +``` + +Uses the GitHub-native release endpoint (no custom server, no extra infrastructure). Updates automatically on every release. Color matches AWS orange. Click-through goes to the latest release page where the ARN table lives. + +Considered: a committed `layer/arns.json` file updated via automated PR after each release. Rejected — adds a workflow that opens a PR per release; brittle. The release-asset approach delivers the same JSON without the PR ceremony. + +Considered: a region-count badge (`shields.io/badge/regions-17-blue`). Rejected — low signal; users care about whether their region is covered, not the count. The README's "Lambda Layer" section names the regions explicitly. + +### Aggregate-arns job mechanics + +Per-region `publish-layer` jobs each upload a small artifact (`arn-.json` containing `{"region": "...", "arn": "..."}`) via `actions/upload-artifact`. The `aggregate-arns` job depends on `publish-layer` (so it runs after the matrix completes), uses `actions/download-artifact` to gather every per-region artifact, builds the consolidated `layer-arns.json`, generates the markdown table, then: + +```bash +gh release upload v$VERSION layer-arns.json --clobber +gh release edit v$VERSION --notes "$(cat existing-notes)\n\n## Lambda Layer ARNs\n\n$(cat arns-table.md)" +``` + +`if: always()` on the aggregate job so partial-region success still produces an inventory of what DID publish. + +## Risks / Trade-offs + +- **[Risk] AWS API rate limits at scale.** ~17 regions × (`PublishLayerVersion` + `AddLayerVersionPermission`) = ~34 API calls per release. Lambda's `PublishLayerVersion` rate limit is ~10/sec/account, well above this; not a concern at our scale. Documented as a known watch-item if region count grows past 30. +- **[Risk] Layer accumulation.** Each release creates a new layer version in every region. AWS keeps all versions; storage is free but versions accumulate. After 100 releases × 17 regions = 1700 layer versions in the account. No deletion policy applied (Powertools doesn't either). Mitigation: spec a `prune-old-layer-versions` workflow as a future change if it becomes painful. +- **[Risk] Maintainer absence.** If the maintainer's AWS account is suspended or the role is deleted, layer publishing fails forever; PyPI publishing continues (different mechanism). Documented in `layer/MAINTAINER.md`; user-facing impact is "users pin to the wheel via pip" which always works. +- **[Trade-off] Public read access.** Anyone in the world can reference `arn:aws:lambda:::layer:cfn-handler:N` without any IAM trust on their side. This is a *feature* (the whole point); the risk is that AWS could deprecate the public-grant pattern (no signs of this). Powertools is in the same boat; if AWS changed the model both projects would adapt together. +- **[Trade-off] Layer ZIP shipped via GH Release AND published to AWS.** Two distribution paths, two places to keep in sync. Minor doubling of effort; offset by the GH Release ZIP being a fallback if the AWS publish ever breaks. +- **[Trade-off] One layer name across all regions, all versions.** No way to "yank" a bad version (Lambda doesn't support layer-version yanking). Mitigation: if a release ships broken, ship a fixed release immediately; users updating to the new ARN get the fix. + +## Migration Plan + +This is additive. No existing artifact changes shape; users continue installing via `pip install cfn-handler` and get exactly what they got before. The layer is a new option alongside, not a replacement. + +Order of operations within the implementing PR: + +1. Add `layer/` directory with all files (`README.md`, `MAINTAINER.md`, `iam-publisher.cfn.yaml`, `regions.txt`). +2. Add `build-layer-zip` and `publish-layer` jobs to `.github/workflows/release.yml` behind `if: release_created == 'true'` and gated by the new `layer-publisher` environment. +3. Update `docs/CI.md` and `README.md` cross-links. +4. **Maintainer one-time setup** (manually, before merging the PR — see `layer/MAINTAINER.md`): + - Deploy `iam-publisher.cfn.yaml` to AWS account. + - Create `layer-publisher` GitHub environment. + - Add `LAYER_PUBLISHER_ROLE_ARN` secret to that environment. +5. Merge the PR. The next release-please merge triggers the new jobs alongside the existing wheel/sdist publish. + +Rollback: if the layer publish itself starts failing, the rest of the release pipeline (PyPI publish, GH Release) is unaffected. The `layer-publisher` environment can be deleted, killing the OIDC trust; the release.yml jobs would fail at credential-acquisition and the remaining release continues normally. Or revert the workflow YAML edits via `git revert`. + +## Open Questions + +None. Region list is editable; IAM role can be redeployed; layer name is locked in once first published but matches PyPI for clarity. diff --git a/openspec/changes/publish-lambda-layer/proposal.md b/openspec/changes/publish-lambda-layer/proposal.md new file mode 100644 index 0000000..c54efae --- /dev/null +++ b/openspec/changes/publish-lambda-layer/proposal.md @@ -0,0 +1,66 @@ +# Proposal: Publish a Lambda Layer + +## Why + +Users of `cfn-handler` write AWS Lambda functions that import the library. Today they install it via `pip install cfn-handler` baked into their function package (vendored), or via SAM/CDK build hooks that install it during deploy. Both work but add complexity to the user's deploy pipeline and inflate the function package size by ~20 KB on every function. + +A published Lambda Layer is the idiomatic alternative: the maintainer publishes one `cfn-handler` layer per release with public read access; users reference an ARN in their function definition, and the library is mounted at `/opt/python/` at runtime. No vendoring, no build hooks, smaller deploy packages. + +`aws-lambda-powertools-python-layer` is the reference implementation in this ecosystem, and most AWS Python Lambda libraries either ship layers or get asked to. We have an AWS account ready and an OIDC trust setup that GitHub Actions can use to deploy across all commercial regions. + +## What Changes + +### Distribution (user-facing) + +- **NEW** Lambda Layer ZIP (`cfn_handler-X.Y.Z-layer.zip`) attached to every GitHub Release alongside the wheel and sdist. Pure-Python, zero-dep, layout-compatible with all supported Python versions and architectures. +- **NEW** Public read-access Lambda Layer published to ~30 commercial AWS regions on every release, named `cfn-handler`. Users reference `arn:aws:lambda:::layer:cfn-handler:`. +- **NEW** ARN inventory surfaces for user discovery (no AWS credentials required): + - **Augmented GitHub Release body** — each release's notes are extended with a per-region ARN table immediately after the layer publishes complete. Browsing the GH release at `https://github.com/igorlg/cfn-handler/releases/tag/v` shows every ARN at a glance. + - **`layer-arns.json` release asset** — structured JSON manifest (region → ARN) uploaded as a release asset on every release. Programmatic consumption: `curl -L https://github.com/igorlg/cfn-handler/releases/latest/download/layer-arns.json`. + - **Shields.io badge in `README.md`** — uses GitHub-native release endpoint to render the current Layer version inline with the existing PyPI / Python / License badges. + +### Repository (maintainer-facing) + +- **NEW** `layer/` top-level directory holding all Lambda Layer publishing artifacts: + - `layer/README.md` — consumer-facing: ARN format, SAM/CDK snippets. + - `layer/MAINTAINER.md` — publisher setup: deploying the IAM role, opt-in region handling, what the release pipeline does. + - `layer/iam-publisher.cfn.yaml` — CloudFormation template that creates the OIDC-federated IAM role used by GitHub Actions, scoped to the `layer-publisher` environment of this repo. + - `layer/regions.txt` — canonical list of regions the Layer is published to. One region per line, sorted, comments allowed. Sourced directly by the release.yml matrix. +- **MODIFIED** `.github/workflows/release.yml`: + - New job `build-layer-zip` runs after `release-please` succeeds. Builds the layer ZIP and uploads it to the GitHub Release. + - New job `set-layer-matrix` runs after `release-please` succeeds. Reads `regions.txt` for the matrix region list and derives the Lambda `--compatible-runtimes` argument from `pyproject.toml`'s Python classifiers (single source of truth — bumping the supported Python list edits one place). + - New job `publish-layer` runs after `build-layer-zip` and `set-layer-matrix`. Per-region matrix: assume role via OIDC → publish layer version with derived runtimes → grant public read → upload an ARN-stub artifact for the aggregate step. `fail-fast: false` so a single bad region doesn't block the others. + - New job `aggregate-arns` runs after `publish-layer` completes. Downloads the per-region ARN artifacts, builds `layer-arns.json`, uploads it as a release asset, and edits the GitHub Release body to append a per-region ARN markdown table. +- **NEW** GitHub repository environment `layer-publisher` (created via UI / API as part of the rollout). Holds the `LAYER_PUBLISHER_ROLE_ARN` secret. Bound by the OIDC trust policy. + +### Documentation + +- **MODIFIED** `docs/CI.md` — Workflow inventory adds the new layer jobs (`build-layer-zip`, `publish-layer`, `aggregate-arns`); brief paragraph in the Release pipeline section pointing readers at `layer/README.md` for layer-specific operational detail. +- **MODIFIED** `README.md` — short "Lambda Layer" section under "Installation" introducing the layer option alongside `pip install`; new shields.io badge for current Layer version inline with the existing badges. + +### Non-goals + +- **No** GovCloud (`us-gov-east-1`, `us-gov-west-1`) regions in this iteration. Adds different IAM partition handling and a separate AWS account; deferred to a future change if needed. +- **No** China (`cn-north-1`, `cn-northwest-1`) regions. Different OIDC audience, different account model, deferred. +- **No** SAR app publishing. SAR is a third path users could consume the library through; we already have ZIP and ARN; deferring. +- **No** SLSA L3 provenance attestation on the layer ZIP. The wheel/sdist already publish via PyPI Trusted Publishing OIDC which is the strong supply-chain signal; layer ZIP integrity matches the wheel content (the layer IS the wheel, repackaged). +- **No** canary stack that deploys the layer to a test Lambda after publish. Future addition if releases ever break. +- **No** automatic opt-in for regions like `ap-east-1`, `me-south-1`, `af-south-1` etc. The `regions.txt` file lists explicitly enabled regions; opt-in regions need to be enabled in the AWS account first, then added to `regions.txt`. + +## Capabilities + +### New Capabilities + +- `lambda-layer-publishing`: per-release Lambda Layer build + publish across commercial regions, with public read access and three public ARN discovery surfaces (release-body table, `layer-arns.json` asset, README badge). + +### Modified Capabilities + +None. The `ci-infrastructure` capability covers the test/lint/PyPI-publish pipeline; the layer publish jobs in `release.yml` are a parallel post-release surface that doesn't replace or reshape what's already specified there. + +## Impact + +- **AWS account**: `igorlg`'s dedicated AWS account holds the layer versions. Cost ≈ $0/month (Lambda layers are free in storage). +- **IAM**: one OIDC-federated role assumed by GitHub Actions only when the `layer-publisher` environment is active in `release.yml`. Trust policy scoped to repo + environment. Permissions scoped to layer-version operations on `arn:aws:lambda:*::layer:cfn-handler*`; nothing else. +- **Release time**: ~30 region deploys add ~2-5 minutes wall-clock (parallel matrix; each region's publish is ~5-10 seconds). +- **User-facing**: layer ARNs become available via the GitHub Release (body table + `layer-arns.json` asset). No backward compatibility concern (it's a new product surface, not a change to the existing wheel). +- **Setup work for maintainer (Igor)**: one-time before this change can ship — deploy `layer/iam-publisher.cfn.yaml` to your AWS account, create the `layer-publisher` GitHub environment, save the role ARN to the environment secret. All documented in `layer/MAINTAINER.md`. diff --git a/openspec/changes/publish-lambda-layer/specs/lambda-layer-publishing/spec.md b/openspec/changes/publish-lambda-layer/specs/lambda-layer-publishing/spec.md new file mode 100644 index 0000000..cc6a368 --- /dev/null +++ b/openspec/changes/publish-lambda-layer/specs/lambda-layer-publishing/spec.md @@ -0,0 +1,115 @@ +# Spec: lambda-layer-publishing + +## ADDED Requirements + +### Requirement: Lambda Layer ZIP attached to every GitHub Release + +Every successful release SHALL produce a Lambda Layer ZIP artifact named `cfn_handler--layer.zip` and SHALL upload it to the GitHub Release alongside the wheel and sdist. The ZIP SHALL be a valid Python Lambda Layer: top-level entry `python/`, containing the unpacked wheel contents minus the `*.dist-info/` directory. + +#### Scenario: A new release ships +- **WHEN** the release-please PR is squash-merged and `release-please-action` reports `release_created=true` +- **THEN** a `cfn_handler--layer.zip` artifact appears on the GitHub Release at `https://github.com/igorlg/cfn-handler/releases/tag/v` + +#### Scenario: ZIP layout is layer-compatible +- **WHEN** the layer ZIP is unzipped +- **THEN** the unpacked tree begins with `python/cfn_handler/__init__.py`, mirrors the wheel's `cfn_handler/` package contents, and contains no `*.dist-info/` directory + +#### Scenario: ZIP contents match the wheel +- **WHEN** the layer ZIP and the wheel from the same release are compared +- **THEN** every file under `python/cfn_handler/` in the ZIP is byte-identical to the corresponding file in the wheel (the layer is the wheel, repackaged for Lambda's filesystem layout) + +### Requirement: Per-region Lambda Layer published with public read access + +For every region listed in `layer/regions.txt`, every successful release SHALL publish a new Lambda Layer version named `cfn-handler` to that region and SHALL grant public read access via a layer-version resource policy. + +#### Scenario: Layer published to all regions in `regions.txt` +- **WHEN** a release is created +- **THEN** for every region in `layer/regions.txt` (excluding lines that are blank or start with `#`), a new layer version is published in that region; the published layer's `CompatibleRuntimes` is derived at release time from the `Programming Language :: Python :: 3.X` classifiers in `pyproject.toml` (so the runtime list is always in sync with the package's declared support), and `CompatibleArchitectures` lists both `x86_64` and `arm64` + +#### Scenario: Public read access granted +- **WHEN** the layer has been published in a region +- **THEN** the workflow invokes `lambda:AddLayerVersionPermission` with `Principal='*'`, `Action='lambda:GetLayerVersion'`, `StatementId='PublicRead'`; any AWS principal in any account can subsequently call `aws lambda get-layer-version --layer-name ` against the layer version + +#### Scenario: One region's failure does not block the rest +- **WHEN** the publish in one region fails (transient API error, opt-in not enabled, etc.) +- **THEN** the matrix is configured `fail-fast: false`; the failed region's job reports failure but other regions' jobs proceed and complete normally + +#### Scenario: GovCloud and China regions are NOT included +- **WHEN** `regions.txt` is read +- **THEN** no `us-gov-*` or `cn-*` regions appear (different IAM partition and OIDC audience handling; deferred) + +### Requirement: Public ARN discovery via GitHub Release surfaces + +After every successful release, every per-region ARN published SHALL be discoverable by external users via at least three public surfaces that require no AWS credentials: + +1. The GitHub Release body for `v` SHALL contain a per-region ARN markdown table. The table SHALL be appended to the existing release-please-generated notes by an `aggregate-arns` workflow job after all `publish-layer` matrix entries complete (whether successful or failed). +2. A release asset named `layer-arns.json` SHALL be uploaded to the GitHub Release. Its content SHALL be a JSON object with at minimum: `version` (string), `layer_name` (string, currently `cfn-handler`), and `regions` (object mapping each region name to its ARN string). Failed regions SHALL appear with an explanatory `null` value or be omitted. +3. The README SHALL contain a shields.io badge using the GitHub-native release endpoint (`https://img.shields.io/github/v/release//?label=lambda%20layer`). The badge SHALL link to the latest GitHub Release page. + +#### Scenario: User browses to the GitHub Release for a version +- **WHEN** a user opens `https://github.com/igorlg/cfn-handler/releases/tag/v` in a browser +- **THEN** the release notes contain a "Lambda Layer ARNs" section with a markdown table listing every region that successfully published, alongside its full ARN + +#### Scenario: User fetches the JSON manifest for the latest release +- **WHEN** a user runs `curl -fsSL https://github.com/igorlg/cfn-handler/releases/latest/download/layer-arns.json` +- **THEN** the response is a valid JSON document with `version`, `layer_name`, and `regions` fields populated + +#### Scenario: User pins to a specific version's manifest +- **WHEN** a user runs `curl -fsSL https://github.com/igorlg/cfn-handler/releases/download/v/layer-arns.json` +- **THEN** the response is the manifest for that specific version + +#### Scenario: README badge reflects the latest published version +- **WHEN** a user views the README on GitHub or PyPI +- **THEN** the "lambda layer" badge displays the latest GitHub release tag (e.g. `v1.2.0`); clicking it lands on the release page where the ARN table is visible + +#### Scenario: Aggregate runs even with partial-region failures +- **WHEN** one or more `publish-layer` matrix entries fail +- **THEN** `aggregate-arns` still runs (it has `if: always()`); the GitHub Release body is augmented with the table of regions that DID succeed; failed regions are listed in the notes with their failure mode + +### Requirement: OIDC-federated IAM role for the publisher + +The release pipeline's per-region publish jobs SHALL acquire AWS credentials via OIDC federation from GitHub Actions, NOT via stored long-lived AWS access keys. The trust policy of the assumed role SHALL restrict assumption to GitHub Actions runs originating from the `igorlg/cfn-handler` repository AND the `layer-publisher` environment. + +#### Scenario: Workflow assumes the role +- **WHEN** the `publish-layer` job runs and invokes `aws-actions/configure-aws-credentials` with `role-to-assume: ${{ secrets.LAYER_PUBLISHER_ROLE_ARN }}` while the workflow's `environment:` is `layer-publisher` +- **THEN** AWS STS issues credentials based on the federated OIDC token; the credentials are scoped to the role's permissions + +#### Scenario: A different repo or environment cannot assume the role +- **WHEN** any GitHub Actions workflow that is NOT inside `igorlg/cfn-handler` AND running in the `layer-publisher` environment attempts to assume the role +- **THEN** STS rejects the assumption (`AccessDenied`) because the trust policy's `sub` condition does not match + +### Requirement: Publisher-role permissions are least-privilege + +The IAM role's permissions SHALL be scoped to: +- `lambda:PublishLayerVersion`, `lambda:GetLayerVersion`, `lambda:GetLayerVersionPolicy`, `lambda:AddLayerVersionPermission`, `lambda:RemoveLayerVersionPermission`, `lambda:ListLayerVersions` on `arn:aws:lambda:*::layer:cfn-handler` and `arn:aws:lambda:*::layer:cfn-handler:*` + +The role SHALL NOT have any other permissions; it SHALL NOT be granted broad wildcards like `lambda:*` or `iam:*`. It SHALL NOT have any non-Lambda service permissions (no SSM, no S3, no CloudWatch, etc.). + +#### Scenario: Role attempts a permission outside its allowlist +- **WHEN** any process holding the publisher role's credentials attempts e.g. `iam:CreateUser`, `s3:GetObject`, or `lambda:DeleteFunction` +- **THEN** AWS denies the action; only the permissions enumerated above succeed + +#### Scenario: Role attempts to publish a layer named differently +- **WHEN** the role attempts `lambda:PublishLayerVersion` with `--layer-name something-else` +- **THEN** AWS denies the action; the resource policy ARN does not match `arn:aws:lambda:*::layer:cfn-handler*` + +### Requirement: Repository layout for layer publishing artifacts + +A top-level `layer/` directory SHALL hold every artifact specific to Lambda Layer publishing. The directory SHALL contain at least: + +- `layer/README.md` — consumer-facing usage documentation (ARN format, SAM/CDK snippets) +- `layer/MAINTAINER.md` — publisher operational documentation (deploying the IAM CFN, creating the GitHub environment, opt-in region handling) +- `layer/iam-publisher.cfn.yaml` — CloudFormation template for the OIDC-federated IAM role +- `layer/regions.txt` — newline-delimited region list with `#` comments allowed; sourced directly by the release.yml matrix + +#### Scenario: Adding a region to the publish set +- **WHEN** a maintainer wants to add `eu-south-1` to the publish set +- **THEN** they edit `layer/regions.txt` to include `eu-south-1` on its own line; no other repo file needs to change; the release.yml matrix expansion picks up the new region on the next release + +#### Scenario: Reading consumer documentation +- **WHEN** a user wants to learn how to use the published layer +- **THEN** they read `layer/README.md` and find the ARN format and a working SAM template snippet + +#### Scenario: Setting up the maintainer's AWS account +- **WHEN** a (re-) maintainer wants to set up layer publishing in a new AWS account +- **THEN** they follow `layer/MAINTAINER.md`'s steps in order: deploy the CFN template, create the GitHub environment, save the role ARN as the environment secret; the next release publishes layers automatically diff --git a/openspec/changes/publish-lambda-layer/tasks.md b/openspec/changes/publish-lambda-layer/tasks.md new file mode 100644 index 0000000..1ff249d --- /dev/null +++ b/openspec/changes/publish-lambda-layer/tasks.md @@ -0,0 +1,75 @@ +# Tasks: Publish a Lambda Layer + +## 1. layer/ directory scaffolding + +- [x] 1.1 Create `layer/` top-level directory. +- [x] 1.2 Create `layer/regions.txt` with the 17 default-enabled commercial regions, one per line, sorted alphabetically. Document the `#` comment convention with a header comment in the file. +- [x] 1.3 Create `layer/iam-publisher.cfn.yaml` — a CloudFormation template defining: a `AWS::IAM::OIDCProvider` (idempotent — uses existing if already deployed); a `AWS::IAM::Role` named `cfn-handler-layer-publisher` with trust policy scoped to `repo:igorlg/cfn-handler:environment:layer-publisher`; an inline policy granting only layer-version operations on `arn:aws:lambda:*:${AWS::AccountId}:layer:cfn-handler*`. Output: the role ARN. +- [x] 1.4 Create `layer/MAINTAINER.md` documenting: how to deploy the CFN template (`aws cloudformation deploy --capabilities CAPABILITY_NAMED_IAM`); how to create the `layer-publisher` GitHub environment; how to add the `LAYER_PUBLISHER_ROLE_ARN` secret; how to add a new region (edit `regions.txt`); how to handle opt-in regions (enable the region in the AWS account first, then add to `regions.txt`); how to roll back by deleting the GitHub environment. +- [x] 1.5 Create `layer/README.md` documenting: the ARN format (`arn:aws:lambda:::layer:cfn-handler:`); how to look up the latest ARN via the GitHub Release surfaces (`layer-arns.json` asset, release-body table); a SAM template snippet showing how to attach the layer to a function; a CDK snippet (TypeScript or Python); a note that the ZIP is also attached to every GitHub Release for users who'd rather deploy it in their own account. + +## 2. Workflow YAML changes + +- [x] 2.1 Edit `.github/workflows/release.yml`: add a new job `build-layer-zip` after `release-please`, gated on `release_created == 'true'`. Steps: checkout at the released tag, install uv, build the wheel, extract into `build/python/`, strip `*.dist-info/`, zip as `dist/cfn_handler-${VERSION}-layer.zip`, upload the ZIP to the GitHub Release via `gh release upload`. Job permission: `contents: write` (for `gh release upload`). +- [x] 2.2 Edit `.github/workflows/release.yml`: add a new job `set-layer-matrix` after `release-please`, gated on `release_created == 'true'`. Two outputs: `regions` (parsed from `layer/regions.txt` via shell+jq) and `runtimes` (derived from `pyproject.toml`'s `Programming Language :: Python :: 3.X` classifiers via a small `tomllib` Python script — the package's classifier list is the single source of truth for `--compatible-runtimes`). +- [x] 2.3 Add a new job `publish-layer`, `needs: [release-please, build-layer-zip, set-layer-matrix]`, gated on `release_created == 'true'`. Set `environment: layer-publisher`. Set `permissions: { id-token: write, contents: read }`. Use `strategy.matrix.region: ${{ fromJSON(needs.set-layer-matrix.outputs.regions) }}`. +- [x] 2.4 In `publish-layer`: download the layer ZIP from the GitHub Release; assume the role via `aws-actions/configure-aws-credentials@` with `role-to-assume: ${{ secrets.LAYER_PUBLISHER_ROLE_ARN }}` and `aws-region: ${{ matrix.region }}`; invoke `aws lambda publish-layer-version` with `--layer-name cfn-handler --license-info Apache-2.0 --description 'cfn-handler ${VERSION}' --zip-file fileb://cfn_handler-${VERSION}-layer.zip --compatible-runtimes ${RUNTIMES} --compatible-architectures x86_64 arm64` (where `RUNTIMES` is the space-separated list from the `set-layer-matrix.outputs.runtimes` job output); capture the resulting `LayerVersionArn`. +- [x] 2.5 In `publish-layer`: invoke `aws lambda add-layer-version-permission --layer-name cfn-handler --version-number --statement-id PublicRead --action lambda:GetLayerVersion --principal '*'`. Treat `ResourceConflictException` (statement already exists) as success for idempotent re-runs. +- [x] 2.6 In `publish-layer`: write `arn-${{ matrix.region }}.json` containing `{"region": "${{ matrix.region }}", "arn": ""}` and upload it via `actions/upload-artifact@` with `name: layer-arns-${{ matrix.region }}` for the aggregate step to consume. +- [x] 2.7 Set `strategy.fail-fast: false` on the `publish-layer` matrix so a single bad region does not cancel the rest. +- [x] 2.8 Add a new job `aggregate-arns`, `needs: [release-please, publish-layer]`, gated on `release_created == 'true'`, with `if: always()` so partial-region success still produces an inventory. `permissions: contents: write`. Steps: download all `layer-arns-*` artifacts via `actions/download-artifact`; read each region's JSON; build a consolidated `layer-arns.json` (`{ "version": "${VERSION}", "layer_name": "cfn-handler", "regions": { "us-east-1": "", ... } }`); build a markdown table `arns-table.md` (regions sorted alphabetically; failed regions noted explicitly); upload `layer-arns.json` as a release asset via `gh release upload v${VERSION} layer-arns.json --clobber`; append the markdown table to the release notes via `gh release view v${VERSION} --json body | jq -r .body > existing-notes.md && printf '\n\n## Lambda Layer ARNs\n\n' >> existing-notes.md && cat arns-table.md >> existing-notes.md && gh release edit v${VERSION} --notes-file existing-notes.md`. +- [x] 2.9 SHA-pin every action used in the new jobs (`aws-actions/configure-aws-credentials`, `actions/upload-artifact`, `actions/download-artifact`) with `# vX.Y.Z` comments. `secure-workflows.yml` will re-validate on the PR. + +## 3. Docs + +- [x] 3.1 Update `docs/CI.md` "Workflow inventory" section: `release.yml` row's "Jobs" column gains `build-layer-zip`, `publish-layer (matrix over regions)`, and `aggregate-arns`. +- [x] 3.2 Update `docs/CI.md` "Release pipeline" section: add a brief paragraph describing the layer publish as a parallel post-release surface alongside PyPI publish; explain the three public ARN discovery surfaces (release body table, `layer-arns.json` asset, shields badge); link to `layer/README.md` for usage and `layer/MAINTAINER.md` for operational detail. +- [x] 3.3 Update `README.md` "Installation" section: add a short paragraph introducing the layer option as an alternative to `pip install`; link to `layer/README.md`. Add a shields.io badge for the current Layer version (`https://img.shields.io/github/v/release/igorlg/cfn-handler?label=lambda%20layer&color=ff9900&logo=amazonaws`) inline with the existing PyPI / Python / License badges, linking to the latest GitHub Release page. + +## 4. Local verification (before push) + +- [x] 4.1 `just ci-check` — pure tests, no library code change; sanity check. +- [x] 4.2 `act pull_request -W .github/workflows/release.yml --job build-layer-zip --secret GITHUB_TOKEN="$(gh auth token)"`. Note: `act` cannot replicate the AWS OIDC step, so this exercises only the build, not the publish. +- [x] 4.3 Inspect the layer ZIP locally: `uv build && mkdir -p /tmp/layer-build/python && unzip -q dist/*.whl -d /tmp/layer-build/python && rm -rf /tmp/layer-build/python/*.dist-info && ( cd /tmp/layer-build && zip -qr /tmp/cfn-handler-layer.zip python/ ) && unzip -l /tmp/cfn-handler-layer.zip | head -10`. Confirm: top-level `python/`, `python/cfn_handler/__init__.py`, no `dist-info`. + +## 5. PR open + +- [x] 5.1 Stage all changes; commit with title `feat(layer): publish Lambda Layer to GitHub Releases and all commercial regions`. Note: the `feat:` prefix is correct here — this is a user-facing capability (a new distribution channel). +- [x] 5.2 Branch `feat/lambda-layer-publishing` (already created); push. +- [x] 5.3 `gh pr create` against `main`. PR description: link to `openspec/changes/publish-lambda-layer/proposal.md` and to `layer/MAINTAINER.md`. Highlight the maintainer setup steps Igor must do BEFORE merging. + +## 6. Maintainer one-time setup (manual; gates the merge) + +- [x] 6.1 Igor: `aws cloudformation deploy --stack-name cfn-handler-layer-publisher --template-file layer/iam-publisher.cfn.yaml --capabilities CAPABILITY_NAMED_IAM --region us-east-1` (the role itself is global; pick any region for the stack). Capture the role ARN from `aws cloudformation describe-stacks --stack-name cfn-handler-layer-publisher --query 'Stacks[0].Outputs'`. +- [x] 6.2 Igor: in the GitHub UI for `igorlg/cfn-handler`, Settings → Environments → New environment → name `layer-publisher`. No protection rules (PR-only releases gate it; protection rules would block release-please's bot). +- [x] 6.3 Igor: in the `layer-publisher` environment, add a secret named `LAYER_PUBLISHER_ROLE_ARN` with the ARN value from 6.1. +- [x] 6.4 Igor: confirm the OIDC provider exists in the AWS account: `aws iam list-open-id-connect-providers`. If the CFN deployed it (first-time setup), it's there. If a previous OIDC provider already existed, the CFN template imports it cleanly. +- [ ] 6.5 Igor: **(after merging this PR)** re-deploy the CFN stack to drop the now-unused SSM IAM policy: `aws cloudformation deploy --stack-name cfn-handler-layer-publisher --template-file layer/iam-publisher.cfn.yaml --capabilities CAPABILITY_NAMED_IAM --region us-east-1` (idempotent; the diff is just removing the SSM `WriteCfnHandlerSsmParameters` policy). Verify with `aws iam get-role-policy --role-name cfn-handler-layer-publisher --policy-name WriteCfnHandlerSsmParameters` returns `NoSuchEntity`. + +## 7. Cloud CI on the PR + +- [x] 7.1 Watch `secure-workflows.yml` re-validate the new SHAs and report SUCCESS. +- [x] 7.2 Watch `ci.yml` matrix + lint pass (no library changes; should be green). +- [x] 7.3 The new `build-layer-zip` and `publish-layer` jobs do NOT run on PRs — they're gated on `release_created == 'true'`, which only happens after release-please's PR merges. Document this in the PR description so reviewers don't expect to see them. + +## 8. Merge + first release + +- [ ] 8.1 Squash-merge the PR. Title format: `feat(layer): publish Lambda Layer ...`. The `feat:` triggers a minor bump in the next release-please PR. +- [ ] 8.2 Merge the resulting release-please PR. Watch `release.yml` end-to-end: + - `release-please` ✓ + - `publish-artifacts` ✓ (existing wheel/sdist + new layer ZIP attached to GH Release) + - `publish-pypi` ✓ (existing PyPI publish) + - `build-layer-zip` ✓ (new) + - `publish-layer` × 17 regions, all ✓ (new; `fail-fast: false` so partial failure is tolerated) + - `aggregate-arns` ✓ (new; runs `if: always()`; uploads `layer-arns.json` and edits release body) +- [ ] 8.3 Verify a published layer: `aws lambda get-layer-version --layer-name cfn-handler --version-number 1 --region us-east-1` returns the layer; `aws lambda get-layer-version-policy --layer-name cfn-handler --version-number 1 --region us-east-1` shows the public read grant. +- [ ] 8.4 Verify the public discovery surfaces: + - GH Release body at `https://github.com/igorlg/cfn-handler/releases/tag/v` shows the "Lambda Layer ARNs" table. + - `curl -fsSL https://github.com/igorlg/cfn-handler/releases/latest/download/layer-arns.json` returns valid JSON with `version`, `layer_name`, `regions` keys. + - README badge on the GitHub repo page renders the Layer version label correctly. +- [ ] 8.5 Smoke test from a fresh AWS principal (any account): `aws lambda get-layer-version --layer-name --region us-east-1` succeeds without `AccessDenied`. Confirms public read. + +## 9. Validate + archive + +- [x] 9.1 `openspec validate publish-lambda-layer --strict` passes before merging the PR. +- [ ] 9.2 After PR + first release ship: `openspec archive publish-lambda-layer`. The new requirements merge into a fresh `openspec/specs/lambda-layer-publishing/spec.md`.