Skip to content

Run Component build steps in artifact build (S-002) - #138

Merged
shipmill-romamo[bot] merged 5 commits into
mainfrom
feat/115-component-build-steps
Oct 27, 2026
Merged

shipmill-romamo[bot] merged 5 commits into
mainfrom
feat/115-component-build-steps

Conversation

@shipmill-romamo

@shipmill-romamo shipmill-romamo Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements S-002's build steps (docs/specs/002-component-build-steps.md), criteria S-002-2 to S-002-10, S-002-12 and S-002-13. #113 shipped validation (S-002-1, S-002-11) and #114 the runtime refusal (S-002-14, S-002-15), so this is the spec's last build issue: the spec is now status: built, with its Verification section filled in.

  • cloudfall-engine artifact build on a Component with spec.build:
    1. Before cloning, every spec.buildTools entry must be on the controller's PATH (artifact_build_tool_missing) and <tool> --version must print a version at least the minimum (artifact_build_tool_unsupported, also when no version can be read). env is looked up the same way
    2. After the clean checkout, git ls-files under the packaged root is read, then each step runs in order in <packaged root>/<workingDirectory> through ctx.run(..., stream="always") as env -i PATH=… HOME=… LANG=… TMPDIR=… CI=true <tool path> <args>. The step sees only those variables, so HCLOUD_TOKEN, SOPS keys and the rest stay out. Its output goes to stderr as plain text and never to stdout. A non-zero exit, or the step outrunning its timeoutSeconds, fails with artifact_build_step_failed, naming the step index, argv, exit code and stderr tail
    3. Every artifact.include path must exist afterwards (artifact_include_missing). Every tracked file must still exist too: a step that deletes one is refused with artifact_tracked_file_missing naming the path, since S-002-3 requires the tarball to hold every file git ls-files lists
    4. The archive holds the tracked files, plus every include path walked without following symlinks, plus their parent directories, all owned by root as before. node_modules/ and caches stay out
  • Time limit: the command no longer has a fixed 1800 s limit. Its own budget (BuildBudget) is 1800 s for the clone plus every declared step timeoutSeconds (ComponentInventory.artifact_timeout_seconds), shared by every git, --version and step child. A step's own limit is therefore the one that fires, as artifact_build_step_failed. An explicit --timeout replaces the budget, and running past it ends in TIMEOUT. --timeout 0 still means no limit, as on main. Treaty's own --timeout reaches the handler only as the effective ctx.timeout/ctx.remaining, which read the same whether the flag was omitted or 0 (Timeout.parse maps 0 to Timeout(None)). So the command takes --timeout as a field of its own (BuildArgs.timeout, parsed with treaty's Timeout.parse), which treaty documents as replacing the framework flag (REQ-C-012). On the SDK/MCP path (build_release_artifact), the engine call now carries no 3600 s STEP_TIMEOUT_SECONDS cap (ExecutionStep.timeout_seconds=None), so the engine's budget governs there too
  • Any failure in 1 to 3 writes no .tar.gz and no .json. ArtifactBuildError now carries a context (tool, found/minimum, step, argv, exitCode, path), which the CLI merges into error.context next to code
  • artifact.schema.json gains an optional spec.build: steps (workingDirectory, command) and tools (name to the dotted version read in step 1). This change only adds fields
  • A Component without build goes down the unchanged code path: same _write_archive tree, no ls-files, no spec.build
  • The artifact build command declares child_log=True, and its description and the build_artifact MCP tool's description mention the build. engine/README.md documents the behaviour

Fixes #115

Test plan

  • engine/tests/test_artifact_build_steps.py runs artifact build as a child process with its own environment. PATH holds only a temp dir containing a logging git wrapper, env, and Python stand-ins for node and pnpm, so CI needs neither tool installed. HCLOUD_TOKEN and SOPS_AGE_KEY are set for the command:
    • S-002-2/3: the tarball has web/dist/index.html despite .gitignore, holds every tracked file at the commit, and has no node_modules
    • S-002-4: spec.build records the steps and versions, and the file validates against the schema
    • S-002-5: no clone reaches the git wrapper
    • S-002-6: covers an old version and a missing version
    • S-002-3: a step that deletes the tracked file web/package.json fails with artifact_tracked_file_missing and writes nothing
    • S-002-7: covers exit 3, and a step outliving its own 2-second timeoutSeconds (artifact_build_step_failed). Three 3600 s steps give a budget of 1800 + 10800 s, and each step gets its full hour. --timeout 3 ends in TIMEOUT. --timeout 0 applies no limit and the build succeeds. With no --timeout, the computed limit applies. sdk/tests/test_lifecycle.py shows the SDK's engine step carries no cap of its own
    • S-002-8: a missing include path writes no artifact
    • S-002-9: the whole stdout parses as one envelope, and step lines are on stderr
    • S-002-10: the environment the step dumped into the archive has CI=true and no secrets
    • S-002-13: the plain archive equals the clean tracked tree, and a built archive differs only by its include paths
  • S-002-12: two releases are built, then the cloudfall_deploy role's own path, digest, unpack and switch tasks run under ansible-playbook against a controller directory, followed by rollback.yml's switch tasks. Ownership is dropped and the systemd tasks are skipped. releases/<release>/web/dist/index.html holds each page, and current/web/dist/ follows the deploy and the rollback. Ansible's unarchive needs GNU tar, so the test is skipped on macOS and runs on CI's Linux runner. Locally the same flow passed with the unpack swapped for tar -xzf
  • engine/tests/test_artifact_builder.py: the existing direct calls pass the new run_step and environment arguments
  • Ran locally: uv run ruff check ., uv run mypy, uv run cloudfall config validate --project config/examples, specs.py check, specs.py coverage --spec 002 (every criterion has a test), and the tests of the touched areas: test_artifact_builder.py, test_artifact_build_steps.py, test_playbook.py, test_release_command.py, test_agent_server.py, test_lifecycle.py, plus test_inventory.py, test_data_migration.py, test_migrate.py, test_secrets.py, test_domains_converge.py, test_app.py and test_component_build_validation.py after the timeout change. All passed except one skip, the S-002-12 GNU tar case. No Ansible file changed. The full suite is left to CI
  • Not run: a real node/pnpm build, and a deploy and rollback on a live Debian/systemd host

Plan

  • Serves: quality bar
  • Files: engine/src/cloudfall_engine/artifact.py, engine/src/cloudfall_engine/cli.py, config/schemas/v1/artifact.schema.json, sdk/src/cloudfall/app.py (MCP tool description, step runner limit), sdk/src/cloudfall/domain.py, sdk/src/cloudfall/inventory.py, sdk/src/cloudfall/lifecycle.py, sdk/tests/test_lifecycle.py, engine/README.md, docs/specs/002-component-build-steps.md, changelog.d/115-component-build-steps.md, engine/tests/test_artifact_build_steps.py, engine/tests/test_artifact_builder.py
  • Order: tool check before clone, steps in a scrubbed environment with output on stderr under a budget of clone time plus step timeouts, include and tracked-file checks, archive of tracked files plus includes, spec.build metadata and schema, then tests per criterion (see the spec's Behaviour)
  • Proof: S-002-2, S-002-3, S-002-4, S-002-5, S-002-6, S-002-7, S-002-8, S-002-9, S-002-10, S-002-12, S-002-13 (specs.py coverage --spec 002)

🤖 Generated with Claude Code

@shipmill-romamo

Copy link
Copy Markdown
Contributor Author

@romamo Decided without waiting: a build step that deletes a file git tracks fails the build with artifact_tracked_file_missing and writes no artifact, spec.build.tools records the dotted version found in the tool's output (v22.3.0 is stored as 22.3.0), and each step's own timeoutSeconds is the limit that stops it with artifact_build_step_failed, under a command limit of 1800 s plus the declared step timeouts.

Why: S-002-3 says the tarball holds every file git ls-files lists, and spec 002 lets a step run up to 3600 s; the version is compared with buildTools minimums as a dotted version.
Not taken: leaving a deleted tracked file out silently (S-002-3 would not hold), and a fixed 1800 s for the whole build (a step the config allows would be cut off with a bare TIMEOUT).

Reply with a comment starting with veto before 2026-10-28 11:10 UTC to reverse it, adding what you want instead if you like. A later veto still counts and gets a follow-up.

@shipmill-romamo

Copy link
Copy Markdown
Contributor Author

@romamo Decided without waiting: artifact metadata gains an optional spec.build, artifact build gains the error codes artifact_build_tool_missing, artifact_build_tool_unsupported, artifact_build_step_failed, artifact_include_missing and artifact_tracked_file_missing with their context, and --timeout moves from treaty's framework flag into the command's own input (treaty's documented way, REQ-C-012) so --timeout 0 still means no limit and leaving it out applies the computed limit.

Why: S-002-4..8 ask for these; a Component without build packages the same files and writes the same metadata (S-002-13), and every --timeout value behaves as before.
Not taken: keeping treaty's flag, which can't tell "no --timeout" from --timeout 0, so one of them would have changed meaning.

Reply with a comment starting with veto before 2026-10-28 11:10 UTC to reverse it, adding what you want instead if you like. A later veto still counts and gets a follow-up.

@shipmill-romamo shipmill-romamo Bot added the decided An agent decided at notify; the maintainer may veto label Oct 27, 2026
shipmill-romamo Bot and others added 5 commits October 27, 2026 12:19
artifact build packaged the git tree only, so a Component could not ship a
built frontend such as a gitignored web/dist. It now checks every
spec.buildTools entry on PATH at its minimum version before cloning, runs
each spec.build step in the clean checkout through env -i with only PATH,
HOME, LANG, TMPDIR and CI=true, streams step output to stderr so stdout
stays one JSON envelope, and packages the tracked files plus every
spec.artifact.include path. A failed or timed-out step or a missing include
writes no archive. The Artifact metadata gains an optional spec.build with
the steps and tool versions. A Component without build is packaged exactly
as before.

Spec 002 is now built: every criterion has its test and its verification.

Fixes #115

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…acked files

A fixed 1800 s command limit cut long builds short with treaty's TIMEOUT,
and the SDK/MCP path capped the engine call at 3600 s, so a step never
reached its own timeoutSeconds. artifact build now declares no fixed limit
and applies its own: 1800 s for the clone plus every declared step timeout
(ComponentInventory.artifact_timeout_seconds), shared by every child, so
a step's own limit is the one that fires (artifact_build_step_failed). An
explicit --timeout still replaces it. The SDK's artifact step carries no
cap of its own and leaves the limit to the engine.

A step that deletes a tracked file broke S-002-3 by leaving it out of the
archive. The build now refuses it with artifact_tracked_file_missing,
naming the path, before any archive is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Treaty's --timeout reaches a handler only as the effective timeout, the
same whether the flag was omitted or 0, so the computed limit silently
replaced an explicit 0. The command now takes --timeout as a field of its
own, which treaty allows in place of its framework flag, parsed with
Timeout.parse: omitted applies the clone time plus every declared step
timeout, 0 disables the limit, and any other value replaces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mlink

A tracked symlink above an include path (web/link -> /outside, include
web/link/secrets) let the build package controller files outside the
checkout. The include path itself may still be a symlink, archived as one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@shipmill-romamo
shipmill-romamo Bot force-pushed the feat/115-component-build-steps branch from e668682 to 65b1670 Compare October 27, 2026 10:21
@shipmill-romamo
shipmill-romamo Bot merged commit 389f0af into main Oct 27, 2026
1 check passed
@shipmill-romamo
shipmill-romamo Bot deleted the feat/115-component-build-steps branch October 27, 2026 10:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

decided An agent decided at notify; the maintainer may veto

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Run Component build steps in artifact build (S-002-2..10, S-002-12, S-002-13)

0 participants