Skip to content

feat(layer): publish Lambda Layer to GitHub Releases and ~17 commercial regions - #15

Merged
igorlg merged 7 commits into
mainfrom
feat/lambda-layer-publishing
May 21, 2026
Merged

feat(layer): publish Lambda Layer to GitHub Releases and ~17 commercial regions#15
igorlg merged 7 commits into
mainfrom
feat/lambda-layer-publishing

Conversation

@igorlg

@igorlg igorlg commented May 21, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the publish-lambda-layer openspec change. Three coordinated additions:

  1. Layer ZIP attached to every GitHub Releasecfn_handler-<version>-layer.zip is the wheel repackaged for Lambda (python/cfn_handler/... layout, no dist-info). Users who want to deploy the layer themselves just download the ZIP.

  2. Public Lambda Layer published across ~17 commercial regions — every release calls aws lambda publish-layer-version per region with Principal=* public read; users reference the resulting ARN. fail-fast: false so a single bad region doesnt block the rest.

  3. Three public ARN discovery surfaces — no AWS credentials needed:

    • GitHub Release body augmented with a per-region ARN markdown table (auto-generated by aggregate-arns job).
    • layer-arns.json release asset for programmatic use (curl …/releases/latest/download/layer-arns.json).
    • shields.io badge for the current Layer version inline with the existing PyPI / Python / License badges.

OpenSpec change

openspec/changes/publish-lambda-layer/ — proposal, design, spec, tasks. New capability lambda-layer-publishing. Validated --strict.

What landed

File Change
layer/regions.txt 17 commercial regions, sorted, # comments allowed
layer/iam-publisher.cfn.yaml CloudFormation: OIDC provider + scoped IAM role
layer/MAINTAINER.md Setup guide (deploy CFN, create env, save secret, add regions)
layer/README.md Consumer guide (ARN format, three discovery surfaces, SAM/CDK snippets)
.github/workflows/release.yml 4 new jobs: build-layer-zip, set-layer-matrix, publish-layer (matrix), aggregate-arns
docs/CI.md Workflow inventory + release-pipeline diagram updated
README.md New lambda layer badge + "Or use the AWS Lambda Layer" subsection

Maintainer setup BEFORE merging

The new jobs run after release-please reports release_created=true, so this PRs CI does NOT exercise the AWS-facing path. But a future release would fail without:

  1. Deploy layer/iam-publisher.cfn.yaml to your AWS account:

    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 your account already has the GitHub Actions OIDC provider (from another project), pass --parameter-overrides CreateOidcProvider=false.

  2. Capture the role ARN:

    aws cloudformation describe-stacks \
      --stack-name cfn-handler-layer-publisher --region us-east-1 \
      --query "Stacks[0].Outputs[?OutputKey==\`RoleArn\`].OutputValue" --output text
  3. Create GitHub environment layer-publisher in igorlg/cfn-handler (Settings → Environments). No protection rules — they would block the bot-driven release.

  4. Save the role ARN as the LAYER_PUBLISHER_ROLE_ARN secret inside the layer-publisher environment (NOT at the repo level).

Full walkthrough in layer/MAINTAINER.md.

What runs on this PRs CI

The 4 new jobs are gated on release_created=true, so PR CI exercises only:

  • CI passed (test matrix + lint)
  • analyze (python) (CodeQL)
  • review dependencies
  • ensure SHA-pinned actions (validates the new action SHAs we added)

secure-workflows.yml will re-validate the new SHAs:

  • aws-actions/configure-aws-credentials@00943011d9042930efac3dcd3a170e4273319bc8 # v5.1.0
  • actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
  • actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0

Local verification

  • just ci-check ✓ — 103 passed, 99.48% coverage
  • uv run cfn-lint layer/iam-publisher.cfn.yaml ✓ — clean
  • Built layer ZIP locally via the same workflow steps; confirmed contents match the wheel sans dist-info, layout is python/cfn_handler/...
  • actionlint .github/workflows/release.yml ✓ — clean (one info-level shellcheck false positive on literal markdown backticks; harmless)
  • openspec validate publish-lambda-layer --strict

After merge

The next release-please PR (whatever feat/fix commits accumulate) will trigger the new pipeline end-to-end. Watch the release.yml run for ~17 region publishes + the aggregate. Verify a layer via aws lambda get-layer-version --layer-name cfn-handler --version-number 1 --region us-east-1 from any AWS account; verify public read.

Non-goals

  • GovCloud / China regions (different IAM partition + OIDC audience; deferred).
  • SAR app publishing (different distribution path; deferred).
  • SLSA L3 provenance on the Layer ZIP (wheel already covers attestation).
  • Canary stack post-publish (future addition if releases ever break).

igorlg added 3 commits May 21, 2026 17:00
Adds a new openspec change for publishing a Lambda Layer to GitHub
Releases (a ZIP artifact) AND across all default-enabled commercial
AWS regions (~17 today; opt-in regions added later by editing
layer/regions.txt).

New capability: lambda-layer-publishing

Key design decisions captured in design.md:

- One universal layer ZIP per release (pure Python zero-deps; works
  across all CompatibleRuntimes [py3.10..3.14] and Architectures
  [x86_64, arm64]).
- All layer artifacts under a top-level layer/ directory:
  layer/README.md (consumer), layer/MAINTAINER.md (publisher),
  layer/iam-publisher.cfn.yaml (CFN for OIDC role),
  layer/regions.txt (single source of truth for region matrix).
- OIDC-federated IAM role; trust policy scoped to
  repo:igorlg/cfn-handler:environment:layer-publisher.
