How to Publish Immutable Release Artifacts From a Private Repository
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.pyThe 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.
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.pkgClients 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 state | Decision |
|---|---|
| Versioned object absent | Attempt the conditional package write |
| Same version and digest | Reuse the stored package |
| Same version, different digest | Stop with a version conflict |
| Pointer already newer | Leave discovery unchanged |
| Candidate newer than pointer | Attempt the ETag-guarded pointer update |
| Pointer race lost | Read 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.
