# Passing source and artifacts between Tekton and a macOS VM

The macOS app compiled successfully, but the archive copied out of the VM was empty. Its hash was SHA-256 of zero bytes. The agent compared it with the archive inside the guest and failed the job before publication. The copy helper had captured standard output into a pipe instead of writing the intended host file. Writing those captured bytes to the file fixed the transfer.

The next Linux task needs the files produced in the guest. This external macOS VM has no mount of the pipeline's workspace, so source and artifacts move through an OCI registry. Checks at the guest, host, registry and Linux task compare the transferred bytes with their expected digests. A successful compilation alone would have missed the empty copy.

In [Part 1](/blog/tekton-customrun-macos-builds), the controller waited for confirmed VM deletion before reporting normal CustomRun completion. This part follows the files: checking the source before allocating a VM, then checking the archive as it leaves the guest and reaches Linux. Separate builds produced different archive digests even when their app-file hashes matched.

## Tekton passes the digest; the registry holds the files

Two Linux tasks surround the macOS CustomRun. `prepare` uploads the source bundle and returns its digest. The Mac agent fetches that bundle, builds the app in the guest and publishes the archive. The controller returns the artifact digest through the CustomRun to Linux `verify-artifact`. The controller runs in the cluster, and the agent runs on the Mac. The diagram uses illustrative `mac-ci` names:

```mermaid
flowchart LR
  subgraph cluster["kind cluster mac-ci-local"]
    P[prepare] -- "push mac-ci/source" --> REG[(mac-ci-registry)]
    P -- "sourceDigest, commit" --> CR[CustomRun: MacBuild]
    CR -- "artifactDigest, appTreeSha256" --> V[verify-artifact]
    REG -- "pull mac-ci/artifacts" --> V
  end
  subgraph mac["Mac host"]
    AG[Rust agent] -- "pull by digest, re-hash" --> REG
    AG -- "tart exec" --> VM[disposable Tart VM]
    AG -- "push artifact" --> REG
  end
  CR -. "MacBuild status" .- AG
```

Tekton results carry the digests, commit and app-file tree hash. The registry carries the files. Each Linux task uses its own `emptyDir` for scratch files, which disappear with its Pod.

I chose the registry because blobs are content-addressed by digest within a repository and the agent can reach it through its existing authenticated API server connection. This avoids a key-naming convention for writers and a separate storage mount inside the guest.

## Source is checked before a VM is cloned

Linux `prepare` makes a git bundle whose single ref points to the requested commit and uploads it to `mac-ci/source`. The CustomRun passes the bundle digest to the agent as `sourceDigest`, together with the commit. The source fields in the request identify both. Examples and captured excerpts below use normalized `mac-ci` names and abbreviated digests:

```yaml
spec:
  source:
    bundle:
      repository: mac-ci/source
      digest: sha256:0f4cde83...effe493f
    commit: ccfede490ced604439e61715260123323de642b5
```

The agent fetches the blob by digest, hashes it again and checks that its only ref points to the requested commit before cloning a VM. The pass, compile-error, failing-test, cancel and fresh-run cases all fetched the same 5,063-byte bundle, and the agent logged the same line in each:

```text
fetched source bundle mac-ci/source@sha256:0f4cde83...effe493f (5063 bytes), digest and head verified
```

The guest checkout also reported the requested commit. These cases began from the same checked source. The fixture repository reached the cluster as a ConfigMap bundle, with no git server. With a git server, `prepare` could fetch the requested commit before creating and uploading the bundle.

## The archive is checked at each transfer

The empty-copy failure happened after compilation, before tests ran. Comparing the guest and host archives stopped the job before an empty file could be published with a valid digest of its own. The passing cases below ran after the copy helper was fixed.

After compilation and before tests, the guest zips the app and hashes the archive. The agent copies it out and compares the host file's hash with the guest hash. After tests, the agent uploads the archive with its digest, which the registry checks against the uploaded bytes. Linux `verify-artifact` then fetches it and compares the downloaded file's hash with the digest returned through the CustomRun.

