Archive Step Type: Pack Zip/Tar Archives Without Shelling Out
Packaging a directory into a deployable archive — most commonly zipping a Lambda
function's source into handler.zip before terraform plan/apply — has always
meant reaching for a shell-based hook that wraps the zip/tar binary. That works
until you actually depend on it: the flags aren't even the same between BSD tar
(macOS) and GNU tar (most Linux CI images), errors surface as opaque shell exit
codes instead of anything typed, and — the part that's easy to miss — the archive you
get back isn't reproducible. Build the exact same source twice and you get two
different files. A new archive step type fixes all three: implemented on the Go
standard library only, it behaves identically everywhere Atmos runs, validates its
config before touching the filesystem, and can now produce byte-identical output for
identical input.
The Problem
This surfaced while migrating a Terragrunt Lambda example (Lambda + DynamoDB + IAM
role) to Atmos. The source unit used a before_hook "package" wrapping
scripts/package.sh, which itself wrapped the zip binary, before every
plan/apply/destroy. Translating that directly meant an equivalent shell-based
hook:
hooks:
package:
kind: command
command: bash
args: ["-c", "cd src && zip -r ../handler.zip . -x '*.test.js'"]
events: [before.terraform.plan, before.terraform.apply, before.terraform.destroy]
Three problems with this, once you look past "it worked when I tested it":
- The invocation isn't even portable between macOS and Linux, let alone Windows.
zip -xandtar --excludetake different glob syntax; BSDtar(what macOS ships) and GNUtar(most Linux CI images) disagree on flags like--excludevs.-X excludefile. Two engineers on the same team can get different results running the "same" hook. - Failures are opaque. A typo in a glob, a missing source directory — all of it surfaces as a shell exit code with no structure Atmos can validate ahead of time.
- The output isn't reproducible. Zip and tar both bake in each file's real
modification time and permission bits. A fresh
git clonesets every file's mtime to checkout time, and umask varies across machines and CI images — so identical source content produces a different archive on every rebuild. This is the same failure mode Terraform's ownarchive_fileprovider has been reported for over the years: two runs, same input, different checksum. Anything downstream that keys off the archive's hash — Lambda only redeploying when the zip's checksum changes, a build cache, a provenance check — sees a "change" that isn't one.
A data "archive_file" Terraform data source was tried first for the packaging step
itself. It works fine when a config references the data source's own output attribute
directly (output_path, output_base64sha256) — Terraform's graph orders that
correctly. It broke here because the migrated module computed the zip path
independently in a locals block, mirroring the original Terragrunt script's own
variable, instead of referencing the data source's attribute — so there was no
dependency edge forcing archive_file to run first. That's not a one-off: it's a
documented class of bug,
closed once the reporter switched to referencing the output attribute directly — the
same fix this migration's module hadn't made. A before.terraform.* hook sidesteps
the whole class of problem
structurally — it runs and completes before Terraform's graph is even evaluated, so
there's no reference to get wrong.
The Fix
The new archive step type packages a directory or file into a zip, tar, or
tgz archive using only Go's standard library — archive/zip, archive/tar,
compress/gzip. No shelling out, so the same config produces the same archive
structure everywhere Atmos runs:
steps:
- name: package
type: archive
source: src/
destination: handler.zip
exclude:
- "**/*.test.js"
- "**/node_modules/**"
It has no dedicated hook kind — like every step type added since
kind: step shipped, it reaches hooks through the same
bridge, so it works as a lifecycle hook with zero extra plumbing:
hooks:
package:
kind: step
type: archive
events: [before.terraform.plan, before.terraform.apply, before.terraform.destroy]
with:
source: src/
destination: handler.zip
How to Use It
source/destination— required. What to archive, and where to write it.format—zip,tar,tgz,tar.bz2, ortar.xz. Inferred fromdestination's extension when omitted (writingtar.bz2/tar.xzisn't implemented yet — see below).subpath— nestssource's content under a path inside the archive, e.g.subpath: opt/nodejsfor a Lambda Layer.include/exclude— glob filters, evaluated exclude-then-include.action—replace(default) always rebuilds the archive fresh fromsource.updateadds/refreshes entries in an existing archive without touching the rest — supported forzipand uncompressedtaronly, sincetgz/tar.bz2/tar.xzcompress the whole stream as one unit and can't be edited surgically. Selectingupdateon one of those formats fails with a clear error instead of silently falling back to a full rebuild.
create and extract are reserved in the schema for a future release, so it won't
need a breaking change to add them later.
Reproducible Output
A native step type fixes the shelling-out problems, but not the reproducibility one
by itself — by default, entries still carry the source files' real mtime and
permission bits (same as omitting the field, or setting it to filesystem
explicitly). Set mtime: epoch or mtime: git to fix that too:
steps:
- name: package
type: archive
source: src/
destination: handler.zip
mtime: epoch
mtime names the mechanism, not an outcome: it's the modification-time metadata
stamped into each archive entry — not the source files on disk, and not the
archive file's own OS-level mtime.
filesystem— the default. Every entry carries the source file's real mtime and permission bits.epoch— every entry gets the same timestamp: the most recent Git commit that touched anything undersource. One timestamp, whole archive. Named after theSOURCE_DATE_EPOCHreproducible-builds convention, which this mirrors conceptually — one shared reference timestamp for the whole build — even though the value here comes from Git history rather than an environment variable.git— each entry gets its own timestamp, from its most recent commit. Files with no Git history (build output,node_modules/) fall back to the same valueepochwould use.
epoch and git both also normalize permission bits (0644, or 0755 if the
source is executable), since inconsistent umask-derived permissions produce different
archive bytes just as reliably as inconsistent timestamps do. Timestamps come from
Git commit history via go-git — no shelling out to git log, and no dependency on
SOURCE_DATE_EPOCH being threaded through every tool in the chain. Outside a Git
repository, both modes fall back to a fixed reference date rather than failing the
step. It's opt-in: existing workflows that don't set mtime see no change in
behavior.
action: replace + mtime is idempotent — action: update is not. With
replace, the same source produces the same bytes on every rerun, because
replace always rebuilds from scratch. update doesn't get that guarantee even with
mtime set: its existing entries keep whatever mtime/mode a prior write already gave
them, and only the entries a given update call adds or refreshes get normalized —
so the final bytes depend on the archive's history, not just source's current
content. If you need the idempotent guarantee, use replace.
Get Involved
See the archive step reference for the full field list, and the hooks reference for using it as a lifecycle hook. Questions or ideas? Join us in the Cloud Posse community Slack.
