Skip to content

feat(presets): replace the nixpacks build engine with autopack - #530

Merged
dviejokfs merged 8 commits into
mainfrom
feat/autopack-preset
Aug 4, 2026
Merged

feat(presets): replace the nixpacks build engine with autopack#530
dviejokfs merged 8 commits into
mainfrom
feat/autopack-preset

Conversation

@dviejokfs

Copy link
Copy Markdown
Contributor

Replaces Nixpacks with autopack as the build engine. The nixpacks* preset slugs stay exactly where they are — only what runs underneath them changed.

Why now

nixpacks 1.41.0 is the latest published version and pulls two advisories that have no upstream fix:

Advisory Crate Path
RUSTSEC-2023-0018 remove_dir_all 0.5.3 nixpacks → tempdir 0.3.7 → remove_dir_all
GHSA-8wf9-4rjw-8j9r serde_with 2.3.3 nixpacks → serde_with (2.3.3 is the last 2.x ever published; the fix landed in 3.21.0)

Both were carried as documented, reachability-analysed exceptions in the root Cargo.toml because there was nothing to upgrade to. Dropping the dependency is the only thing that clears them.

Verified — cargo audit before and after:

before: 4 vulnerabilities (protobuf, rsa, remove_dir_all, serde_with)
after:  2 vulnerabilities (protobuf, rsa)

The two exception blocks are deleted rather than reworded.

This is the same pair of advisories #513 set out to fix by vendoring a nixpacks fork. That PR does clear them and is a legitimate approach; this one removes the dependency instead of maintaining a fork of it.

Compatibility is the constraint

Existing projects have preset = 'nixpacks' and a NixpacksConfig persisted against them. A deployment of one of those has to keep working with nobody touching anything:

  • every nixpacks* slug still resolves, with the same stored_preset and the same stored config shape;
  • a persisted nixpacks_config TOML is still honoured — autopack reads the Nixpacks schema in compatibility mode, and surfaces anything it could not translate as a warning instead of dropping it silently;
  • a stored provider still forces that language, so a polyglot repo pinned to python does not quietly start building as node because detection order differs.

Labels move from Nixpacks (Python) to Autopack (Python). Slugs, which are persisted, do not move.

Two behaviour changes worth review

A project that cannot be planned now fails loudly. The old fallback emitted FROM alpine + COPY . . with no CMD — that builds, pushes, deploys, and then the container exits immediately with nothing useful in the log. It now emits a Dockerfile that exit 1s with the actual reason.

Autopack's Dockerfiles require BuildKit (cache and secret mounts). build_image.rs already sets use_buildkit: true, so the deployment path is fine. temps-cli build defaults it to false, and a classic-builder parse error points at a generated line the user never wrote — so that case is now refused by name up front. Worth a look: whether temps build should just default to BuildKit instead.

Tests: build it, run it, ask it for a page

crates/temps-presets/tests/starters.rs takes every starter from gotempsh/temps-examples, renders it through the real AutopackPreset the pipeline uses, docker builds it, runs it, and requests a page. The existing unit tests assert on generated Dockerfile text, which cannot tell a working build from a plausible-looking one.

It also checks the two properties that are invisible until they bite:

  • it answers on $PORT — what the proxy connects to, and nothing else checks it;
  • it stops on SIGTERM rather than waiting out the kill timeout. A container that ignores SIGTERM turns every redeploy into a hard kill of in-flight requests.

Result: 23 of 25 starters pass end to end locally (dockerfile is excluded — it exists to exercise the Dockerfile preset).

.github/workflows/starters.yml runs them as a matrix, one job per starter, plus nightly (the starters live in another repo, so a green PR here can still break later).

The two that don't pass

They run in a separate always-green report job rather than as required checks — a red required check everyone learns to ignore is worse than no check.

  • java/spring-boot — Spring deduces a REACTIVE application type from a classpath that only has spring-boot-starter-web, then fails for want of a ReactiveWebServerFactory. The boot jar is selected correctly (the -plain.jar exclusion works) and the app does start, so this is not jar selection. Not yet root-caused.
  • rust/actix — ignores SIGTERM; docker stop waits the full grace period and SIGKILLs. Actix's default graceful-shutdown timeout outlives the stop timeout.

One finding that belongs in temps-examples

ruby/rails, python/django, php/laravel and dotnet/web initially failed. All four ship a nixpacks.toml pinning a nixpacks-specific start command, and autopack's compatibility mode honours them faithfully:

Starter Pinned command What happens
ruby/rails rails server ... no bundle exec, so rails is not on PATH — exec: rails: not found
python/django manage.py migrate && gunicorn ... migrate fails: settings.DATABASES is improperly configured
php/laravel php artisan serve dev server; also pins php8.3-pgsql which does not resolve
dotnet/web dotnet run needs the SDK, which the runtime image does not carry

All four pass once the nixpacks.toml is removed — autopack's own providers already emit bundle exec rails server, gunicorn, FrankenPHP and dotnet <assembly>.dll correctly. Autopack already warns about three of these four via its risky-start-command check.

Those files are nixpacks-specific tuning and should go once nixpacks does. That's a change to gotempsh/temps-examples#12, not to this PR — this PR's matrix will stay red on those four until it lands.

Verification

  • cargo check --workspace --lib — clean
  • cargo test -p temps-presets --lib197 passed, 0 failed
  • cargo clippy --all-targets -- -D warnings on the touched crates — clean
  • cargo audit — 2 advisories cleared, confirmed above
  • cargo tree -p temps-presets | grep nixpacks — no match; gone from Cargo.lock entirely

Not done

autopack-* are pinned to a commit on the public repo, not a released tag — a build plan changing under a release would change what every project's image contains with nothing here moving. Worth deciding whether to cut a tag before merge.

Nixpacks is gone as a dependency. The slugs it owned stay, and now build
through autopack.

Why now: nixpacks 1.41.0 is the latest published version and pulls two
advisories with no upstream fix available — RUSTSEC-2023-0018 (remove_dir_all
0.5.3, via tempdir 0.3.7) and GHSA-8wf9-4rjw-8j9r (serde_with 2.3.3). Both
were carried as documented, reachability-analysed exceptions because there was
nothing to upgrade to. Removing the dependency is the only thing that clears
them, and `cargo audit` now reports neither.

Compatibility is the constraint, not a nice-to-have. Existing projects have
`preset = 'nixpacks'` and a NixpacksConfig persisted against them, so:

  * every `nixpacks*` slug still resolves, with the same stored_preset and the
    same config shape;
  * a persisted `nixpacks_config` TOML is still honoured — autopack reads the
    Nixpacks schema in compatibility mode, and reports what it could not
    translate instead of dropping it;
  * a stored provider still forces that language, so a polyglot repository
    pinned to `python` does not start building as `node` because detection
    order differs.

Labels move from "Nixpacks (Python)" to "Autopack (Python)"; slugs do not.

Two behaviour changes worth knowing about:

  * a project autopack cannot plan now produces a Dockerfile that exits 1 with
    the reason. The previous fallback emitted `FROM alpine` + `COPY . .` and no
    CMD, which builds and deploys cleanly into a container that exits at once;
  * autopack's Dockerfiles need BuildKit for cache and secret mounts. The
    deployment pipeline already enables it; a build that does not is refused by
    name rather than failing later on a line the user never wrote.

Also adds tests/starters.rs, which builds every temps-examples starter through
the real preset, runs it, and requests a page — plus the two properties that
are invisible until they bite: that it answers on $PORT, and that it stops on
SIGTERM instead of waiting out the kill timeout.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

📓 Changelog preview

This is what your commits will add to the generated CHANGELOG.md at release time (via git-cliff). Do not edit CHANGELOG.md by hand — it is generated from your Conventional Commit messages.

## [Unreleased]

### Added

- **presets:** Replace the nixpacks build engine with autopack
- **web:** Add the autopack preset icon

### CI

- Drop the temporary starters branch pin

### Fixed

- **ci:** Point the starters job at the branch that carries them
- **presets:** Stop the Nixpacks TOML validation error echoing the config

### Refactor

- **web:** Open the autopack icon's lid into a chevron

### Testing

- **ci:** Gate on rust/actix, which passes

Every job in the matrix failed in 0.03s: `examples/starters` does not exist on
temps-examples' default branch yet — it arrives with gotempsh/temps-examples#12
— so the checkout produced a tree without it and the test panicked before
building anything.

Two changes:

  * the checkout pins `ref: feat/language-starters`, marked TEMPORARY. It must
    come out when #12 merges; a branch ref that outlives its PR is a job that
    quietly stops testing what main ships.
  * the panic now distinguishes "the variable is unset" from "the directory is
    not there". They read identically and want completely different fixes, and
    the second one is what just cost a full CI run to diagnose.
It was parked in the report-only job on the strength of a local failure: on
macOS it ignored SIGTERM and `docker stop` had to SIGKILL it. On Linux CI it
passes cleanly in 104s, SIGTERM check included — so the failure was an artefact
of Docker Desktop, not the starter.

