Files
Oleksandr ShyshatskyiandClaude Opus 5 c3a64e0394 The stock firmware is a pinned download like everything else
config.env.example opened with DESKTOP="$HOME/Desktop", and four settings
hung off it. Nothing in bin/, pkgs/, qa/ or installer/ ever read it: three
of the four were commented-out overrides for files `make vendor` already
fetches, and the fourth, BUSYBOX_BIN, names a binary with no source at all.
What DESKTOP really did was carry the stock FlashForge .tgz -- the one input
a person had to go and find before they could build anything.

It did not have to be. .github/workflows/ci.yml has been fetching that exact
file for as long as printer-sim has run, from ghzserg/FF, pinned by sha256,
with its own get(), its own cache and its own secrets -- a second spelling of
"download the firmware and check the hash" that no human path shared. The
pins here are those pins, verified byte-for-byte against a fresh download of
both assets.

So versions.env pins the stock package the way it pins everything else,
bin/fetch-assets.sh downloads it into vendor/, and bin/common.sh defaults
STOCK_TGZ_* there. A cold checkout is now `cp config.env.example config.env
&& make build`, with nothing to edit.

--stock is a separate flag from --all, not folded into it. The packaging lane
wants no FlashForge package -- that is most of the point of it being runnable
in CI on a bare checkout -- so `--all` stays "every payload piece, no
firmware", `make build` asks for the one model it is building, and `make
vendor` (--all --stock) takes both.

The one download that will not overwrite an override. HELIX_TGZ and friends
name a public release anyone can re-fetch in a minute; STOCK_TGZ_* names 93MB
of proprietary firmware someone downloaded by hand, and clobbering it on a
hash mismatch would destroy the only copy on the machine. It is skipped
instead, and unchecked -- their file, their problem.

CI loses its duplicate fetcher, its stock/ cache and the STOCK_URL_* secrets.
A fork testing its own firmware re-pins versions.env, which works locally too;
the secrets only ever worked in CI.

qa/lib/config.py reads versions.env for the pinned filenames so stock_for()
looks where the download actually lands. An explicit STOCK_TGZ_* still wins.

An existing config.env keeps working untouched: its paths are now overrides,
so the build skips the download and uses the file it always used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPU6RR4UJH2Qf9DR5hb2gg
2026-08-30 16:01:47 +03:00

160 lines
6.4 KiB
Python

"""config.env and test.env, read the way the build reads them.
Lifted from tools/replica/ffsim/config.py rather than imported from it. The
endgame is that ffsim is deleted, and a qa/ that imports from it would block
that deletion or break on it. The file format is stable and the parser is
thirty lines; the coupling would cost more than the duplication.
The two files are shell, and bin/*.sh still sources them, so they stay shell:
they use quoting, `$ROOT`, and occasionally a command substitution, and a
hand-rolled KEY=VALUE parser would agree with bash right up until the day it
did not. So bash reads them and reports back what it ended up with. That keeps
exactly one definition of what these files mean.
config.env the BUILD config. Everything that can reach a printer.
test.env replica-only: the factory image, the partition sizes.
Kept apart so nothing in it can end up inside a package.
THE ENVIRONMENT WINS OVER BOTH FILES. An empty `PRINTER_IMAGE=` sitting in
test.env used to overwrite an explicit `PRINTER_IMAGE=foo ...` on the command
line, so the override silently did nothing and the run tested a different image
than the one it was told to. The rule is about being SET, not about being
non-empty: `PRINTER_IMAGE= make qa-replica` deliberately means "build locally",
not "use whatever the file says".
"""
import os
import subprocess
from .paths import ROOT
# What the harness reads. Anything else in those files belongs to the build.
WANTED = (
"FF_KEY",
"STOCK_TGZ", "STOCK_TGZ_CREATOR5PRO", "STOCK_TGZ_CREATOR5",
# Not config at all -- the pinned filenames out of versions.env, so that
# stock_for can look where fetch-assets.sh actually put the download. The
# alternative was spelling "vendor/<pinned name>" a second time here, and a
# default that exists twice is a default that drifts.
"STOCK_FILE_CREATOR5PRO", "STOCK_FILE_CREATOR5",
"PRINTER_IMAGE", "PROG_MB", "DATA_MB",
)
# Of those, the ones a caller is expected to override per-run.
OVERRIDABLE = ("PRINTER_IMAGE", "PROG_MB", "DATA_MB", "FF_KEY")
# `.` on a file with a syntax error returns non-zero and does NOT stop the
# shell, so the && form quietly carried on with half the file applied. That is
# the disease being cured here: a broken config that looks like an absent one.
# Each source is checked, and its own exit code says which file.
_DUMP = r'''
set -a
if [ -f "$1" ]; then . "$1" || exit 90; fi
if [ -f "$2" ]; then . "$2" || exit 91; fi
if [ -f "$3" ]; then . "$3" || exit 92; fi
set +a
for key in %s; do
eval "value=\${$key-}"
printf '%%s=%%s\0' "$key" "$value"
done
''' % " ".join(WANTED)
class ConfigError(Exception):
"""The config itself is broken -- not absent, broken.
Deliberately not a skip. A syntax error in config.env leaves STOCK_TGZ_*
empty, and a harness that treats that as "nothing to test here" reports a
clean skip on a machine that could have run everything. Naming the file
that failed is the entire point.
"""
class Config:
"""What the two files say, with the environment layered back on top."""
def __init__(self, values, config_env, test_env):
self.values = values
self.root = ROOT
self.config_env = config_env
self.test_env = test_env
@classmethod
def load(cls):
config_env = os.environ.get("CONFIG_ENV") or str(ROOT / "config.env")
test_env = os.environ.get("TEST_ENV") or str(ROOT / "test.env")
versions_env = str(ROOT / "versions.env")
# versions.env FIRST, so config.env still wins on anything both name.
# It is read for the pinned stock filenames alone -- see WANTED.
dumped = subprocess.run(
["bash", "-c", _DUMP, "qa", versions_env, config_env, test_env],
cwd=str(ROOT), capture_output=True, text=True)
if dumped.returncode != 0:
which = {90: versions_env, 91: config_env,
92: test_env}.get(dumped.returncode)
raise ConfigError(
"could not read %s:\n%s"
% (which or ("%s / %s / %s"
% (versions_env, config_env, test_env)),
dumped.stderr.strip()))
values = {}
for item in dumped.stdout.split("\0"):
if "=" in item:
key, _, value = item.partition("=")
values[key] = value
# The environment wins -- see the module docstring. Being SET is what
# counts, so this tests membership rather than truthiness.
for key in OVERRIDABLE:
if key in os.environ:
values[key] = os.environ[key]
return cls(values, config_env, test_env)
def get(self, key, default=""):
return self.values.get(key, default) or default
@property
def ff_key(self):
return self.get("FF_KEY", "FFP0331&*%root")
def _vendored_stock(self, key):
"""Where bin/fetch-assets.sh puts the stock package for that key.
The common case now, and on a fresh checkout the only case: config.env
carries no path at all and `make vendor` has downloaded the pin. An
explicit STOCK_TGZ_* in config.env still comes first -- that is what it
is for -- so this is a fallback, not an override.
"""
# Only the per-model keys have a pin. Bare STOCK_TGZ is a path someone
# typed to force one package, and has no vendored counterpart.
if not key.startswith("STOCK_TGZ_"):
return ""
name = self.get(key.replace("STOCK_TGZ_", "STOCK_FILE_", 1))
return str(self.root / "vendor" / name) if name else ""
def stock_for(self, package_name=None):
"""The stock package that is the authentic baseline for this build.
With a package name, pick by its model prefix: the two models ship
different firmwareExe binaries and each refuses to install on the
other, so the baseline has to match.
"""
if package_name:
if package_name.startswith("Creator5Pro-"):
keys = ("STOCK_TGZ_CREATOR5PRO",)
elif package_name.startswith("Creator5-"):
keys = ("STOCK_TGZ_CREATOR5",)
else:
keys = ("STOCK_TGZ",)
else:
keys = ("STOCK_TGZ_CREATOR5PRO", "STOCK_TGZ")
for k in keys:
for path in (self.get(k), self._vendored_stock(k)):
if path and os.path.isfile(path):
return path
return ""