- Permissions least-privilege: lambda:*LayerVersion* on
  arn:aws:lambda:*:<account>:layer:cfn-handler*; ssm:*Parameter*
  on /cfn-handler/*.
- Per-region SSM parameter for ARN discovery:
  /cfn-handler/<region>/layer-arn/latest and .../v<version>.
- Public read access via lambda:AddLayerVersionPermission with
  Principal='*'.
- fail-fast: false on the publish matrix so a single bad region
  doesnt block the rest.

Tasks.md walks through the full implementation including the
maintainer one-time setup steps that gate the PR merge (deploy CFN,
create GitHub environment, save secret).

Validated --strict; ready for /opsx-apply.
…lds badge

Two enhancements to the openspec change before applying, per Igor's feedback:

1. ARN inventory — three public discovery surfaces:
   - GitHub Release body augmentation: aggregate-arns job appends a
     per-region ARN markdown table to the release notes.
   - layer-arns.json release asset: structured JSON manifest
     (version, layer_name, regions) uploaded to the release.
     Fetchable at predictable URL via gh release / curl.
   - shields.io badge: GitHub-native release endpoint renders current
     Layer version inline with PyPI / Python / License badges in
     README; AWS-orange (#ff9900); links to latest release page.

2. SSM parameters reframed as maintainer-operational, NOT user-
   facing. Users in other AWS accounts cannot read SSM in the
   maintainer's account without cross-account sharing infra (out of
   scope). The public surfaces above cover the user need.

Workflow change: new aggregate-arns job after publish-layer matrix.
Per-region jobs upload an arn-<region>.json artifact; aggregate
downloads all, builds the consolidated JSON + markdown table,
uploads asset, edits release body. Runs if: always() so partial-
region success still produces an inventory.

design.md grows D10 (ARN discovery surfaces) and refines D4 (SSM
operational role). spec.md adds requirement 'Public ARN discovery
via GitHub Release surfaces' with 5 scenarios. tasks.md expands
section 2 with new aggregate task (2.8) and the per-region upload
artifact task (2.6); section 8 verification adds public-surface
checks (8.5).

Validated --strict; ready for /opsx-apply.
…al regions

Implements the publish-lambda-layer openspec change. New top-level
layer/ directory holds the publishing infrastructure:

- layer/regions.txt — canonical region list (17 commercial regions
  enabled by default in any AWS account; opt-in regions added later
  by editing the file).
- layer/iam-publisher.cfn.yaml — CloudFormation template creating the
  OIDC-federated IAM role assumed by GitHub Actions during release.yml.
  Trust scoped to repo + 'layer-publisher' environment; permissions
  scoped to cfn-handler* layers and /cfn-handler/* SSM parameters.
- layer/MAINTAINER.md — operational guide for the AWS-side setup
  (deploy CFN, create GitHub environment, save secret, add regions,
  troubleshoot).
- layer/README.md — consumer-facing guide (ARN format, three discovery
  surfaces, SAM/CDK snippets, deploy-it-yourself path).

release.yml gains four new jobs, all gated on
release_created == 'true':

- build-layer-zip: builds the wheel and repackages it into a Lambda-
  layer-compatible ZIP (top-level python/cfn_handler/, no dist-info).
  Uploads to GH Release.
- set-layer-matrix: reads layer/regions.txt and emits a JSON array
  matrix output for the publish job.
- publish-layer: matrix over regions; uses environment 'layer-publisher'
  with secrets.LAYER_PUBLISHER_ROLE_ARN; assume role via OIDC; publish
  layer version; grant public read; write SSM parameters
  (/cfn-handler/<region>/layer-arn/{latest, <tag>}); upload per-region
  ARN artifact for aggregate. fail-fast: false; max-parallel: 10.
- aggregate-arns: if: always(); downloads per-region artifacts; builds
  layer-arns.json (uploaded as a release asset); appends a per-region
  ARN markdown table to the GitHub Release notes idempotently.

User-facing ARN discovery (no AWS credentials needed):
1. GitHub Release body markdown table (browser).
2. layer-arns.json release asset (programmatic;
   curl https://github.com/igorlg/cfn-handler/releases/latest/download/layer-arns.json).
3. shields.io badge in README using GitHub-native release endpoint
   (renders current Layer version inline with PyPI / Python / License).

SSM parameters live in the maintainer's account; they're an
operational record, NOT user-facing (cross-account SSM access requires
extra setup we're not doing).

docs/CI.md: workflow inventory updated; release-pipeline section
expanded with the layer publishing diagram + the three discovery
surfaces; cross-links to layer/README.md and layer/MAINTAINER.md.
README.md: new Lambda Layer badge in the badge row; new 'Or use the
AWS Lambda Layer' subsection under Installation.

Verified locally:
- just ci-check: 103 passed, 99.48% coverage.
- uv run cfn-lint layer/iam-publisher.cfn.yaml: clean.
- Built the layer ZIP via the same steps the workflow uses; confirmed
  ZIP contents match the wheel (sans dist-info) and lay out under
  python/cfn_handler/.
- actionlint .github/workflows/release.yml: clean (one info-level
  shellcheck false positive on literal markdown backticks; harmless).
- openspec validate publish-lambda-layer --strict: valid.

Maintainer setup BEFORE merging this PR (per layer/MAINTAINER.md):
1. Deploy layer/iam-publisher.cfn.yaml in the AWS account.
2. Create the 'layer-publisher' GitHub environment in
   igorlg/cfn-handler (no protection rules).
3. Save the role ARN as the LAYER_PUBLISHER_ROLE_ARN secret of that
   environment.
The new jobs only run after release-please's PR merges (release_created
true), so this PR's CI does not require the AWS setup to pass.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 99bacf4ef6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/release.yml Outdated
Comment thread .github/workflows/release.yml Outdated
igorlg added 4 commits May 21, 2026 19:50
When walking through the maintainer setup with a real account, two
ergonomics gaps surfaced:

1. The GitHub-environment + secret steps were UI-only; the gh CLI
   does both ('gh api -X PUT /repos/.../environments/<name>' for the
   environment, 'gh secret set --env <name>' for the secret). Both
   are now documented as the recommended path with the UI as
   fallback.

2. AWS commands assumed default credentials. Most maintainers use
   named profiles ('--profile personal-opensource' etc.); added a
   note + a snippet for checking the OIDC provider's prior
   existence.
Status: 31/38 tasks complete. Remaining are post-merge work:
  8.1-8.6: merge + first-release verification (waits on Igor merging
           PR #15 and the subsequent release-please PR)
  9.2:     archive the change once it's shipped
Two design simplifications surfaced during PR review:

1. Hard-coded --compatible-runtimes was duplicating the supported-Python
   list that already lives in pyproject.toml's classifiers. Add a new
   set-layer-matrix.outputs.runtimes derivation step that reads the
   classifiers via tomllib and emits 'python3.10 python3.11 ...'. The
   publish-layer step consumes this via env. Single source of truth;
   bumping a Python version edits one file.

2. SSM Parameter Store does not support cross-account public read, so
   the per-region /cfn-handler/<region>/layer-arn/{latest,vX.Y.Z}
   parameters were not actually a user-facing surface. The same ARN
   info is already public via three other paths (release-body table,
   layer-arns.json release asset, lambda:GetLayerVersion direct query).
   Drop the SSM put-parameter calls from the workflow and the
   WriteCfnHandlerSsmParameters policy from the IAM CFN template.

Maintainer follow-up (task 6.5): re-deploy the IAM CFN stack post-merge
to remove the now-unused SSM policy from the live role:

  aws cloudformation deploy \
    --stack-name cfn-handler-layer-publisher \
    --template-file layer/iam-publisher.cfn.yaml \
    --capabilities CAPABILITY_NAMED_IAM \
    --region us-east-1

Verify: aws iam get-role-policy \
  --role-name cfn-handler-layer-publisher \
  --policy-name WriteCfnHandlerSsmParameters
returns NoSuchEntity.

OpenSpec change updated (proposal/design/spec/tasks); validates --strict.
All 103 tests pass; coverage 99.48%; ruff/cfn-lint clean.
… P1)

The previous `|| echo "(PublicRead already granted; idempotent)"`
swallowed every `add-layer-version-permission` failure, not just the
intended duplicate-statement case. A ThrottlingException, AccessDenied,
or any transient service error would let the job report success while
leaving the published layer without its public read grant — silently
breaking the main user-facing API (other accounts get AccessDenied
when calling `get-layer-version` against the ARN).

Replaced with explicit error inspection: capture stderr, check exit
code, swallow only when the error message contains
`ResourceConflictException`, fail loudly on any other error.

Verified with three local bash test cases (success / conflict /
throttle); the throttle case now exits 1 as required.

Refs: PR #15 review by chatgpt-codex-connector[bot] (P1).
@igorlg
igorlg merged commit 88321d0 into main May 21, 2026
16 checks passed
@igorlg
igorlg deleted the feat/lambda-layer-publishing branch May 21, 2026 10:20
igorlg added a commit that referenced this pull request May 21, 2026
All 38 tasks complete:
  * 1-7 (scaffolding, workflow, docs, local + cloud verification)
  * 8.1-8.5 (merge, first release, post-release validation including
    real Lambda invoke from cross-account principal — see PR #15
    history; layer SHA-256 byte-identical us-east-1 == ap-southeast-2
    == GH Release ZIP)
  * 9.1 (openspec validate --strict pre-merge)
  * 6.5 (CFN redeployed to drop unused SSM IAM policy from live role
    cfn-handler-layer-publisher)

The new `lambda-layer-publishing` capability moves into the baseline
specs at openspec/specs/lambda-layer-publishing/spec.md (6 added
requirements). The change directory is preserved at
openspec/changes/archive/2026-05-21-publish-lambda-layer/ for
historical reference.

Also fills in the spec's Purpose section (the archive command leaves
a TBD placeholder by default).
igorlg added a commit that referenced this pull request May 22, 2026
Move `ReplayRequestType` and `ReplayStatus` into the
`if TYPE_CHECKING:` block. They were imported at module scope but only
referenced in annotations (resolved as strings via
`from __future__ import annotations`) and in a string-form
`cast("ReplayRequestType", ...)` call (per ruff's TC006 rule, which
prefers the string form to keep type-only symbols out of the runtime
import graph). CodeQL's py/unused-import sees the runtime import and
the string-only references, and reasonably concludes the import is
unused.

Cleaner resolution: keep the imports type-only, expand the explanatory
comment so future readers see why both static analyzers (CodeQL +
ruff TC006) end up happy with this shape.

The two CodeQL py/cyclic-import alerts on the surrounding
`if TYPE_CHECKING:` block remain false positives — CodeQL does not
model conditional imports, and the runtime cycle is broken by the
lazy import in `CustomResource.replay` (resource.py:354). Both have
been dismissed in the GitHub UI with rationale (alerts #15, #16).
igorlg added a commit that referenced this pull request May 22, 2026
Move `ReplayRequestType` and `ReplayStatus` into the
`if TYPE_CHECKING:` block. They were imported at module scope but only
referenced in annotations (resolved as strings via
`from __future__ import annotations`) and in a string-form
`cast("ReplayRequestType", ...)` call (per ruff's TC006 rule, which
prefers the string form to keep type-only symbols out of the runtime
import graph). CodeQL's py/unused-import sees the runtime import and
the string-only references, and reasonably concludes the import is
unused.

Cleaner resolution: keep the imports type-only, expand the explanatory
comment so future readers see why both static analyzers (CodeQL +
ruff TC006) end up happy with this shape.

The two CodeQL py/cyclic-import alerts on the surrounding
`if TYPE_CHECKING:` block remain false positives — CodeQL does not
model conditional imports, and the runtime cycle is broken by the
lazy import in `CustomResource.replay` (resource.py:354). Both have
been dismissed in the GitHub UI with rationale (alerts #15, #16).
igorlg added a commit that referenced this pull request May 22, 2026
Move `ReplayRequestType` and `ReplayStatus` into the
`if TYPE_CHECKING:` block. They were imported at module scope but only
referenced in annotations (resolved as strings via
`from __future__ import annotations`) and in a string-form
`cast("ReplayRequestType", ...)` call (per ruff's TC006 rule, which
prefers the string form to keep type-only symbols out of the runtime
import graph). CodeQL's py/unused-import sees the runtime import and
the string-only references, and reasonably concludes the import is
unused.

Cleaner resolution: keep the imports type-only, expand the explanatory
comment so future readers see why both static analyzers (CodeQL +
ruff TC006) end up happy with this shape.

The two CodeQL py/cyclic-import alerts on the surrounding
`if TYPE_CHECKING:` block remain false positives — CodeQL does not
model conditional imports, and the runtime cycle is broken by the
lazy import in `CustomResource.replay` (resource.py:354). Both have
been dismissed in the GitHub UI with rationale (alerts #15, #16).
igorlg added a commit that referenced this pull request May 22, 2026
…rs (#24)

* chore(openspec): propose v1.3 testing helpers + roadmap

Adds the OpenSpec change `add-testing-helpers` documenting:

  * proposal.md - rationale, scope (replay(), Replay, factories,
    assertions, pytest fixtures), explicit non-goals (no integration
    helpers, no fluent builders, no async support), and the
    soft-deprecation path for the existing `test_mode` flag.
  * design.md - architecture, the dispatch flow diagram, eight key
    decisions each with at least one rejected alternative, risks,
    and open questions reserved for the implementation phase.
  * specs/testing-helpers/spec.md - 9 ADDED requirements with ~25
    scenarios covering the full public contract.
  * tasks.md - 13 phases / 43 verifiable tasks in TDD order.

Adds `docs/ROADMAP.md` capturing the wider library-surface evolution
discussion: testing helpers (this change), better logging, optional
idempotency module, typed events for v2.0, and the cfn-lint plugin
parallel-track work. The Decision Log section records explicit non-goals
(CDK construct, SFN polling, CFN macros, async handlers) with rationale.

This commit lands the plan only - no implementation. Subsequent commits
in this PR implement it, ending with the test-suite migration.

* feat(testing): add cfn_handler.testing module with replay() and helpers

Introduces a public testing surface so users can unit-test custom-resource
handlers without HTTP, without boto3, and without reaching into
`cfn_handler._internal/`.

Public API (`cfn_handler.testing`):

  * `Replay` - frozen dataclass capturing the dispatch outcome
    (status / data / reason / no_echo / payload / request_type /
    physical_resource_id). The `status` field is one of
    "SUCCESS" / "FAILED" / "DEFERRED"; the last is a replay-only
    sentinel signalling "would have entered polling".
  * `CustomResource.replay(event, context=None)` - drives the full
    dispatch pipeline in-process using internal seams to capture the
    response payload. No HTTP, no boto3 import. Polling is stubbed:
    a deferred replay mutates the event with marker keys and returns
    `Replay(status="DEFERRED")`; a follow-up `replay()` with the
    mutated event correctly resumes through the poll handler.
  * `make_event(...)` / `make_context(...)` - factories with safe
    defaults (RFC 6761 `example.invalid`, AWS-reserved 111111111111
    account ID). `make_event` enforces `physical_resource_id` for
    Update/Delete events.
  * `assert_success` / `assert_failed` / `assert_deferred` -
    pytest-style assertion helpers with informative AssertionError
    messages on mismatch.
  * pytest fixtures `cfn_create_event`, `cfn_update_event`,
    `cfn_delete_event`, `cfn_lambda_context` - auto-discovered via
    a new `pytest11` entry point. No `pytest_plugins` declaration
    required in user conftest.

Internal seams (private API, used by replay() but also available to
power users via the constructor kwargs `transport=`,
`provision_poller=`, `teardown_poller=`):

  * `Transport` - `Callable[[str, dict], None]` replacing the default
    urllib PUT (`send_response`). Late-bound default lookup so existing
    tests that `patch("cfn_handler.resource.send_response")` continue
    to work.
  * `PollerProvision` / `PollerTeardown` - mirror seams for the
    boto3-using polling provisioning/teardown calls. Late-bound for
    backwards compatibility with existing patches.
  * `CustomResource._replay_seams(...)` - context manager that swaps
    all three seams atomically and restores them on exit (including
    exceptional return). Used by the runner; not part of the public
    API contract.

CI / tooling:

  * `pyproject.toml`: registers the `pytest11` entry point.
  * `justfile` + `.github/workflows/ci.yml`: switch `test-cov` from
    `pytest --cov` to `coverage run -m pytest`. The pytest11 entry
    point causes pytest to import `cfn_handler.testing.fixtures` (and
    transitively `cfn_handler`) during plugin collection, BEFORE the
    pytest-cov instrumentation hooks attach. Module-level code in
    `__init__.py` then runs uninstrumented and the report shows
    artificial 0% on those lines, dropping aggregate coverage to ~68%.
    `coverage run` ensures the tracer is active before any imports.
    Documented in both the recipe and the workflow step.

Tests added:

  * `tests/unit/test_transport_seam.py` - the seam intercepts; default
    behaviour preserved.
  * `tests/unit/test_poller_seam.py` - poller stubs work; boto3 is
    never imported during a stubbed deferral.
  * `tests/unit/testing/test_replay.py` - SUCCESS/FAILED replays;
    `Replay` is frozen; no HTTP I/O; default context fallback.
  * `tests/unit/testing/test_replay_polling.py` - DEFERRED status,
    event mutation, two-step deferral->resume flow.
  * `tests/unit/testing/test_factories.py` - make_event /
    make_context with overrides + validation.
  * `tests/unit/testing/test_assertions.py` - all helpers, both
    pass and fail paths, message contents on failure.
  * `tests/integration/test_replay_parity.py` - same handler via
    `__call__` (moto + fake transport) vs `replay()` produce
    equivalent payloads.
  * `tests/integration/test_fixture_discovery.py` - pytest11 entry
    point auto-discovers fixtures in a fresh subprocess project,
    fixture invocations are independent.

No breaking changes. The legacy `test_mode` flag continues to work.
The deprecation of `test_mode` and `last_response` lands in the next
commit.

* feat(resource): deprecate test_mode and last_response in favour of replay()

The legacy `test_mode=True` constructor flag and `last_response` capture
attribute are superseded by the new `replay()` method shipped in the
previous commit. They had known issues:

  * Mutable state on the resource (tests must reset `last_response`
    between assertions or risk false positives).
  * Sentinel string `__cfn_handler_polling__` for the polling-defer
    case lives on the public `last_response` surface with no type
    or documentation guarantees.
  * Polling re-invocation can't be tested: `test_mode` short-circuits
    `setup_polling` entirely, so the marker keys never get added to
    the event and a follow-up dispatch can't be simulated.

This commit:

  * Emits a `DeprecationWarning` from `CustomResource.__init__` when
    `test_mode=True`, with a message pointing at `replay()` and the
    `cfn_handler.testing` module. `stacklevel=2` so the warning
    surfaces at the user's call site.
  * Updates the docstrings on `test_mode` (constructor parameter) and
    `last_response` (instance attribute) to mark them as deprecated
    and reference `replay()`.
  * Adds `tests/unit/test_test_mode_deprecation.py` covering the
    warning, that `test_mode=False` does NOT warn, and that the
    legacy behaviour still functions verbatim (so existing user code
    keeps working in v1.x).

Removal is scheduled for v2.0 (separate change). The behaviour is
unchanged in v1.3 - users see only the warning. The internal test
suite migrates onto `replay()` in the next commit; until that lands
the project's own pytest `filterwarnings` would promote the warning
to an error in tests that still use `test_mode=True`. To avoid
artificial failures during the migration window, that single warning
is filtered to `ignore` in pyproject.toml's `[tool.pytest.ini_options]
.filterwarnings` block - reverted in the migration commit.

* test: migrate existing test suite from test_mode to replay()

Bulk migration of the 100+ pre-existing references to
`CustomResource(test_mode=True)` and `resource.last_response` over to
the new `replay()` API. Verbatim translation:

  CustomResource(test_mode=True)             → CustomResource()
  resource(event, ctx)                        → replay = resource.replay(event, ctx)
  resource.last_response["Status"] == "X"     → replay.status == "X"
  resource.last_response["Data"]              → replay.data
  resource.last_response["Reason"]            → replay.reason
  resource.last_response["PhysicalResourceId"] → replay.physical_resource_id
  resource.last_response["NoEcho"]            → replay.no_echo

Where the assertion shape matches, the migration uses the helpers
`assert_success(replay, data=...)` / `assert_failed(replay,
reason_contains=...)` for clearer intent and informative error
messages.

Files migrated:

  * tests/unit/test_resource.py - decorator registration tests,
    lifecycle dispatch, exception handling, PhysicalResourceId
    semantics, init_failure, log_level acceptance.
  * tests/unit/test_backstops.py - the lone test_mode-specific test
    (`test_safe_teardown_skipped_in_test_mode`) was moved to
    `test_test_mode_deprecation.py` since it tests deprecated
    behaviour we keep working until v2.0.
  * tests/unit/test_polling_dispatch.py - the
    `__cfn_handler_polling__` sentinel test (also deprecated-only
    behaviour) was moved to test_test_mode_deprecation.py.
  * tests/unit/test_state_machine.py - hypothesis-driven invariant
    tests, all migrated to inspect `Replay` fields rather than
    `last_response`.

After this commit `grep -rn 'test_mode\|last_response' tests/` returns
hits only from `test_test_mode_deprecation.py` (intentional, testing
the deprecated path) and a single docstring mention in
`test_resource.py`. The temporary `filterwarnings` exception added
in the previous commit is removed - any new test using `test_mode=True`
would now fail the suite (as intended), forcing the test author to use
the new API.

Coverage stays at 98%.

* docs(readme): add Testing section showing replay() workflow

Adds a 'Testing your handlers' section between 'Examples' and
'Project status' that:

  * Shows a minimal one-screen example: build a CustomResource,
    register a handler, call `replay(make_event())`, assert via
    `assert_success`.
  * Lists the public testing surface (`replay`, `make_event`,
    `make_context`, the three assertion helpers, and the four
    auto-discovered pytest fixtures).
  * Calls out the polling-deferral semantics: first `replay()`
    returns `Replay(status='DEFERRED')` and mutates the event;
    a second `replay()` resumes through the poll handler. Useful
    for testing both halves of a polled lifecycle without
    provisioning EventBridge rules.

The detailed contract lives in docstrings, the spec, and the
roadmap doc; the README only carries enough context to nudge a
new reader toward unit-testing their handlers from day one.

* ci(justfile): skip CodeQL in gha-pre-release (act/CodeQL incompatibility)

The CodeQL GitHub Action's post-analysis step calls the GH REST API
(`/repos/{owner}/{repo}/actions/runs/{run_id}`) for telemetry and
status-page reporting. Under `act`, the synthesized GITHUB_RUN_ID
doesn't exist on github.com, so the call 404s and the action sets the
job status to JOB_STATUS_CONFIGURATION_ERROR even when:

  * 174/174 queries loaded successfully
  * 42/42 Python files extracted
  * SARIF generated and post-processed
  * Zero findings in our code

The result is gha-pre-release ALWAYS failing on the codeql step under
act, which masks real failures in upstream steps. Cleanest fix: drop
CodeQL from the local replay sequence with a clear note about why,
and rely on the real GH Actions run on every PR (which has actual
access to the workflow-runs endpoint via GITHUB_TOKEN).

Changes:

  * Recipe header documents the skip with rationale and a one-liner
    showing how to run CodeQL locally on demand for ad-hoc inspection.
    The user runs `act push` directly and inspects the SARIF; a
    configuration-error exit with a successfully-generated SARIF means
    no findings.
  * Step counters renumbered from N/7 to N/6 throughout the recipe.
  * Step 5 prints an explicit SKIPPED notice explaining the situation
    so the user isn't left wondering whether CodeQL ran.
  * Final "safe to merge" message clarifies CodeQL still gates merge
    on the actual PR via real GH Actions.

Discovered while running gha-pre-release on the testing-helpers PR -
CodeQL kept reporting failure despite analyzing cleanly.

* fix(testing): silence CodeQL py/unused-import on runner module imports

Move `ReplayRequestType` and `ReplayStatus` into the
`if TYPE_CHECKING:` block. They were imported at module scope but only
referenced in annotations (resolved as strings via
`from __future__ import annotations`) and in a string-form
`cast("ReplayRequestType", ...)` call (per ruff's TC006 rule, which
prefers the string form to keep type-only symbols out of the runtime
import graph). CodeQL's py/unused-import sees the runtime import and
the string-only references, and reasonably concludes the import is
unused.

Cleaner resolution: keep the imports type-only, expand the explanatory
comment so future readers see why both static analyzers (CodeQL +
ruff TC006) end up happy with this shape.

The two CodeQL py/cyclic-import alerts on the surrounding
`if TYPE_CHECKING:` block remain false positives — CodeQL does not
model conditional imports, and the runtime cycle is broken by the
lazy import in `CustomResource.replay` (resource.py:354). Both have
been dismissed in the GitHub UI with rationale (alerts #15, #16).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant