Dmytro Hrimov
  • Blog
  • About

© 2026 Dmytro Hrimov

  • Privacy Policy
  • Terms & Conditions
← All posts
by Dmytro Hrimov·Posted Oct 3, 2026·20 min read

A GitHub Action to Package Python AWS Lambdas with uv

AWSLambdaPythonGitHub ActionsuvCI/CD

My last post on packaging Python Lambdas with uv ended with one thing left open. I had a build script that produced a reproducible zip, handled the 250 MiB limit for me, and ran with one command per Lambda. What it didn't do was run in CI.

The script is now a public GitHub Action, dhrimov/package-python-lambda-gha, that packages a Python AWS Lambda into a reproducible zip in one workflow step.

Packaging Python Lambdas: from script to GitHub Action

Two posts led here. The first, Deploy a Python Lambda on AWS with uv and Terraform, set up a single Lambda with uv and deployed it with Terraform. It packaged the zip with a handful of uv and zip commands, following uv's AWS Lambda guide. Those commands had to be adapted to each Lambda's layout.

The second, Packaging Python Lambdas with uv and package-python-function, replaced them with a build.sh. The script builds the project as a wheel, installs it into a throwaway venv for the target platform, and hands that venv to package-python-function.

I had four requirements in mind. After the previous post, three of them were done:

  • one command per Lambda - done

  • reproducible builds, with the same inputs giving the same hash - done

  • dependencies packaged as an inner zip once the package goes past 250 MiB, with no import shenanigans in the handler - done

  • running in CI/CD - not done

The gap isn't that the script can't run in CI. It runs fine. The gap is what happens after it does. Every repo that wants this has to install uv first, copy build.sh into itself, and then keep its copy in step with mine when I fix something. I already had two copies across two demo repos, both pinning package-python-function at 0.0.12 while the tool had moved on to 1.0.0.

A copied script isn't a dependency. You can't update it, you can't pin it, and nothing tells you it's out of date.

Composite action vs reusable workflow

GitHub gives you four ways to share CI logic - a reusable workflow, or an action in one of three flavours - and the pick here wasn't obvious, so it's worth a couple of words.

The one to rule out first is a reusable workflow, and the first reason is the Marketplace: actions can be listed there, reusable workflows can't.

The second reason is that a reusable workflow is a job, and an action is a step. A reusable workflow runs on its own runner, so the zip it produces is gone when the job ends, unless it's uploaded as an artifact and downloaded again in the deploy job - for every caller, whether they need that or not. An action runs inside the caller's job, and the caller decides what happens to the zip next:

  • one Lambda, deployed in the same job - the zip is already on disk, nothing to upload

  • several Lambdas, deployed one function at a time, for example with aws lambda update-function-code - the deploy step goes inside each matrix job, still nothing to upload

  • several Lambdas, deployed together by one terraform apply or one CDK or SAM stack - this is the case that needs the artifact round trip, and the action does the upload part when you set upload-artifact: true

  • no deploy at all, for example a pull request check that only looks at the package size or hash

Being a step also means you can put your own steps around it in the same job, such as aws-actions/configure-aws-credentials before a deploy.

The third reason is smaller, but the bug it causes would be a nasty one to track down. Environment variables set in the caller's workflow-level env are not passed to a called reusable workflow. Set SOURCE_DATE_EPOCH there - the standard variable for fixing file timestamps in a reproducible build - and it would never reach the build. The steps of a composite action see the job's environment like any other step.

So, an action of some kind.

A Docker container action would let me pin an exact base image, which sounds appealing for a packaging tool. But it adds image pull time to every run, and the actual work here is calling uv and uvx, which setup-uv already installs the same way every time. A JavaScript action would mean rewriting working Bash for no gain, since the real work is still shelling out to those binaries.

That leaves a composite action. It runs inside your job, on your runner, and shares your filesystem, so the zip it writes is just there for the next step. Sharing the filesystem is also why this action stops at producing a zip. Terraform, the AWS CLI, SAM and CDK all accept a zip path, so deploying is your business, and you write that step yourself.

Prerequisites

The action assumes three things about the project it packages, and none of them is optional:

  • A uv project with a committed uv.lock. The first step is uv export --frozen, which fails when there is no lock file. --frozen also means the lock is used as it is, without checking that it still matches pyproject.toml - so a stale lock packages stale dependencies. The uv cache is keyed off that same file.

  • A project that builds as a wheel. Next step is uv build --wheel, so the handler has to live in a package with a pyproject.toml, not in a loose lambda_function.py at the repo root.

  • Dependencies with prebuilt wheels for the target platform. The install runs with --only-binary=:all:, so a dependency that ships only a source distribution fails the build. That's on purpose: a wheel compiled on the runner is built for the runner, not for AWS Lambda.

🛑

The build happens in <input-path>/build, and that directory is removed with rm -rf before the build and again when the action exits - deleted, not emptied. build.sh takes a --build-dir flag to move it, but the action doesn't expose it yet, so a project with its own build/ directory gets no warning and no way to opt out.

Examples

Two examples before the details.

Package a single Python Lambda in GitHub Actions

Here's the single-Lambda case, the one from the first post's repo:

jobs:
  package:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v7

      - id: package
        uses: dhrimov/package-python-lambda-gha@v1
        with:
          input-path: .
          output-path: terraform
          python-version: "3.13"
          platform: aarch64-manylinux2014

That's the package job from the workflow in demo-aws-lambda-terraform-uv, unedited.

The four inputs set here are the same four arguments the original build.sh took, renamed to the hyphenated style actions use. No setup-uv step, no pip install uv. The action installs uv itself and turns on uv's Actions cache, keyed off your project's uv.lock.

The permissions: block is there for your own steps, not for the action. The action makes no authenticated GitHub API calls and asks for no token scopes of its own: contents: read is what actions/checkout needs, and turning on upload-artifact needs nothing on top of it. The one example below that asks for more is the deploy job, and that's download-artifact's requirement, not this action's.

The deploy is your own step, and it's a short one. In this single-Lambda example the zip is already on the runner, so a deploy step in the same job can read it straight off disk - no upload, no download of artifacts. Here it is with Terraform:

      - run: terraform apply -auto-approve -var "lambda_zip=${{ steps.package.outputs.zip-path }}"
        working-directory: terraform

That step is a sketch, not something the demo repo runs - its Terraform takes the zip filename as a literal, not a variable. Applying for real needs a terraform init before it, AWS credentials, and a state backend.

Package several Lambdas with a matrix build

The action packages one Lambda per call. It doesn't loop, and that's on purpose: as soon as it loops, there's no single answer to "which zip path did you produce?" and the work serializes.

The loop is your matrix instead, and that gets you the parallelism for free. Using the layout from the multi-Lambda demo repo:

jobs:
  package:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    strategy:
      fail-fast: false
      matrix:
        lambda: [acme-order-notifier, acme-order-processor]
    steps:
      - uses: actions/checkout@v7

      - uses: dhrimov/package-python-lambda-gha@v1
        with:
          input-path: lambdas/${{ matrix.lambda }}
          output-path: dist
          python-version: "3.13"
          platform: aarch64-manylinux2014
          upload-artifact: "true"

That's the package job from the workflow in demo-aws-lambda-packaging-with-uv, unedited.

Each Lambda gets its own job, its own log, and its own artifact.

Each one also runs on its own runner, and that runner is gone by the time anything else starts, taking its dist/ with it. Whether that's a problem depends on how you deploy.

A tool that deploys one function at a time, like aws lambda update-function-code, can go inside each matrix job right after the action and read the zip off disk, the same as in the single-Lambda case. No artifact needed.

A tool that deploys all the Lambdas together, like terraform apply or a CDK or SAM stack, has to see every zip at once. It can't sit inside a packaging job, and by the time it runs, the machines that held the zips are no longer there. Uploading is how the zips outlive the runners, and it's why this example turns upload-artifact on when the single-Lambda one left it off.

