scadbatch
One .scad model plus a list of parameter sets gives you one output file per set per output
format — in any format OpenSCAD can write, several at a time, with a machine-readable record of
what happened.
These pages go deeper on one thing each. For installation and a first run, start from the project README.
- Parameter files — the CSV and Customizer JSON formats: what a blank cell means, how cell text becomes an OpenSCAD literal, how output files are named, which parameter files are refused and why.
- Running a batch — parallelism, output formats, timeouts, skipping existing
output, which OpenSCAD is used, and the
--summaryJSON, including whycounts.errorsis notcounts.failed. - Related tools — the other tools that batch-export from OpenSCAD, what each is good at, when a plain shell loop is the right answer, and why this exists separately.
- Cutting a release — how a release is cut, and what the release workflow actually does.
Parameter files
A parameter file says which models to build. Each parameter set is one row of a CSV or one entry in a Customizer JSON file, and becomes one output file per output format.
Both formats describe the same thing, and scadbatch csv2json / scadbatch json2csv convert
between them. They are not equivalent in how they reach OpenSCAD, which is the subject of
How values reach OpenSCAD below.
CSV
The first row is the header: one column per parameter, named exactly as the variable is named in
the .scad file. Each later row is one parameter set.
exported_filename,width,height,depth
cube_small,10,10,10
cube_medium,20,20,20
cube_large,30,30,30
exported_filename is optional and is not a parameter. When present it names the output file;
see Output names.
A parameter the file does not mention keeps the default from the .scad file. That is the point
of the format: list only what varies.
A blank cell leaves the parameter unset
An empty or whitespace-only cell is not the empty string. The parameter is left out of the set entirely, so the model’s own default applies:
exported_filename,w,h
blank_w,,20
reads as {'exported_filename': 'blank_w', 'h': '20'} — no w at all.
This is deliberate, because the alternative is worse. OpenSCAD types each parameter by the
default the model declares, and silently ignores a value it cannot convert: against w = 10,
passing "" leaves w at 10 and exits 0 with no warning. A blank cell that meant “empty
string” would therefore build a model the row never asked for, and nothing in the output would
say so.
To pass an actual empty string, write a cell whose text is two quote characters. In CSV that
needs escaping, so the file contains """""":
exported_filename,w,h
quoted_empty,"""""",20
which reads w as "" and passes the empty string. This only does what you want where the
model’s default for that parameter is itself a string.
read_csv logs which parameters each row leaves unset, since OpenSCAD will not.
A row must match the header
A row with more or fewer cells than the header is refused, naming the file, the row and both
counts. Python’s CSV reader pads a short row with None and collects a long row’s overflow
under a None key, either of which previously became a -D flag like -DNone=["2"] — which
OpenSCAD accepts, ignores, and exits 0 on.
A header that names a column twice is refused too, naming the file and the column. Each row is
read by column name, so only the last of the repeated columns would survive. Two names that
differ only by surrounding whitespace, such as d and d, count as a repeat: OpenSCAD reads
-D d=11 as an assignment to d, and keeps the later of the two. Names are case-sensitive, as
they are in OpenSCAD, so d and D are two parameters.
Customizer JSON
The format OpenSCAD’s own Customizer saves, so a .json saved from the GUI works unchanged:
{
"parameterSets": {
"cube_small": { "width": "10", "height": "10", "depth": "10" },
"cube_medium": { "width": "20", "height": "20", "depth": "20" }
},
"fileFormatVersion": "1"
}
The key of each entry in parameterSets is the set name, used for the output file name and for
-P. Values are strings, as OpenSCAD writes them.
What is refused
A parameter file is refused rather than partially understood when it cannot be read faithfully. Each message names the file and the place in it.
| the file has | why it is refused |
|---|---|
a root, parameterSets, or a set that is not an object | there is nothing to read as parameter sets; this previously raised AttributeError from inside the reader and reached you as a traceback |
NaN, Infinity or -Infinity anywhere | OpenSCAD’s JSON reader rejects the whole document, so every set exported at the model’s defaults, exit code 0 — loud but not fatal: ERROR: Cannot open Parameter Set 'p.json': <unspecified file>(1): expected value, which set -e will not catch |
| a number too large for a double | it parses, then cannot be used |
| the same name twice at any level | for a duplicated set name, this tool kept the last and OpenSCAD selects the first, so the run reported values it did not build |
NaN and Infinity are not valid JSON, but Python’s json module accepts them by default,
which is how they got this far.
How values reach OpenSCAD
This differs by format, and it is the one place the two are not interchangeable.
JSON goes to OpenSCAD natively, with the file and the set name:
openscad -o out/cube_small.stl --export-format=binstl \
-p examples/simpleCube/simpleCube.json -P cube_small \
examples/simpleCube/simpleCube.scad
OpenSCAD reads the set itself, so the model’s declared default fixes each parameter’s type. A value that cannot convert to that type is ignored and the default stands.
CSV becomes -D flags, one per parameter, in the order the columns appear:
openscad -o out/cube_small.stl --export-format=binstl \
-Ddepth=10 -Dheight=10 -Dwidth=10 \
examples/simpleCube/simpleCube.scad
-D takes an OpenSCAD expression, so each value has to be serialised as an OpenSCAD literal —
which is where quoting goes wrong if it is done by string concatenation. A string containing a
quote, a list of lists, a boolean spelled the Python way: each has exactly one correct form, and
the serialisation is a single function with one rule per type.
Cell text to value
| cell | value | as a -D literal |
|---|---|---|
10 | int 10 | 10 |
-2.5 | float -2.5 | -2.5 |
1e3 | float 1000.0 | 1000.0 |
true / false | bool | true / false |
undef | none | undef |
abc | str 'abc' | "abc" |
"quoted" | str 'quoted' | "quoted" |
[1,2,3] | list | [1, 2, 3] |
[0:2:10] | range | OpenSCAD range |
0x10 | str '0x10' | "0x10" |
1_000 | str '1_000' | "1_000" |
The last two matter: Python’s int() accepts 0x10 and 1_000, OpenSCAD does not read either
as a number, so they stay strings rather than becoming a number OpenSCAD would reject.
Quoted text is unquoted once, so "quoted" is the string quoted. Embedded quotes and
backslashes are escaped on the way out: say "hi" serialises as "say \"hi\"".
[0:2:10] is an OpenSCAD range, not a list. [1:5] is a range with no step.
Output names
The name of each output file, without extension, is chosen in this order:
--name-template, if given- the set name — the JSON key, or the
exported_filenamecolumn model_<index>, zero-based
A template may use {name}, {index}, and any parameter in the set:
--name-template '{name}_d{diameter}'
--name-template 'part_{index:03d}'
A parameter the row leaves unset renders empty rather than failing the batch.
Every name is made filesystem-safe — a/b c:d* becomes a_b c_d_, and ../escape becomes
_escape, so a set name cannot direct output outside the folder you chose. Spaces are left
alone. If two cases would produce the same file, the run is refused rather than letting one
silently overwrite the other.
Selecting a subset
--select takes zero-based indices into the parameter file:
| form | meaning |
|---|---|
2,4 | those indices |
0-5 | a range, inclusive |
1-3,7,10-12 | combined |
every:2 in 0-10 | every second index in the range → 0 2 4 6 8 10 |
from:5 | index 5 onward |
up_to:4 | up to index 4, inclusive |
Running a batch
One OpenSCAD process is started per parameter set per output format. This page covers how many run at once, what happens when one misbehaves, and what the run records about itself.
Parallelism
scadbatch export model.scad params.csv out/ --jobs 4
--jobs bounds how many OpenSCAD processes run at once, defaulting to the number of CPUs.
OpenSCAD renders single-threaded for most work, so the useful setting is usually the core count;
the reason to go lower is memory, since each process holds its own geometry.
--sequential is deprecated and means --jobs 1.
Output formats
scadbatch export model.scad params.csv out/ --format 3mf
scadbatch export model.scad params.csv out/ --format stl --format png
--format takes an extension, as the detected OpenSCAD’s -o accepts it: stl, off, 3mf,
csg, png and whatever else the build supports. Repeat it to export every case in several
formats — each becomes its own output file and its own OpenSCAD run.
A format the detected build does not advertise is warned about, not refused, because the list of advertised formats is itself version-dependent and a wrong refusal is worse than a failed export with OpenSCAD’s own message.
--export-format chooses the STL flavour, binstl (default) or asciistl, and applies only to
stl. For png there are --camera, --imgsize and --colorscheme, passed straight through.
Not repeating work
scadbatch export model.scad params.csv out/ --skip-existing
--skip-existing leaves a case alone when its output file is already there, which makes an
interrupted batch resumable. --overwrite is the default and states the same thing explicitly.
Neither looks at content or timestamps: existence is the whole test. A parameter file that
changed since the last run needs --overwrite, or the stale file stays.
Seeing the commands without running them
scadbatch export model.scad params.csv out/ --dry-run
scadbatch -v export model.scad params.csv out/
--dry-run prints the OpenSCAD command line for each case and runs nothing. -v prints the
same commands while actually running.
This is the fastest way to answer “why did that come out wrong” — the command line shows exactly what OpenSCAD was told, including how each value was serialised.
Timeouts
scadbatch export model.scad params.csv out/ --timeout 300
A case that runs longer is killed and recorded as timed out, distinct from a failure. Without it, one pathological parameter set can hang a batch indefinitely; with it, the batch finishes and the summary says which case did not.
The run summary
scadbatch export model.scad params.csv out/ --summary run.json
A machine-readable record of the whole run — what it was told, which OpenSCAD answered, and what happened to every case. This is the output to assert against in CI, rather than parsing log lines.
{
"tool": {
"name": "scadbatch",
"version": "2.0.0rc1"
},
"started_at": "2026-10-14T03:35:01+00:00",
"engine": {
"name": "openscad",
"path": "/usr/bin/openscad",
"version": "2026.08.01"
},
"inputs": {
"scad_file": "examples/simpleCube/simpleCube.scad",
"parameter_file": "examples/simpleCube/simpleCube.csv",
"output_folder": "out",
"formats": ["stl"],
"export_format": "binstl",
"selection": "0-1",
"jobs": 12,
"skip_existing": false,
"timeout": null,
"name_template": null,
"encoding": "utf-8-sig",
"image_options": {}
},
"dry_run": false,
"total_duration": 0.0313,
"counts": { "ok": 2, "failed": 0, "timeout": 0, "skipped": 0, "errors": 0 },
"results": [
{
"index": 0,
"set_name": "cube_small",
"name": "cube_small",
"output_path": "out/cube_small.stl",
"format": "stl",
"status": "ok",
"returncode": 0,
"duration": 0.03,
"stderr": "",
"warnings": [],
"errors": [],
"command": ["openscad", "-o", "out/.cube_small.part.stl", "--export-format=binstl",
"-Ddepth=10", "-Dheight=10", "-Dwidth=10", "simpleCube.scad"]
}
]
}
That is a real summary, with the long absolute paths shortened, the durations rounded and the second case removed for length.
The -o in command is a .part sibling of the output, not the output itself: each case writes
to .<name>.part.<ext> and is renamed on success, so an interrupted or failed run never leaves a
truncated file where a finished one should be. A dry-run summary records the final path, since
nothing is written.
Recording the path and version is the point of the engine block: the same parameter file and
the same model produce different geometry across OpenSCAD releases, so a result without the
engine version is not reproducible. Like tool, it is keyed by role and carries the product
name as data, so a reader can answer what produced a run, and with what, without knowing
either key in advance.
tool.version is read from the installed distribution’s metadata, so it is null when scadbatch
runs from a source tree rather than an install. tool.name is always there.
counts.errors is not counts.failed
status is one of ok, failed, timeout, skipped, or dry-run under --dry-run, and
counts tallies the first four.
counts.errors is separate, and counts something the others cannot see: cases where
OpenSCAD printed an error line whatever its exit status.
OpenSCAD exits 0 on several ERROR: conditions — a parameter file it could not open, a font
file it could not read. Before this existed, such a run reported every case ok while the
geometry was wrong, and there was nothing to gate on.
A missing font family is worse still, and this does not catch it: OpenSCAD substitutes one silently, with no message at all.
So in CI, gate on counts.errors as well as counts.failed:
import json
s = json.load(open("run.json"))
assert s["counts"]["failed"] == 0, "an export failed"
assert s["counts"]["errors"] == 0, "OpenSCAD printed an error"
Per case, errors holds those lines and warnings holds every message line OpenSCAD
printed — WARNING:, ECHO:, DEPRECATED: and ERROR: alike — with errors being the subset
whose prefix marks it an error. The name is narrower than the content.
One consequence worth knowing: the classification is by line prefix. A continuation line that
carries no prefix is kept with the echo it belongs to, so a multi-line echo arrives as one
message. A continuation that happens to begin with a group name cannot be told apart from a
real diagnostic in OpenSCAD’s output, and is read as one — so a model that echo()s a string
beginning with ERROR: is still counted as having printed an error. That direction is
deliberate: an invented error is visible and recoverable, a hidden one is neither. See #59.
Which OpenSCAD is used
In order: --openscad-path, then $OPENSCAD, then openscad on PATH, then the platform’s
usual install location. The choice and the version are resolved once per run and recorded in the
summary.
scadbatch --version answers the same question without running a batch, which is the quickest
way to see which build a run here would pick:
$ scadbatch --version
scadbatch 2.0.0
openscad 2026.10.15 (/home/cam/.local/bin/openscad)
It is worth asking rather than assuming. Where several builds are installed, PATH and
$OPENSCAD decide between them, and the answer is not always the one the install suggests.
Only the last two are a fallback chain. --openscad-path and $OPENSCAD name a specific
executable, so one that does not resolve is an error rather than a reason to look elsewhere:
$ scadbatch export … --openscad-path /nope/openscad
Error: OpenSCAD executable not found (tried --openscad-path '/nope/openscad'). …
$ echo $?
1
scadbatch export model.scad params.csv out/ --openscad-path /opt/openscad/bin/openscad
OPENSCAD=/opt/openscad/bin/openscad scadbatch export model.scad params.csv out/
The detected version decides whether parameter sets can be passed natively with -p/-P, so an
old build changes how a JSON file is handled — see
How values reach OpenSCAD.
Exit codes
0 when every case that ran succeeded, and 1 when any case failed or timed out, or when the
parameter file was refused.
A refusal happens before any export runs, so a malformed parameter file costs nothing but the
--version and --help that detection already needed — the second is how the advertised output
formats are read:
$ scadbatch export model.scad ragged.csv out/
Error: ragged.csv line 2: this row has 3 cells but the header has 2 columns, so it is
not clear which value belongs to which parameter. ...
$ echo $?
1
A failing export reports itself both ways — counts.failed for the non-zero exit and
counts.errors for the printed error line:
counts: {'ok': 0, 'failed': 1, 'timeout': 0, 'skipped': 0, 'errors': 1}
Related tools
Batch-exporting from OpenSCAD is a problem many people have solved for themselves. This page says what else exists, what each does, and when it is the better choice — including the cases where it is.
scadbatch grew out of the first of these. It is not the first tool to do this, and the contribution is the parts that come after “it runs the exports”: refusing a parameter file it cannot read faithfully, serialising values to OpenSCAD literals correctly, and recording a run in a form you can assert against.
Tools that do roughly this
OutwardBuckle/OpenSCAD-Bulk-Export
PowerShell, Windows and Linux. https://github.com/OutwardBuckle/OpenSCAD-Bulk-Export
The closest prior art and the direct inspiration for this project. It exports from a CSV or a
Customizer JSON, converts CSV to JSON, generates a CSV from a .scad file, handles STL, OFF,
AMF, 3MF, DXF, SVG and PNG, takes camera arguments for PNG, has an overwrite switch, and runs
exports in parallel on PowerShell 7+. It also uses the same exported_filename column
convention, which scadbatch kept for compatibility.
Use it instead if you are on Windows, in a PowerShell shop, and want a single script file with no Python involved. For straightforward CSV-to-STL work it does the same job.
What scadbatch adds is mostly about what happens when something is wrong: a parameter file
whose values OpenSCAD would silently misread is refused rather than exported; a run summary
records the engine version and per-case status for CI; and there is a Python API rather than only
a command line. It also has per-case timeouts, index selection, name templates, and a documented
-D serialisation.
pjaspinski/openscad-batch-exporter
Python. https://github.com/pjaspinski/openscad-batch-exporter
Reads a CSV, builds -D flags from the columns, names the output from the joined values, and
runs exports in parallel with the count set by editing a constant in the source. STL only, and
about fifty lines, so it is quick to read and adapt.
One detail to know before adopting it: every value is wrapped in quotes, so a numeric column
reaches OpenSCAD as a string — -Dwidth="10" rather than -Dwidth=10. Which brings us to the
thing this problem keeps coming back to: serialising a -D value is the subtle part. A string
containing a space or a quote, a number, a list, a boolean — each has exactly one correct form,
and getting it wrong produces a command OpenSCAD accepts and misreads.
mrdarip/OctoSCAD
https://github.com/mrdarip/OctoSCAD
Aims at exporting the cross product of parameter values from a JSON description, including range syntax. The README marks its usage “[To be determined]”, so it is an idea with a shape rather than a tool to depend on today — but the idea is a real gap, see What none of these do.
Tools that do something else
18107/OpenSCAD-batch-export-stl
Python. https://github.com/18107/OpenSCAD-batch-export-stl
Despite the name, not a parameter sweep. It looks for a module export() in a .scad file and
renders each statement inside it to its own file, naming each from the trailing comment on
its line, or from the module call when there is no comment:
module export() {
cube(10);
sphere(5); //Ball
}
produces cube.stl and Ball.stl.
Use it instead if your file contains several distinct models rather than one parametric model. That is a genuinely different job, and scadbatch does not do it: scadbatch varies parameters of one model.
kintel/openscad-benchmark
Shell, OpenSCAD and Python, by OpenSCAD’s own maintainer. https://github.com/kintel/openscad-benchmark
A performance harness for OpenSCAD itself — geometry timing, VBO rendering, frame counts — with scripts to run the benchmarks and analyse the results.
Its gen_param_sets is worth knowing about on its own: it takes variable declarations and emits
Customizer JSON for the cross product of their values.
./gen_param_sets 'N=[2,10]' '$fn=[20,100]' > scalemail.json
That composes with scadbatch, which consumes such a file but will not generate one.
Use it instead if you are measuring OpenSCAD’s performance rather than producing models.
BelfrySCAD/openscad_test
Python, from the organisation behind BOSL2.
https://github.com/BelfrySCAD/openscad_test · pip install openscad-test
A test framework that runs OpenSCAD scripts and asserts on what they print. Tests are [[test]]
sections in a TOML .scadtest file, each with an inline script or a script_file, variables
passed via set_vars (which become -D flags), and assertions: expect_success,
assert_echoes, assert_warnings, assert_no_echoes, assert_no_warnings, plus a per-test
timeout. It has a CLI with -j for parallelism and a Python API.
[[test]]
name = "Echo Integer Test"
script = "echo(42);"
expect_success = true
assert_echoes = ["ECHO: 42"]
Use it instead if you want to assert that a model behaves correctly, rather than to produce files from it. It is the closest thing here to scadbatch in construction — Python, a CLI and an API, bounded parallelism, per-case timeouts — and it answers a different question: it tests, and does not export.
The overlap worth noting is that it classifies OpenSCAD’s output lines into echoes, warnings and
errors, which is the same problem counts.errors solves here, approached from the other side.
UnitTestSCAD, SpecSCAD
https://github.com/HopefulLlama/UnitTestSCAD · https://github.com/matths/SpecSCAD
Test frameworks for OpenSCAD code, unit-style and BDD-style respectively. UnitTestSCAD asserts about both functions and modules; SpecSCAD’s README is explicit that it “tests only functions (not modules), encouraging you to place core logic into reusable functions”. They test the source; scadbatch builds artefacts from it.
A plain shell loop
while IFS=, read -r name w h d; do
openscad -o "out/$name.stl" -D "width=$w" -D "height=$h" -D "depth=$d" model.scad
done < <(tail -n +2 params.csv)
Honestly: for a handful of numeric parameters that you control, this is fine, and it is what most people should start with. Reach for a tool when one of these starts to bite:
-
quoting —
-Dtakes an OpenSCAD expression, not a value, so the quotes have to be inside it.-D msg=hellomakesmsgundef, becausehellois read as a variable name:$ openscad -o out.stl -D msg=hello d.scad WARNING: Ignoring unknown variable "hello" in file d.scad, line 7 ECHO: msg = undef, n = 1 $ openscad -o out.stl -D 'msg="hello"' d.scad ECHO: msg = "hello", n = 1The shell’s own quoting is then a second layer on top of that one, and the loop above is already wrong for a CSV field containing a comma.
-
silent wrong results — a misspelled variable is simply never used, so a typo in a column name gives you a full set of outputs built at the defaults, exit 0, no message:
$ openscad -o out.stl -Dwdith=99 m.scad ECHO: w = 10, h = 20 -
errors that still exit 0 — an unreadable parameter file, or a font file it cannot read, prints
ERROR:and exits 0, soset -edoes not catch it. -
parallelism — easy to add with
xargs -P, easy to get wrong in a way that interleaves output. -
wanting to know what happened — which case produced which file, how long each took, which printed warnings.
What none of these do
Generating the parameter sets, with one partial exception. gen_param_sets above does build a
cross product, as part of a benchmarking harness. Otherwise every tool here, scadbatch included,
consumes a list someone else produced — by hand, from a spreadsheet, or from the OpenSCAD
Customizer — and no general cross-product sweep or design-of-experiments selection exists.
Why a new tool rather than contributing to one of these
The honest answer is that it started as the Python port of a PowerShell script, for a lab that did not want PowerShell on its machines, and the scope grew from there. A contribution to OpenSCAD-Bulk-Export would have meant rewriting it in another language, which is not a contribution so much as a replacement.
What justifies it continuing to exist separately is the part that is not the export loop: the parameter-file validation, the value serialisation, and the run record. Those are testable in isolation, and they are where the defects this project has fixed actually were — every one of them a case where OpenSCAD exited 0 and produced the wrong geometry. A shell or PowerShell script can do the export; it is a poor place to put that kind of checking.
Cutting a release
A release is one pushed tag. Everything after that is .github/workflows/release.yml, except
one approval and the archive step.
Before the tag
-
Everything intended for the release is merged, and
mainis green. Check the per-check list rather than a watch’s exit status, and confirm the run’sheadShamatches the commit you are about to tag:gh pr list # nothing in flight you meant to include gh run list --branch main --limit 3 gh run view <id> --json headSha --jq .headSha -
Set the version in
pyproject.tomland move the## [Unreleased]section ofCHANGELOG.mdunder the new version with today’s date, leaving a fresh emptyUnreleasedabove it. Add the compare link at the bottom. -
Update
CITATION.cff:versionanddate-released. -
Open that as its own pull request, like any other change, and merge it. The tag goes on the merged commit, not on a branch.
The tag
The verify job compares the tag with the packaged version as plain strings:
version=$(... tomllib ... pyproject.toml ...) # the raw string in the file
tag="${GITHUB_REF_NAME#v}"
test "$version" = "$tag"
So v2.0.0rc1 requires version = "2.0.0rc1" exactly. That the version is read straight out of
pyproject.toml is what makes this strict: a difference in spelling PEP 440 would normalise
away — 2.0.0-rc1, 2.0.0.rc1 — still fails, because nothing here normalises either side. Were
the version taken from the built wheel instead, all three spellings would compare equal and the
check would pass.
git checkout main && git pull
git tag v2.0.0
git push origin v2.0.0
The workflow triggers on tags matching v*.*.* and nothing else.
What runs
verify, dist and binaries declare no dependencies, so they start together. Only the last
two are ordered, and both wait on all three of the first:
| job | what it does |
|---|---|
verify | the tree at the tag passes the gate, and the tag matches the packaged version |
dist | builds the sdist and the wheel |
binaries | freezes a one-file binary on Linux, macOS and Windows |
pypi | needs all three, then waits for an approval, then publishes |
github-release | needs all three, creates the release and attaches the binaries |
The Linux binaries job gives the frozen GUI a display and checks that it opens a window:
a GUI that starts enters mainloop() and never returns, so the timeout’s exit status 124 is the
pass and anything else is a fault. Matching the output instead cannot tell a missing module from
a missing display — PyInstaller prints Failed to execute script for every unhandled exception.
pypi waits on all three because PyPI never accepts a second upload of a version. If a
binary fails for something needing a code change, publishing first would burn the version number
with nothing to go back to.
The approval
pypi names the pypi environment, which has a required_reviewers rule. Verified from the
API rather than from the workflow file, because an environment: key alone gates nothing —
GitHub creates an environment with no rules the first time a workflow names one:
gh api repos/CameronBrooks11/scadbatch/environments \
--jq '.environments[] | {name, rules: [.protection_rules[].type]}'
Approve it in the run’s page, or:
gh api repos/CameronBrooks11/scadbatch/actions/runs/<id>/pending_deployments \
-f state=approved -f comment=ok -F 'environment_ids[]=<environment id>'
comment is required by the endpoint, and the array needs key[]=value: -F’s type conversion
covers only true, false, null and integers, so -F environment_ids='[123]' sends the
string "[123]" and the request fails.
Publishing uses PyPI trusted publishing over OIDC: no token exists anywhere, and id-token: write is granted to that one job and nowhere else. A trusted publisher is configured with the
repository, the workflow filename and the environment name (PyPI’s
documentation walks
through exactly that), so renaming release.yml breaks publishing until the PyPI configuration
is updated to match.
After the workflow
-
Check the result rather than the workflow’s say-so:
curl -s https://pypi.org/pypi/scadbatch/json | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['info']['version'], sorted(d['releases']))" uvx --from scadbatch==<version> scadbatch --help -
The GitHub release is a prerelease unless the tag is exactly
vN.N.N—^v[0-9]+\.[0-9]+\.[0-9]+$, sov2.0.0rc1is marked prerelease automatically. -
Zenodo. With the Zenodo–GitHub integration enabled for the repository, publishing the GitHub release archives it and mints a DOI: one for the version plus a concept DOI covering all versions. Put the concept DOI in
CITATION.cffand the README badge: it resolves to the newest archived release, where a version DOI freezes a reader on the one they found. This is the step that is not automated. Done for 2.0.0 — concept10.5281/zenodo.24122494, version10.5281/zenodo.24122495— so a later release only needs the badge checked, since the concept DOI does not change.
Rehearsing without spending a version
A tag whose version does not match pyproject.toml fails verify, which blocks pypi — while
binaries, depending on nothing, still runs in full. So a throwaway tag exercises the expensive,
platform-dependent half for free. Not quickly, though: the version check is verify’s last
step, after just setup, just check and the test suite under xvfb-run, so the rehearsal pays
for a complete gate run before the mismatch is reported.
git tag v0.0.0-smoke && git push origin v0.0.0-smoke
# watch the binaries job; then
git tag -d v0.0.0-smoke && git push origin :refs/tags/v0.0.0-smoke
gh run delete <id>
This is worth doing when anything in the binaries job has changed. Cutting 2.0.0rc1 took
three release runs: the first two failed on Binary (ubuntu-latest) alone, with Publish to PyPI skipped both times, so nothing was published and the tag was simply re-pointed. That is
the pypi needs binaries rule working — but each attempt cost a full release run, and a
rehearsal tag would have found it without touching the real tag.
If a release fails
-
Before
pypiran — nothing was published. Delete the tag, fix, re-tag:git tag -d v2.0.0 && git push origin :refs/tags/v2.0.0 -
After
pypiran — the number is spent: PyPI does not accept a second upload for a version that already exists. Fix forward with the next patch version.