Leaving a passing starter in the report-only bucket means it is not actually
gated, and a real regression in it would go unnoticed. Linux CI is the
environment the gate is for.

java/spring-boot stays behind: it still fails, for the same reason, every run.
@dviejokfs

Copy link
Copy Markdown
Contributor Author

CI green — and two corrections to the description above

All 24 starter jobs pass. Run 30823442411

Two things in the original description were wrong:

1. I understated the CI dependency. I wrote that four jobs would stay red pending gotempsh/temps-examples#12. It was all of them — examples/starters does not exist on that repo's default branch at all, so every job panicked in 0.03s on the missing directory, before Docker was ever invoked. Fixed two ways: the checkout now pins ref: feat/language-starters (marked TEMPORARY — it must come out when #12 merges), and the panic now distinguishes "the variable is unset" from "the directory isn't there", which is what cost a full run to diagnose.

2. rust/actix is not failing. I listed it as known-failing on the strength of a local run where it ignored SIGTERM and docker stop had to SIGKILL it. On Linux CI it passes cleanly in 104s with the SIGTERM check included — the failure was a Docker Desktop artefact. It is now in the blocking matrix, because a passing starter parked in a report-only job is not actually gated.

The four nixpacks.toml files that pinned broken start commands were removed on the #12 branch (gotempsh/temps-examples@53cd57f). flask, fastapi and php/vanilla keep theirs deliberately — they pin correct commands and are the CI coverage for reading a nixpacks.toml at all.

Still failing, by design not blocking

java/spring-boot, in an always-green report-only job. Spring deduces a REACTIVE application type from a classpath that only carries spring-boot-starter-web, then fails for want of a ReactiveWebServerFactory. The boot jar is selected correctly (the -plain.jar exclusion works) and the app does start, so it is not jar selection. Not root-caused.

Merge order

This PR cannot go in before gotempsh/temps-examples#12, and the ref: pin has to be deleted in the same breath — otherwise the job keeps testing a branch instead of what main ships.

gotempsh/temps-examples#12 is merged and the branch is deleted, so the `ref:`
now points at nothing. The job reads `examples/starters` from the default
branch, which is what it should have been testing all along.
`test_nixpacks_invalid_inline_toml_is_rejected_during_create` caught this: the
new validation interpolated `toml`'s error Display, which renders the offending
*source line*. The test feeds `secret_token = ["do-not-echo"` and asserts the
value does not come back — it did.

That message reaches an API response body and the logs, and a nixpacks_config
can hold secrets, so this was a real leak rather than untidy output.

Only the position crosses the boundary now: "failed to parse Nixpacks TOML at
line 1, column 30; verify its syntax and supported fields". The position is
derived from the error's span rather than its rendering, because both of the
library's own accessors can carry content — Display embeds the source line, and
`.message()` names the offending key on an unknown-field error. The full error
goes to the debug log server-side, where it is useful and not exposed.

Adds the same assertion as a unit test. The existing guard is behind
`docker_available()`, so it only runs in the Docker integration job — a leak
this cheap to reintroduce should fail in `cargo test -p temps-presets`.
Cargo.lock was the only conflict. Resolved by taking main's copy and
re-resolving, rather than merging the two by hand: it is generated, and a
hand-merged lockfile can be internally inconsistent in ways nothing checks.

Verified afterwards that main's dependency bumps survived (aes-gcm 0.11.0,
base64 0.23.0, md5 0.8.1, cron 0.17.0, validator 0.21.0, serial_test 4.0.1)
and that nixpacks is still absent while the autopack pin is intact.
`AutopackPreset::icon_url` and the auto-detect Nixpacks variant both point at
/presets/autopack.svg, which did not exist. The console falls back to
custom.svg on error, so this was not a broken image — the new preset simply
showed the generic mark, which is worse in a picker whose whole job is telling
builders apart at a glance.

The mark is an isometric box with its lid lifted. Not a triangle (Vercel's,
and temps competes with them) and not a letter "A" (Astro's, and it sits three
tiles away in this very picker). Teal because the picker already has purple,
blue, green and orange.
Matches gotempsh/autopack. The closed cube was the generic registry package
mark; the open lid reads as a box mid-pack and doubles as an up arrow.

Teal rather than green is deliberate and checked against the real picker:
Node.js owns green there, and autopack builds 24 ecosystems, so reading as a
Node tool is misleading. Sky blue collides with Docker and Nixpacks, amber with
HTML5. Teal is the only gap.
@dviejokfs
dviejokfs merged commit a417a0a into main Aug 4, 2026
71 of 72 checks passed
@dviejokfs
dviejokfs deleted the feat/autopack-preset branch August 4, 2026 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant