chore(openspec): archive add-testing-helpers (#26) #35
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: release | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| jobs: | |
| release-please: | |
| name: release-please bot | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| contents: write # create tags + GH releases | |
| pull-requests: write # open / update the release PR | |
| outputs: | |
| release_created: ${{ steps.release.outputs.release_created }} | |
| tag_name: ${{ steps.release.outputs.tag_name }} | |
| steps: | |
| # Mint a short-lived installation token from the dedicated GitHub App. | |
| # Why: workflow runs created via the default GITHUB_TOKEN are | |
| # deliberately blocked by GitHub from triggering downstream workflow | |
| # runs (anti-recursion). That meant release-please's release PRs | |
| # never had `pull_request` checks run against them, blocking merges | |
| # behind required status checks. Using a GitHub App's installation | |
| # token sidesteps the recursion-guard: PRs opened by the App | |
| # trigger workflows normally. | |
| # | |
| # The token is scoped to this repository, expires in ~1 hour, and | |
| # is fresh on every workflow run (no long-lived secret stored). The | |
| # App's private key is the only credential at rest; rotating it is | |
| # a one-line `gh secret set` away. | |
| # | |
| # See layer/MAINTAINER.md (or docs/CI.md) for App setup steps. | |
| - name: Mint App installation token | |
| id: app-token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 | |
| with: | |
| app-id: ${{ vars.RELEASE_PLEASE_APP_ID }} | |
| private-key: ${{ secrets.RELEASE_PLEASE_PRIVATE_KEY }} | |
| - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4 | |
| id: release | |
| with: | |
| config-file: release-please-config.json | |
| manifest-file: .release-please-manifest.json | |
| token: ${{ steps.app-token.outputs.token }} | |
| # Build the wheel + sdist exactly once and share via a workflow artifact. | |
| # Three downstream jobs consume it (publish-artifacts, publish-pypi, | |
| # build-layer-zip). Building once means: | |
| # * the wheel uploaded to PyPI is byte-identical to the wheel attached | |
| # to the GitHub Release and to the wheel repackaged as the Lambda | |
| # Layer — no "should be deterministic" hand-wave; | |
| # * one setup-uv invocation, not three, so the matrix doesn't race for | |
| # the same uv-cache key (avoids the cache-reservation warning); | |
| # * roughly halves the total release wall-clock spent on `uv build`. | |
| build-artifacts: | |
| name: build wheel + sdist | |
| needs: release-please | |
| if: ${{ needs.release-please.outputs.release_created == 'true' }} | |
| runs-on: ubuntu-24.04 | |
| 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 + sdist | |
| run: uv build | |
| - name: Upload dist/ as workflow artifact | |
| uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | |
| with: | |
| name: dist | |
| path: dist/ | |
| if-no-files-found: error | |
| retention-days: 7 | |
| publish-artifacts: | |
| name: attach release artifacts to GitHub Release | |
| needs: [release-please, build-artifacts] | |
| if: ${{ needs.release-please.outputs.release_created == 'true' }} | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Download dist/ from build-artifacts | |
| uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 | |
| with: | |
| name: dist | |
| path: dist/ | |
| - name: Upload artifacts to GitHub Release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh release upload "${{ needs.release-please.outputs.tag_name }}" dist/* \ | |
| --repo "${{ github.repository }}" \ | |
| --clobber | |
| publish-pypi: | |
| name: publish to PyPI | |
| needs: [release-please, build-artifacts] | |
| if: ${{ needs.release-please.outputs.release_created == 'true' }} | |
| runs-on: ubuntu-24.04 | |
| # PyPI Trusted Publishing — see .github/CONTRIBUTING.md for one-time setup. | |
| # The Trusted Publisher must be configured at: | |
| # https://pypi.org/manage/project/cfn-handler/settings/publishing/ | |
| # with: repo=igorlg/cfn-handler, workflow=release.yml, environment=pypi. | |
| environment: | |
| name: pypi | |
| url: https://pypi.org/p/cfn-handler | |
| permissions: | |
| id-token: write # OIDC for trusted publishing | |
| contents: read | |
| steps: | |
| - name: Download dist/ from build-artifacts | |
| uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 | |
| with: | |
| name: dist | |
| path: dist/ | |
| - 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: download the shared `dist/` artifact from | |
| # build-artifacts, repackage the wheel as a Lambda | |
| # 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, build-artifacts] | |
| if: ${{ needs.release-please.outputs.release_created == 'true' }} | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| contents: write # for `gh release upload` | |
| steps: | |
| - name: Download dist/ from build-artifacts | |
| uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 | |
| with: | |
| name: dist | |
| path: dist/ | |
| - 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-<region>.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 |