Picking them back up for the Terraform case looks like this - another sketch, not a workflow I run:

  deploy:
    runs-on: ubuntu-latest
    needs: package
    permissions:
      contents: read
      actions: read
    steps:
      - uses: actions/checkout@v7

      - uses: actions/download-artifact@v8
        with:
          path: dist
          merge-multiple: true

      - run: |
          terraform apply -auto-approve \
            -var "notifier_zip=../dist/acme_order_notifier.zip" \
            -var "processor_zip=../dist/acme_order_processor.zip"
        working-directory: terraform

Nothing in that job mentions an artifact name, and it doesn't need to. That's also why it asks for actions: read: a download with no name has to list the run's artifacts through the API first. More on both in the artifacts section below.

Inputs, outputs, and guard rails

Not every input is covered here - the full list, with defaults, is in the README.

The two inputs with no default

python-version and platform have no defaults, and I don't plan to give them any.

In the previous post I added a warning: the architecture and the runtime are pinned in two places, the packaging step and the Terraform resource, and they have to agree. build.sh already refused to default --platform for that reason.

⚠️

platform still has to match the architecture you set on the Lambda resource - aarch64-manylinux2014 goes with arm64, x86_64-manylinux2014 with x86_64. The action never sees that resource, so it can't check this for you. Get it wrong and the Lambda deploys without a word, then fails the first time it runs.

A default here would be worse than a default anywhere else in the action, because it would quietly pick the ABI - the exact interpreter build a compiled dependency is linked against. That's the cp313 in a wheel's filename, and the aarch64-linux-gnu in the .so inside it. Setting both values every time is what makes it a choice you made.

One thing to be aware of: GitHub doesn't enforce required: true on composite action inputs. It's documentation, nothing else. Leave one out and the action starts anyway with an empty string. So the action checks them itself and stops with an error: line and the usage text, long before anything gets built. Two more checks fail the same way, with the same error: line. One is a missing pyproject.toml at input-path - the likeliest of the lot, usually a wrong path in a matrix. The other is uv, uvx or jq missing from PATH, which every GitHub-hosted runner gives you out of the box and a self-hosted one may not.

Guard rails

Those checks are usage errors: the call itself is wrong, so the script prints the usage text with them. Four more are a different kind - the call is fine, but the build it asks for can't work - and each raises an ::error:: annotation GitHub shows on the run. Both kinds run before the first uv call, so a build that can't work fails in seconds instead of after a full venv install. The one cost you still pay is setup-uv in the step before, because the checks live in the script and the script runs second.

  • The runner has to be Linux. This one is a safety net rather than a hard limit: build.sh always passes --python-platform, so uv resolves wheels for the Lambda target and not for the host. But Linux runners are the only ones the action is tested on, and anything that did go wrong on another one would go wrong quietly, giving you a package that deploys fine and breaks at runtime. There's a fail-on-non-linux: false input to switch it off.

  • platform has to be aarch64-manylinux2014 or x86_64-manylinux2014. That's an allow-list of two, not the limit of what uv can target - aarch64-manylinux_2_28 is a real target too; it's just not one the action maps to a Lambda architecture.

  • python-version has to be one of 3.11, 3.12, 3.13, 3.14. A typo fails.

  • SOURCE_DATE_EPOCH, if you set it, has to be an integer no lower than 315532800 - 1980-01-01, the zip format's own epoch. Nothing below it can be stored in a zip entry. The packager checks this too, but it runs last, so an unusable value would otherwise cost you a full build first.

3.11 is the one case that warns instead of failing. It's the last Amazon Linux 2 Python runtime, and AWS retires it on 2027-06-30, but it works today. The action's job is catching drift you didn't mean to introduce, not enforcing an upgrade schedule. 3.10 isn't on the list at all: AWS deprecates it on 2026-10-31, so adding it now would mean adding a runtime that's about to go.

ℹ️

The supported runtime list is maintained by hand. AWS has no API that answers "which Python runtimes do you support right now?" The list needs an edit here whenever AWS adds a version. Deprecation dates are hand-written too, so a newly deprecated runtime stays quiet until someone adds its date.

Outputs: zip hash, size, and the 250 MiB unzipped limit

The action sets five outputs:

  • zip-path - absolute path to the zip on the runner. Pass it to the next step unchanged.

  • zip-sha256 - SHA-256 of the zip. Same inputs, same hash, so you can skip redeploying a function that didn't change.

  • package-size-bytes - size of the zip on disk.

  • uncompressed-bytes - unzipped size. This is the number AWS's 250 MiB limit applies to.

  • nested-zip - true when the packager's >250 MiB fallback kicked in.

The stable hash also keeps Terraform quiet. source_code_hash = filebase64sha256(var.lambda_zip) is computed from the same bytes, so terraform plan only shows a Lambda update when the code or its dependencies actually changed.

The two size outputs exist because the limit that bites you is not the one you see. AWS measures 250 MiB against the unzipped package, so a comfortable-looking 65 MiB zip can be nowhere near comfortable. nested-zip tells you whether the fallback from the last post - dependencies moved into an inner zip, unpacked at cold start - has kicked in.

All of it comes from a --report flag that package-python-function 1.0.0 added: the packager writes a JSON object once packaging succeeds, and the action reads the values out of that file. The zip's path, the sizes and the distribution name all come from the tool that wrote the zip, instead of being worked out a second time and drifting later. Only the hash is computed locally, because the report doesn't carry one.

That report is also why packager-version defaults to 1.0.0, and why going below it is a bad idea: every earlier release is a 0.0.x with no --report flag. If you're curious, the three asks - print the output path, print the sizes, exit non-zero when nothing was produced - went upstream as PR #16 and shipped in 1.0.0.

⚠️

Nothing stops you from pinning packager-version below 1.0.0. An older pin passes every check and dies at the packaging step, once the venv is already built.

The packager runs on its own Python

The packaging step doesn't run on the interpreter you asked for. uvx fetches one for it - package-python-function wants Python 3.11 or newer - and python-version never reaches it. That input names the Lambda runtime you're targeting, and the packager only reads files out of the venv already built for that target; it never imports them. So the two versions are free to differ, and normally do.

Artifacts are off by default

upload-artifact defaults to false. Most of the cases from earlier - one Lambda, a per-function deploy inside a matrix, a check that deploys nothing - read the zip straight off the filesystem, and turning upload on costs you time and storage for no benefit.

⚠️

When you do turn it on, be aware of one trap. Artifacts are immutable and error on same-name collisions, so every part of a matrix needs a distinct name.

Leave artifact-name empty and you get a name that is already distinct:

<distribution-name>-<platform>-py<python-version>

for example acme_order_notifier-aarch64-manylinux2014-py3.13. The distribution name comes out of the packager's report, so it matches the zip's filename.

You might wonder why the deploy sketch in the several-Lambdas example never mentions an artifact name, when each packaging job gives its artifact a distinct one. The short answer: the artifact name and the zip's filename are two different things.

The artifact name is a label on the container. It's what GitHub lists on the run page, and what upload-artifact refuses to reuse. The zip's filename comes from the distribution name - acme_order_notifier.zip - and that's the one your deploy step reads.

On download, the container name either disappears or becomes a directory: with the merge-multiple: true from that sketch it's gone, and without that flag the zip lands in a directory named after it. Either way, the filename inside stays the same.

Without name, download-artifact takes every artifact the run produced. The deploy job never has to learn what the artifacts were called, which is just as well: a matrix doesn't merge the outputs of the jobs it spawns; the last one to finish simply overwrites the rest, so the names could not have reached this job anyway.

How the action is tested

The action is Bash, and Bash is easy to write and hard to trust. It gets tested from two directions: the functions inside the script, and the action as a whole.

The pure helpers inside build.sh - the platform-to-architecture mapping, the runtime check, the byte formatting - are plain functions, and the file guards its own entry point so that sourcing it defines them without running the pipeline. Bats calls them directly. ShellCheck runs over the whole script on every pull request.

The action itself is tested through its own public interface, with uses: ./ against fixture projects committed to the repo. Eight variations cover four Python versions across both platforms, plus separate jobs for the multi-Lambda pattern, hash stability, artifact naming, and the guard rails.

The assertion that makes the matrix worth running isn't "a zip exists". It's the ABI tag of the compiled extension module inside the zip:

architecture="${PLATFORM%%-*}"
expected="orjson/orjson.cpython-${PYTHON_VERSION//./}-$architecture-linux-gnu.so"
unzip -l "$ZIP_PATH" | grep -qF "$expected"

One check, sensitive to both matrix axes at once. Without a compiled dependency in the fixture, uv pip install --only-binary=:all: would resolve the same py3-none-any wheels no matter what --python-platform says, both platform variations would produce byte-identical zips, and a regression that dropped the flag entirely would pass green. That's why the fixtures carry orjson: not for the payload, for the .so.

Reproducibility gets its own job - run the action twice against the same fixture, assert the two hashes match. The guard-rail job runs the action with a bad platform and an unsupported runtime, expects both to fail, and then checks that no output directory was created at all.

Pin the action by commit SHA

Use the SHA-pinned form:

- uses: dhrimov/package-python-lambda-gha@<40-char-sha> # v1.0.0

A tag can be moved to another commit; a SHA can't. The trailing comment keeps the version readable, and Dependabot or Renovate update both together. The action pins setup-uv and upload-artifact the same way.

Two things the run pulls in are not actions, so a SHA pin can't cover them. The first is package-python-function, fetched from PyPI by the packager-version input, 1.0.0 by default. A version pin there is stronger than it looks: PyPI refuses to reuse a filename, even after a release is deleted, so package_python_function-1.0.0-py3-none-any.whl can't quietly become different bytes the way a git tag can. A hash pin would be stronger still, but uvx has no syntax for one: --require-hashes only works with uv pip install. So a version is both the floor and the ceiling here. What it doesn't cover is a compromised index or mirror.

The second is uv itself, and it's the loose one. uv-version is empty by default, which means setup-uv installs the version your project asks for, or the newest release if it asks for nothing. That's the weakest pin in the action, on the largest piece of software in it. setup-uv checks the checksum of what it downloads, so the download is sound; the choice of version is what floats. Set uv-version if you'd rather have that decided than discovered.

@v1 exists too and always points at the latest v1.x.y, because the Marketplace and everyone's muscle memory expect it. It's fine for a quick experiment. The repo also has GitHub's immutable releases turned on, so a published vX.Y.Z tag can't be moved or deleted afterwards.

Try it, or package a Lambda locally with build.sh

The action is on the Marketplace as Package Python Lambda, and the source is at dhrimov/package-python-lambda-gha.

Both demo repos from the earlier posts now use it instead of carrying their own copy of the script:

  • demo-aws-lambda-terraform-uv - one Lambda, one call. See the change that switched it over.

  • demo-aws-lambda-packaging-with-uv - two Lambdas, one matrix. See the change that switched it over.

Both dropped their build.sh in the process, which was the point.

That doesn't mean you lose the zip when you're not deploying. There are two ways to get one by hand.

The first is the action itself: set upload-artifact: "true", run the workflow - workflow_dispatch is enough - and download the zip from the run page. It's the same zip a deploy step would have used, built on the same runner from the same inputs, so it's not a second-best copy of anything.

The second is running the script directly. That's still a supported path rather than a leftover: build.sh falls back to shasum where there is no sha256sum, checks that uv, uvx and jq are on PATH because GitHub's runners have all three and your laptop may not, and skips the step-output writes when GITHUB_OUTPUT is unset.

git clone https://github.com/dhrimov/package-python-lambda-gha
./package-python-lambda-gha/build.sh \
  --input . \
  --output dist \
  --python 3.13 \
  --platform aarch64-manylinux2014

Reach for that when you're working on packaging itself. For everything else the artifact is less work.

What changed is where the script lives. It used to be a copy in your repo that you had to keep current. Now there's one copy in one place, and you either pin it as an action or clone it.

Final words

Back to the four requirements from the last post. One command per Lambda, reproducible builds, and the 250 MiB fallback were done then. CI/CD is done now, and it turned out to be less about running the script on a runner and more about the script no longer being something you copy.

If you package Python Lambdas with uv, give it a try and tell me where it breaks. Issues and pull requests are always welcome on the repo.

Thank you so much for taking the time to read this. Happy coding!