Bassam Ismail
How to Publish Immutable Release Artifacts From a Private Repository
Engineering

How to Publish Immutable Release Artifacts From a Private Repository

6 min read

The release job was green. The package was signed, but every public download returned 404 because CI had stored it somewhere only repository insiders could reach. Our immutable release artifacts had never crossed the delivery boundary.

TL;DR

Publication needs permanent package identity and separately controlled discovery. The publisher stores the validated artifact under a versioned key, advances latest.json with an ETag condition, and requires downloaders to verify the referenced SHA-256.

Separate validation authority from publication

The workflow has two entry paths with different authority. Manual dispatch validates private state. Publication requires a push of a stable version tag. I limit accepted versions to vMAJOR.MINOR.PATCH, with no leading zeroes or prerelease suffixes, so every component of the release path uses the same ordering domain.

jobs:
  release:
    if: >-
      github.event_name == 'push' &&
      startsWith(github.ref, 'refs/tags/v')
    permissions:
      contents: write
      id-token: write
    runs-on: macos-latest
    steps:
      - name: Authorize stable release tag
        id: policy
        shell: bash
        env:
          EVENT_NAME: ${{ github.event_name }}
        run: |
          tag="${GITHUB_REF#refs/tags/}"
          stable='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
          if [[ "$EVENT_NAME" == push && "$GITHUB_REF" == refs/tags/* && \
                "$tag" =~ $stable ]]; then
            echo "publish=true" >> "$GITHUB_OUTPUT"
          else
            echo "publish=false" >> "$GITHUB_OUTPUT"
          fi
 
      - name: Publish package
        if: steps.policy.outputs.publish == 'true'
        run: ./ci/publish-package.py

The job condition excludes manual runs, including a manual run that selects a tag. The regular expression also rejects prereleases before storage code sees them. Repository rules govern who can create the tag, while the identity provider's trust policy governs who can assume the publishing role. Those controls sit outside the script and deserve their own review.

Acquire one canonical asset

I receive or download one workflow artifact after the package has passed its signature and installer checks. From that point onward, the release process reads one byte sequence, calculates its SHA-256, and uploads it without another build or signing step. Signing timestamps can make two valid builds differ byte for byte, so a retry reuses the accepted workflow artifact rather than manufacturing a replacement. A retry that finds an existing object compares the stored bytes with the accepted digest. A different digest records a version conflict. It does not authorize replacement.

RELEASE IDENTITY AND DISCOVERYstable tagworkflow artifactvalidated onceversioned packageimmutable identitylatest.jsonmutable discoverydownloaderverifies SHA-256[ The pointer can change; the referenced package cannot. ]

Package validation decides whether an artifact is acceptable, while SHA-256 detects corruption or accidental substitution after that decision. The digest is not an authenticity proof because an attacker who can replace both latest.json and the package can replace the digest too. Downloaders still verify the package signature, and the publishing role that updates the pointer remains a tightly controlled trust boundary.

Publish immutable release artifacts through ordered discovery

A release such as v1.4.2 lives at a permanent key:

releases/app/v1.4.2/app-v1.4.2.pkg

Clients discover it through releases/app/latest.json:

{
  "version": "v1.4.2",
  "key": "releases/app/v1.4.2/app-v1.4.2.pkg",
  "sha256": "<64 lowercase hexadecimal characters>"
}

A downloader follows the key in the JSON, hashes the received bytes, and rejects a mismatch. This design has no mutable latest binary. Discovery changes by replacing latest.json; package bytes remain at their versioned key.

Amazon S3 conditional writes supply the storage controls. IfNoneMatch="*" protects the versioned package from replacement. Reading the pointer returns both its contents and ETag; IfMatch then prevents a publisher from updating a pointer that changed after that read.

Here is the core publisher. artifact_path names the single workflow artifact received by this job. The code catches only the two expected storage outcomes. Authentication, network, throttling, and server failures escape immediately because they are not evidence of a publication race.

import hashlib
import io
import json
 
import boto3
from botocore.exceptions import ClientError
from packaging.version import Version
 
s3 = boto3.client("s3")
 
 
def error_code(exc):
    return exc.response["Error"]["Code"]
 
 
def stable(value):
    parsed = Version(value.removeprefix("v"))
    if parsed.is_prerelease or parsed.is_devrelease or parsed.is_postrelease:
        raise ValueError(f"stable release required: {value}")
    if f"v{parsed}" != value:
        raise ValueError(f"noncanonical version: {value}")
    return parsed
 
 
def publish(bucket, artifact_path, version, prefix="releases/app"):
    release = stable(version)
    with open(artifact_path, "rb") as source:
        package = source.read()
 
    digest = hashlib.sha256(package).hexdigest()
    package_key = f"{prefix}/{version}/app-{version}.pkg"
    pointer_key = f"{prefix}/latest.json"
    pointer = json.dumps(
        {"version": version, "key": package_key, "sha256": digest},
        separators=(",", ":"),
    ).encode()
 
    try:
        s3.put_object(
            Bucket=bucket,
            Key=package_key,
            Body=io.BytesIO(package),
            IfNoneMatch="*",
            Metadata={"version": version, "sha256": digest},
            CacheControl="public,max-age=31536000,immutable",
        )
    except ClientError as exc:
        if error_code(exc) != "PreconditionFailed":
            raise
        existing = s3.get_object(Bucket=bucket, Key=package_key)["Body"].read()
        if hashlib.sha256(existing).hexdigest() != digest:
            raise RuntimeError(f"version conflict at {package_key}")
 
    for _ in range(5):
        try:
            current_response = s3.get_object(Bucket=bucket, Key=pointer_key)
            etag = current_response["ETag"]
            current = json.loads(current_response["Body"].read())
        except ClientError as exc:
            if error_code(exc) != "NoSuchKey":
                raise
            try:
                s3.put_object(
                    Bucket=bucket,
                    Key=pointer_key,
                    Body=pointer,
                    IfNoneMatch="*",
                    ContentType="application/json",
                    CacheControl="public,max-age=60",
                )
                return
            except ClientError as create_exc:
                if error_code(create_exc) != "PreconditionFailed":
                    raise
                continue
 
        current_version = stable(current["version"])
        if current_version > release:
            return
        if current_version == release:
            if current["sha256"] == digest and current["key"] == package_key:
                return
            raise RuntimeError(f"equal-version conflict at {pointer_key}")
 
        try:
            s3.put_object(
                Bucket=bucket,
                Key=pointer_key,
                Body=pointer,
                IfMatch=etag,
                ContentType="application/json",
                CacheControl="public,max-age=60",
            )
            return
        except ClientError as exc:
            if error_code(exc) != "PreconditionFailed":
                raise
 
    raise RuntimeError("pointer contention did not converge")

packaging.version.Version performs the comparison. stable() limits this protocol to canonical stable releases, so prerelease ordering cannot slip in through another caller. The example reads the full package and any conflicting object into memory; large artifacts need streamed hashing and comparison. Reconciliation also performs a full object read, CDN caches may expose the previous pointer for its configured lifetime, and the five-attempt contention limit is an operational policy rather than a convergence guarantee.

Reconcile the orphan-object window

The package write and pointer update are separate operations, so publication is not atomic. A runner can stop after storing the versioned package but before advancing latest.json. Clients still resolve the previous release, and the new object becomes an orphan until reconciliation examines it.

The reconciler handles each observed state explicitly:

Observed stateDecision
Versioned object absentAttempt the conditional package write
Same version and digestReuse the stored package
Same version, different digestStop with a version conflict
Pointer already newerLeave discovery unchanged
Candidate newer than pointerAttempt the ETag-guarded pointer update
Pointer race lostRead again and reevaluate

Normal publication cannot move discovery backward because every pointer change is compared with the version and ETag just read. Rollback uses a separate authorized path that deliberately selects an older immutable key.

Immutable identity and mutable discovery live in different consistency domains. Reconciliation joins them after interrupted or concurrent writes, while signature verification remains the proof that the downloaded package came from an authorized release process.

Comparison mechanics

The publisher parses both versions with Version, rejects anything outside the stable subset, and compares the parsed values. A greater current version leaves the pointer untouched. Equal versions require matching keys and digests. Only a greater candidate proceeds to the ETag-guarded update.

FAQ

What should a downloader cache?

Cache the versioned package as immutable and give latest.json a short lifetime. Resolve the pointer before requesting the package, then hash the downloaded bytes and reject a mismatch.

What happens when a signed rebuild has a different digest?

Reuse the previously accepted canonical artifact after validating it. A differing rebuild signals a conflict and leaves both the stored package and discovery pointer unchanged.

Can this protocol publish prereleases?

No. The tag guard and publisher accept stable releases only. Prerelease support requires a deliberate expansion of the validation and ordering policy.

More to read