The [OCI distribution spec](https://github.com/opencontainers/distribution-spec/blob/main/spec.md) describes a blob upload as a POST followed by a PUT carrying the digest. It asks registries to reject a bad digest with a 400. The agent sends these requests through the API server's [service proxy](https://kubernetes.io/docs/tasks/access-application-cluster/access-cluster-services/), using this path:

```text
/api/v1/namespaces/mac-ci/services/http:mac-ci-registry:5000/proxy/v2/...
```

In this setup, the proxy returned an upload `Location` with an HTTP scheme for the HTTPS API server. Following that URL failed with HTTP 400. The agent keeps the returned path and query and sends the PUT through its existing authenticated client.

The upload check used `registry:3.1.2` through that proxy. An upload of `other bytes` claimed the digest of `hello` and returned `DIGEST_INVALID`. The checks also fetched an unknown digest and a successfully published artifact:

```text
GET /v2/                                   -> 200
upload "other bytes" as sha256:2cf24dba...  -> HTTP 400
  {"errors":[{"code":"DIGEST_INVALID","message":"provided digest did not match uploaded content",...}]}
GET unknown digest sha256:0000...0000        -> HTTP 404
  {"errors":[{"code":"BLOB_UNKNOWN","message":"blob unknown to registry",...}]}
GET mac-ci/artifacts@sha256:2e6b4c4c...a0721 -> HTTP 200, 18857 bytes, re-hashes to the same digest
```

The registry rejected bytes that did not match the submitted digest. Download checks happen at the consumers too: the agent before using source, and Linux before unpacking the artifact. Together, these checks cover the guest-to-host copy, registry upload and consumer download.

## Different archives, matching file contents

Three builds produced different zip digests of the same size and recorded the same guest-computed app tree hash. Linux independently confirmed that tree hash for the pass and fresh runs. The zip digest identifies the exact archive from one build. The tree hash compares regular-file paths and contents across builds.

The guest computes the tree hash before tests add their bundle to the app. It sorts the relative paths of regular files, hashes each file and hashes the resulting list. Linux uses the same recipe after unpacking the archive. This excludes timestamps, permissions, symlinks and extended attributes, so a matching tree hash is narrower than full app equivalence.

For the pass and fresh runs, the guest, host, registry and Linux archive checks agreed. The guest's app tree hash `9c3032...6a74` also matched Linux's hash over 6 files. The `fresh` row is a separate build from the same source in another fresh VM.

The `test-fail` run published its artifact too, but Linux verification was skipped because its CustomRun had failed. The compile-error run produced no artifact:

| Run       | Registry digest           | Linux re-hash              | Tree hash                    |
|-----------|---------------------------|----------------------------|------------------------------|
| pass      | `sha256:39fdda0b...8e91`  | equal, 18,857 bytes        | `9c3032...6a74`, equal       |
| test-fail | `sha256:09aa499f...8f33`  | skipped (CustomRun failed) | `9c3032...6a74` (guest only) |
| fresh     | `sha256:2e6b4c4c...a0721` | equal, 18,857 bytes        | `9c3032...6a74`, equal       |

In the passing run, Linux matched both the archive digest and the app-file tree hash:

```text
artifact: want sha256:39fdda0b...8e91
artifact: got  sha256:39fdda0b...8e91 (18857 bytes)
app tree: want 9c3032...6a74
app tree: got  9c3032...6a74 (6 files)
```

File timestamps inside the archives are a possible explanation for the differing zip digests. The archives were not compared entry by entry, and these results cover one fixture on one host.

## Shared storage would need a guest mount

[Tekton workspaces](https://tekton.dev/docs/pipelines/workspaces/) give task steps access to bound volumes. A PVC can carry files between tasks that mount it, but binding it to the Linux tasks does not also mount its storage inside the external VM.

An EFS-backed workspace could give both clients access to the same directory over NFS. [AWS supports Linux and supported EC2 Mac configurations](https://docs.aws.amazon.com/efs/latest/ug/mounting-fs.html). Kubernetes would mount the directory into task Pods, while the guest would need a separate mount with network access and permissions. That option was not tested with the Tart guest.

The store choice depends on which access path already exists:

| Store                                    | Reached from the Mac by                                     | Integrity                                                                                                                                                  | Fit here                                                                             |
|------------------------------------------|-------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------|
| OCI registry in the cluster              | API server service proxy, using the agent's existing client | Blobs are addressed by digest; uploads are checked against it                                                                                              | Uses the existing API connection and gives each blob a digest address                |
| S3-compatible store (S3, MinIO)          | Store endpoint and credentials                              | S3 supports [SHA-256 upload checksums](https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html); objects are addressed by key | Useful when an object store already exists; keys can be derived from hashes          |
| Shared storage behind a PVC, such as EFS | Separate guest mount of the same storage directory          | Depends on the storage and application checks                                                                                                              | An option when the backend supports both clients; the PVC handles only the Pod mount |
| Directory on the Mac host                | Local path                                                  | Requires explicit digest checks                                                                                                                            | Needs an added mount for pods in kind to access it                                   |

S3 or MinIO may fit better when an object store is already available. For registry uploads from the command line, [ORAS](https://oras.land/docs/) provides `oras blob push`. The S3 and ORAS alternatives were not tested here.

## Artifact retention needs persistent storage

For these runs, the registry used `emptyDir` storage with no garbage collection. Replacing its Pod loses the stored files. The registry itself had no authentication. Agent access used the authenticated Kubernetes API service-proxy path, while cluster tasks accessed the registry directly. Longer-lived use needs persistent storage and access control for both paths.

Blob uploads do not require a manifest or tag, and this implementation uploads neither. Manifest references keep blobs reachable during [garbage collection](https://distribution.github.io/distribution/about/garbage-collection/), while tags make artifacts discoverable by name. Retention still depends on the registry's policy.

The service proxy puts artifact traffic through the Kubernetes API server. Throughput was not measured, and the artifacts here are about 18 KB. As in [Part 0](/blog/mac-build-host-private-eks), for a runner fleet I would move artifact transfer off the API server and measure direct store access over its own network path.

## Linux verifies the archive returned by the macOS stage

Linux can fetch the archive and check it against the macOS stage's digest without mounting the guest's disk. That establishes agreement with the producer's expected bytes. The checks still trust the producer and the digest passed through the pipeline. They do not independently establish that the build is trustworthy.

The digest checks do not record which VM belongs to the build or whether deletion completed. The host journal provides that separate record. [Part 3](/blog/tekton-macos-build-vm-lifecycle) follows it from environment checks through execution and VM deletion.
