A staging test establishes facts about the artifact and environment that actually ran. Building again before production creates another output whose identity must be checked or whose behavior must be revalidated. Promotion carries an already verified artifact to the next environment and records that decision. It removes one source of uncertainty, but it does not make staging and production equivalent or eliminate the need to verify the resulting deployment.

Name the unit that moves

Use an immutable package identifier or content digest to identify the build. A moving tag, branch name, or source commit alone does not uniquely establish the deployed bytes. Toolchains, dependency resolution, embedded settings, or packaging can change between builds of the same source. A reproducible rebuild might yield identical bytes; compare the result before treating it as the previously tested artifact.

Keep the build stage separate from the release stage. A release combines a particular artifact with the configuration required for a deployment. Runtime then executes that combination against actual dependencies and data. Record the artifact identity and a suitable configuration revision, without exposing secrets. If frontend settings are compiled into JavaScript during packaging, changing them creates different bytes and therefore a different artifact requiring validation.

When it breaks

A pipeline can say it promotes a release while a deployment script quietly recompiles it. Another pipeline can download the correct archive and overwrite a configuration file inside it. Both change the tested input. Decide which settings belong outside the immutable payload and make the deployment read them through the application’s existing configuration interface. Do not assume a checksum checked before a later mutation still describes the running files.

The same artifact can behave differently with a changed database schema, feature flag, dependency version, permission, resource limit, or network path. Promotion establishes continuity of bytes, not continuity of the entire execution context. Validate important production behavior after deployment and compare the relevant environment differences when staging results do not reproduce.

Rollback also has prerequisites. An older executable may not understand newly written records or a completed destructive schema migration. Returning a pointer to an earlier artifact cannot undo those external effects. Keep rollback instructions tied to data compatibility, configuration, and dependency state. When reversal is unavailable, plan the necessary forward repair rather than promising that selecting the old image always restores service.

Check it quickly

Save this as promote.py and run python3 promote.py. The script uses Python’s standard library, temporary directories, and HTTP servers bound only to loopback. It writes one tiny application artifact, records its digest, and copies it to two local environments. The label production-simulation names a temporary directory; no external deployment occurs.

from hashlib import sha256
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from tempfile import TemporaryDirectory
from threading import Thread
from urllib.request import ProxyHandler, build_opener
import json
import runpy
import shutil

source = 'def render(config):\n    return {"build": "local-build-41", "tier": config["tier"]}\n'
digest = sha256(source.encode()).hexdigest()


def verify(path):
    if sha256(path.read_bytes()).hexdigest() != digest:
        raise ValueError('artifact changed')


def handler(path, config):
    verify(path)
    render = runpy.run_path(str(path))['render']

    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):
            data = path.read_bytes() if self.path == '/artifact' else json.dumps(render(config)).encode()
            self.send_response(200)
            self.send_header('Content-Length', str(len(data)))
            self.end_headers()
            self.wfile.write(data)

        def log_message(self, *args):
            pass

    return Handler


with TemporaryDirectory() as directory:
    root = Path(directory)
    artifact = root / 'build.py'
    artifact.write_text(source)
    opener = build_opener(ProxyHandler({}))
    observed = []
    for tier in ('staging-local', 'production-simulation'):
        destination = root / tier
        destination.mkdir()
        deployed = destination / 'app.py'
        shutil.copyfile(artifact, deployed)
        config = {'tier': tier}
        server = ThreadingHTTPServer(('127.0.0.1', 0), handler(deployed, config))
        thread = Thread(target=server.serve_forever)
        thread.start()
        try:
            origin = f'http://127.0.0.1:{server.server_port}'
            with opener.open(origin + '/artifact', timeout=5) as response:
                received_digest = sha256(response.read()).hexdigest()
            with opener.open(origin + '/', timeout=5) as response:
                rendered = json.load(response)
            assert received_digest == digest
            assert rendered == {'build': 'local-build-41', 'tier': tier}
            observed.append({'artifact_sha256': received_digest, 'response': rendered,
                             'config_sha256': sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()})
        finally:
            server.shutdown()
            thread.join()
            server.server_close()
    deployed.write_text(source + '# rebuilt\n')
    try:
        verify(deployed)
    except ValueError:
        changed_rejected = True
    else:
        changed_rejected = False
    assert changed_rejected
    print(json.dumps({'environments': observed, 'changed_copy_rejected': changed_rejected}))

Before starting each server, the script verifies the copied file against the original digest and loads its application function. Fetching /artifact returns the deployed bytes; fetching / executes that function with the environment’s separate configuration. Both HTTP downloads have the same artifact hash, and both responses name local-build-41.

The response’s tier differs between environments, and the recorded configuration hashes differ as well. Those configurations contain only harmless labels. The result demonstrates how identical application bytes can intentionally produce different behavior. After the servers stop, a changed copy is rejected by the same digest check, so a silent rebuild cannot pass this particular identity gate.

The fixture does not model a registry, authentication, database migration, production traffic, or rollback. It establishes the local transfer, validation, and configuration boundary using actual file copies and HTTP responses. It cannot establish that a real deployment system enforces the same rules.

Verify the release that actually runs

Attach the promotion decision to the artifact and configuration revision, then check the deployed process or served files instead of relying solely on a successful upload. Retain the approved artifact for the required recovery window. If a later step generates a different package, give that output a new identity and its own relevant validation. A useful promotion record answers which bytes moved, what configuration accompanied them, and what behavior was observed after the move.

Further reading

Sources