Skip to content

chore(openspec): archive add-testing-helpers (#26) #35

chore(openspec): archive add-testing-helpers (#26)

chore(openspec): archive add-testing-helpers (#26) #35

Workflow file for this run

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