Skip to main content
Most artifacts are delivered to a place your application reads from: a file in an object store, an image in a container registry. Some destinations cannot be read from at all. A hosted build service that builds your application from a git repository has to be pushed to, and there is nobody inside your customer’s account to do the push. The # @artifact_commit() annotation closes that gap. It declares that the contents of one of your artifacts become a commit on a branch of a git repository in your customer’s account. The appliance controller performs the push itself, using its own identity in that account, so no git credential, access token, or console authorization is involved anywhere. The artifact is still an ordinary object-store file. What is new is where it lands and who moves it.

When you need this

You need this when something inside your customer’s account builds your application from a git repository rather than running a bundle you built yourself. That happens when two things hold at once. The build has to run per install, because some of what goes into it is created by the install’s own apply. A framework that compiles public environment values into its browser bundle cannot be served by one bundle you built in advance. And the thing that builds has to be pushed to rather than read from. If it accepts an artifact you hand it, ordinary artifact delivery already covers you. AWS Amplify is an example that shows both halves at once:
  • Build-time values are compiled into the bundle. A framework like Next.js bakes its public environment values into the browser bundle during the build, and some of those values (an identity provider client ID, for example) are created by the install’s own apply. One prebuilt bundle therefore cannot serve every install.
  • Amplify has no manual deploy path for server-rendered apps. For an SSR app, a git repository is the only input it accepts.
  • Of the git sources Amplify accepts, only CodeCommit authenticates without a person. GitHub, GitLab, and Bitbucket each require somebody to complete an interactive authorization flow and hand over a token. Nobody is present inside your customer’s account to do that.
Nothing in the mechanism is specific to Amplify. Any service inside your customer’s account that builds from a branch it watches has the same shape.

How a delivery works

1

You publish the artifact

Your build packages the tree you want committed as a tar archive and uploads it to a bucket in your own account.
2

Your origin stack declares the delivery

A data source for the object, an aws_codecommit_repository resource for the destination, and a local that names both, annotated with # @artifact_commit().
3

Tensor9 moves the artifact to the appliance controller

During deployment, Tensor9 fetches the object from your bucket and streams it to the appliance controller.
4

The appliance controller pushes it

The appliance controller unpacks the archive and pushes the tree onto the branch as a single commit, authenticating with its own identity in the customer’s account.
5

The build service builds

Whatever watches that branch sees the push and builds from it.
Deliveries are idempotent. Before pushing, the appliance controller compares the tree it is about to write against what is already at the branch tip and writes nothing when they match, so redeploying unchanged contents does not trigger a rebuild. When it does write, the delivery replaces the branch with a single commit. The branch belongs to the delivery, and hand edits to it are overwritten.

Packaging the artifact

  • The archive is tar, optionally gzipped. The format is detected from the file’s contents, so the object key can be named anything.
  • Entry paths are preserved exactly and no leading directory is stripped. Create the archive from inside the directory you want at the repository root.
  • A .git directory inside the archive is ignored, so an archive made from a working tree does not carry its history in.

Declaring a delivery

All four keys are required. The annotation itself takes no arguments. Every value is a real Terraform reference inside the object, so Terraform validates it: a rename breaks loudly, your editor completes it, and your linter sees it.

after decides whether anything builds

after names the resources that must exist before the artifact lands, written as addresses, the way depends_on entries are written. Tensor9 already orders the delivery after every resource it can see consuming the destination repository. after is for a resource that watches the repository without consuming it, which no reading of your Terraform can find. aws_amplify_branch is the example: it references the Amplify app’s ID and names the repository nowhere, so nothing connects it to the delivery.
Getting after wrong fails silently. A build service that watches a branch discards a push notification for a branch it holds no record of, and it never re-scans. If the artifact lands before the watcher exists, the apply succeeds, no build runs, no build fails, and the install looks healthy while serving nothing. Creating the watcher afterwards does not back-fill a build for the commit already sitting at the branch tip.
One caution, and it applies only when a resource you name in after is itself gated by count or for_each. Terraform accepts a depends_on entry naming a resource whose count is zero, and orders the delivery against nothing at all. Tensor9 does not compare that gate against the delivery’s own. So if the watcher is switched off on an apply where the delivery still runs, the delivery lands unordered, and you are back in the silent case above: the apply succeeds and nothing builds. Gate the delivery so it can never be on while the watcher is off. The usual way is to build the delivery’s gate out of the watcher’s own:
A delivery gated this way always has a branch to be ordered against. Write after = [] only if nothing watches the repository. after = null is an error, to prevent silent misconfigurations.

The version and the etag are derived, not declared

Tensor9 reads four values off src: the source object’s bucket, key, version_id, and etag. Declaring a version key or an etag key is refused. Both are functions of src, so stating either separately could only ever disagree with it. The two do different jobs, and they are not interchangeable:
  • The version is the pin. It is what Tensor9 hands S3 when it fetches the object, so it decides which bytes are delivered.
  • The etag is only a change token. It is never read. It exists so that a republished artifact plans a difference.
This is also why src has to be a data source rather than a bucket and key you write out yourself. Both version_id and etag come from an S3 HeadObject performed at plan time, and your own AWS provider is the only thing positioned to make that call. The bucket and the key are just the block’s own arguments read back.

How Tensor9 notices a republished artifact

A delivery re-runs when any of those four values changes, so republishing your artifact plans a difference however you choose to publish it. The etag is what covers the awkward case: an unversioned bucket reports an empty version, so an object overwritten at a stable key moves nothing else Terraform could compare. Versioning the bucket, or writing a new object key per build, is still worth doing, for a different reason. It gives the delivery a pin:
  • A versioned bucket produces a new version per publish, and Tensor9 fetches that exact version.
  • Immutable keys (a build ID or commit SHA in the key, never overwritten) need no pin, because the current version of that key is the only version there will ever be.
  • An unversioned bucket written at a stable key has neither. Tensor9 fetches whatever the key holds at the moment the appliance controller reads it, which is not necessarily the object the plan compared.
On an unversioned bucket with stable keys, republishing between plan and apply delivers the newer bytes rather than the ones the plan saw. The delivery still lands and the build still runs; it is just not the artifact you were looking at. Either of the other two arrangements closes that window.

What ships today

Declaring a delivery that Tensor9 cannot perform is a compile error, not a silent skip. A declaration compiled for an appliance that has nowhere to put the commit fails the build rather than quietly producing a deployment that reads as healthy and serves nothing.

It stays inert in your own deployment

The annotation is a comment, and the declaration is an ordinary Terraform local. Your own terraform plan and terraform apply see a local that nobody reads, and behave exactly as they did before you added it. That is deliberate: an integration that stopped your ordinary deployments from planning would not be one you could adopt. Gate the delivery the same way you gate the rest of the feature. In the example above, count = local.webapp_seeded ? 1 : 0 on the source leaves the whole declaration inert when the feature is off. One consequence is worth knowing. During a Tensor9 deployment, the annotated local is replaced by the delivery itself, so the keys you wrote are no longer readable from it, and referencing local.webapp_delivery.src elsewhere in your stack is refused at compile time. The delivery does expose repository_name, branch_name, commit_id, and tree_digest, but none of those exist in a deployment without Tensor9, so a stack you also deploy yourself should leave the local unread. A resource that reads one of those values must not also consume the destination repository, and must not appear in after. Either arrangement asks for the artifact to land both before and after the same resource, so Tensor9 refuses the declaration at compile time rather than emitting a graph Terraform would report as a cycle far from its cause.
  • Artifacts: How artifacts move from your environment into an appliance
  • Origin Stacks: How to define portable infrastructure code
  • Deployments: How compilation and deployment works