Portability Contract and Version Gates¶
Page Maps¶
graph LR
family["Reproducible Research"]
program["Deep Dive Make"]
section["Portability Hermeticity Failure Modes"]
page["Portability Contract and Version Gates"]
capstone["Capstone evidence"]
family --> program --> section --> page
page -.applies in.-> capstone
flowchart LR
orient["Orient on the page map"] --> read["Read the main claim and examples"]
read --> inspect["Inspect the related code, proof, or capstone surface"]
inspect --> verify["Run or review the verification path"]
verify --> apply["Apply the idea back to the module and capstone"]
Portability is one of the most abused words in build engineering.
Teams say things like:
- "it should be portable"
- "it mostly works on macOS"
- "CI has a newer Make, but that is probably fine"
- "if Bash is missing, people can install it"
None of that is a contract. It is hope with a few anecdotes attached.
This page is about replacing that habit with a clearer one:
say exactly which tools, versions, and shell behaviors the build requires, and fail early when those conditions are not met.
That is not less portable. It is more honest. Honest boundaries are what let you add safe fallbacks without turning the build into folklore.
The sentence to keep¶
When someone asks whether the build is portable, the right answer is not "yes."
The right answer sounds more like this:
this build requires GNU Make 4.3 or later, a POSIX shell, and
python3; grouped targets are optional because we provide a stamp fallback.
That sentence is useful because another engineer can test it.
What a portability contract actually contains¶
A real portability contract usually needs four parts:
- the minimum supported Make behavior
- the shell model the recipes assume
- the required external tools
- the optional features and their fallbacks
If any one of those is left implied, the build starts leaning on workstation luck.
Required is different from optional¶
One of the biggest mistakes people make is treating every tool or feature as if it were equally negotiable.
They are not.
Use this split:
| Kind | Meaning | Build behavior |
|---|---|---|
| required | without it, correctness is undefined | fail fast |
| optional | nice to have, but not essential to core correctness | warn or use a safe fallback |
| unsupported | explicitly outside the contract | fail clearly and say why |
This is important because "best effort" builds often hide correctness drift behind soft fallbacks that were never reviewed.
Prefer feature gates to guessed version ranges¶
Suppose the build wants grouped targets &:. The important question is not "which machine
am I on?" The important question is:
does this Make provide the semantics required for grouped targets?
That is why version gates should be tied to capabilities, not to tribal knowledge.
GNU Make reports supported semantics in $(.FEATURES). Use that evidence when a token
exists:
ifeq ($(origin MAKE_VERSION),undefined)
$(error this repository requires GNU Make)
endif
REQUIRED_MAKE_FEATURES := grouped-target second-expansion
MISSING_MAKE_FEATURES := $(filter-out $(.FEATURES),$(REQUIRED_MAKE_FEATURES))
ifneq ($(strip $(MISSING_MAKE_FEATURES)),)
$(error GNU Make $(MAKE_VERSION) lacks required features: $(MISSING_MAKE_FEATURES))
endif
This accepts a future Make release that still advertises the required behavior. A text
filter such as 4.3% 4.4% 4.5% 5.% accidentally rejects 4.10 and 6.0; expanding that
list after every release is not a capability model.
If required behavior has no feature token, keep the human support statement and implement one of these machine checks:
- a bounded probe that parses or executes the exact required behavior
- a maintained numeric-version helper with boundary tests
- an installation or container boundary that supplies a pinned Make
Do not claim robust semantic-version comparison with a collection of prefix filters.
Keep version policy and feature discovery distinct¶
A project can require "GNU Make 4.3 and later" as its supported test range while still
asking .FEATURES whether one runtime exposes grouped targets. These are different claims:
| Claim | Evidence | Failure meaning |
|---|---|---|
| supported release range | tested version matrix | maintainers have not promised this runtime |
| required feature | .FEATURES or behavioral probe |
runtime cannot express the graph correctly |
| observed version | MAKE_VERSION in diagnostics |
identity only; not proof of one behavior |
The contract should print all three when they differ. That lets an operator distinguish "known but missing capability" from "untested release."
The shell is part of the contract too¶
Recipes run inside a shell. That means the shell is not an implementation detail. It is a semantic dependency.
If your recipes assume:
- Bash arrays
pipefail[[ ... ]]- brace expansion
- process substitution
then you do not have a plain POSIX shell contract anymore. You have a Bash contract.
That can still be a valid decision. The mistake is hiding it.
For this course, the healthy default is:
- write recipes for POSIX
/bin/sh - keep shell behavior simple and explicit
- choose a stricter shell contract only when the benefit is worth stating aloud
Setting SHELL is necessary but not sufficient. Make does not normally import SHELL
from the environment on Unix-like systems, and recipe behavior also depends on
.SHELLFLAGS. Declare both:
-e has shell-specific edge cases and POSIX does not provide pipefail. If pipeline
failure is part of correctness, either avoid the pipeline, test each command explicitly,
or declare a shell that provides the required behavior.
A simple shell mistake¶
This recipe is not POSIX /bin/sh:
It may work on one machine where /bin/sh is really Bash-compatible, then fail on another
machine where /bin/sh is stricter.
If you only need POSIX behavior, write:
This is not glamorous advice. It is the kind of choice that prevents avoidable portability incidents.
Tool requirements should be declared once¶
Many inherited Makefiles discover tools in a scattered, repetitive way:
PYTHON := $(shell command -v python3 || command -v python)
TAR := $(shell command -v gtar || command -v tar)
AWK := $(shell command -v gawk || command -v awk)
This creates three problems:
- the policy is spread out
- the fallback order is hard to review
- the build may silently switch tools with different semantics
A clearer pattern makes each tool variable an executable identity and keeps arguments in separate variables:
PYTHON ?= python3
TAR ?= tar
PYTHONFLAGS ?=
TARFLAGS ?=
ifneq ($(words $(PYTHON)),1)
$(error PYTHON must name one executable path; put arguments in PYTHONFLAGS)
endif
.PHONY: contract-check
contract-check:
@command -v "$(PYTHON)" >/dev/null 2>&1 || { \
printf 'contract: missing Python executable %s\n' "$(PYTHON)" >&2; \
exit 2; \
}
@command -v "$(TAR)" >/dev/null 2>&1 || { \
printf 'contract: missing archive executable %s\n' "$(TAR)" >&2; \
exit 2; \
}
Now the contract is:
- these are the tool names we expect
- callers may override them intentionally
- the build checks them in one place
That is much easier to teach and audit.
Presence is only the first gate. A complete contract distinguishes:
- resolution — does this executable name resolve?
- identity — which path and version resolved?
- capability — does it implement the required behavior?
- artifact input — should a change in identity invalidate outputs?
The contract check owns the first and third claims. A convergent toolchain manifest owns the fourth when tool identity changes artifact meaning.
A requirement checked inside publication is too late¶
Consider this artifact rule:
REQUIRED_TOOL ?= bijux-missing-tool
RESULT := build/result.txt
$(RESULT): | build/
$(REQUIRED_TOOL) --version > build/tool-version.txt
printf 'result=complete\n' > $@
The build fails because the tool does not exist, but failure is not the only observation.
The shell opens build/tool-version.txt for redirection before it tries to execute the
command. The workspace is left with a zero-byte file.
That residue creates a second bug opportunity. A later script that asks only whether the path exists can mistake a failed probe for version evidence.
Move the requirement ahead of publication:
REQUIRED_TOOL ?= python3
.PHONY: contract-check
contract-check:
@command -v "$(REQUIRED_TOOL)" >/dev/null 2>&1 || { \
printf 'contract: missing required tool %s\n' "$(REQUIRED_TOOL)" >&2; \
exit 2; \
}
.PHONY: all
all: $(RESULT)
$(RESULT): | contract-check
This gate has a deliberately narrow responsibility: reject an unavailable executable
before any artifact recipe begins. Compatibility is a separate question; finding
python3 does not prove that every Python version is supported.
The order-only edge is important. This shape would not establish ordering:
Normal prerequisites of the same target may run concurrently under -j. Listing the gate
first is not a sequencing promise. Attach the gate to every target that can begin
publication, or attach it to a file node shared by those targets.
Prove both rejection and acceptance¶
A missing-tool test alone can produce false confidence. A gate that rejects every value would pass it. Pair the negative case with an available-tool control.
Run the course audit from the repository root:
Read these rows in the generated summary.tsv:
| Model | Exit and files | Finding |
|---|---|---|
| late tool check | nonzero; empty version file; no result | LATE_TOOL_FAILURE_LEFT_RESIDUE |
| missing contract gate | nonzero; deliberate message; no files | REQUIRED_TOOL_REJECTED_EARLY |
| available contract gate | zero; version and result files | REQUIRED_TOOL_ACCEPTED |
Then inspect:
Use the traces to locate the failure boundary and the workspace files to judge the publication consequence. Exit status alone cannot distinguish a clean early rejection from a dirty late failure.
Fallbacks must preserve correctness¶
Not all fallbacks are healthy.
Healthy fallback:
- grouped targets unavailable, so use a stamp-governed generation rule
Unhealthy fallback:
- grouped targets unavailable, so only generate one of the two outputs and hope the other one is close enough
The first fallback preserves the logical event. The second one changes the meaning of the build.
Whenever you add a fallback, ask:
does this fallback preserve the same correctness contract, or does it silently lower the standard?
That question is more important than whether the fallback feels convenient.
A contract file with testable branches¶
A practical contract file might look like this:
ifeq ($(origin MAKE_VERSION),undefined)
$(error GNU Make is required)
endif
REQUIRED_MAKE_FEATURES := grouped-target second-expansion
MISSING_MAKE_FEATURES := $(filter-out $(.FEATURES),$(REQUIRED_MAKE_FEATURES))
ifneq ($(strip $(MISSING_MAKE_FEATURES)),)
$(error GNU Make lacks required features: $(MISSING_MAKE_FEATURES))
endif
SHELL := /bin/sh
.SHELLFLAGS := -eu -c
PYTHON ?= python3
PYTHONFLAGS ?=
ifneq ($(words $(PYTHON)),1)
$(error PYTHON must name one executable path)
endif
.PHONY: contract-check
contract-check:
@command -v "$(PYTHON)" >/dev/null 2>&1 || { \
printf 'contract: missing %s\n' "$(PYTHON)" >&2; \
exit 2; \
}
This is not the only good shape, but it demonstrates the habit:
- declare the boundary
- name the capability
- make the shell contract explicit
- keep feature rejection visible before unsupported syntax is selected
- keep executable identity separate from tool arguments
Test the contract as a matrix:
| Runtime case | Required result |
|---|---|
| every required feature present | parsing continues |
| one required feature absent | parsing rejects and names only the missing features |
| required executable available | contract target succeeds |
| required executable absent | contract target fails before publication |
| executable variable contains arguments | parsing rejects and points to the flags variable |
Test the supported runtime range in CI as well. A local feature gate cannot substitute for the versions maintainers actually exercise.
Failure signatures worth recognizing¶
"It works locally, but CI says the syntax is invalid"¶
That often means the local Make or shell supports a feature the contract never declared.
"The fallback path worked, but outputs changed subtly"¶
That means the fallback was not actually safe. It preserved execution, not semantics.
"Nobody knows which tool was used on that machine"¶
That usually means tool discovery is happening implicitly or in too many places.
"The tool is missing, and an empty version file remains"¶
The requirement was discovered after publication began. Move it to a contract gate and test that no evidence paths exist after rejection.
"We support everything" but the build has machine-specific branches everywhere¶
That is not broad support. It is an undocumented compatibility maze.
A good review question¶
When someone claims the build is portable, ask them to write a four-line summary:
- required Make version
- required shell behavior
- required tools
- optional features and fallbacks
If they cannot do that, the portability boundary is not clear enough yet.
What to practice from this page¶
Take one real build in the repository and write its portability contract in plain language:
- which Make is required
- which shell semantics are assumed
- which tools are mandatory
- which features are optional
- which fallback preserves correctness when the optional feature is missing
Then use the environment contract audit to explain why these are different claims:
- the missing tool is rejected before publication
- an available tool passes the same gate
- the tool's version is compatible with the artifact recipe
The audit proves the first two. Your declared version policy must prove the third.
End-of-page checkpoint¶
Before leaving this lesson, make sure you can explain:
- why portability needs a declared boundary instead of optimistic language
- why required, optional, and unsupported are different categories
- why version gates should be tied to capabilities
- why the shell belongs in the contract
- why a fallback is only good if it preserves correctness rather than merely keeping the build alive
- why required-tool tests need both a rejection case and an available control
- why a nonzero exit does not prove that failure left the workspace clean