Skip to content

[3.x] Empty the entire output directory before every build - #2586

Merged
emmadesilva merged 7 commits into
masterfrom
v3/deterministic-output-builds
Aug 11, 2026
Merged

[3.x] Empty the entire output directory before every build#2586
emmadesilva merged 7 commits into
masterfrom
v3/deterministic-output-builds

Conversation

@emmadesilva

@emmadesilva emmadesilva commented Aug 10, 2026

Copy link
Copy Markdown
Member

Makes the site output directory disposable build output that is recreated from source on every build.

Follows on from #2585, which added _static as the source-controlled home for arbitrary root-level files. That was the only reason the cleaner had to preserve unknown files, so it no longer needs to.

User-facing changes

  • The whole output directory is emptied before every build, including dotfiles and nested directories. In v2 only HTML and JSON files plus the media directory were removed, so anything else left in _site survived indefinitely — including output whose _static source had since been deleted, which is the bug that motivated this.
  • Removed hyde.empty_output_directory and hyde.safe_output_directories, along with the confirmation prompt.
  • The build fails with an InvalidConfigurationException when the output directory is the project root, escapes the project, or is reached through a symbolic link.

Local builds now match what a clean CI or GitHub Pages build has always produced: if a file should exist after a build, it comes from source.

The safelist and its prompt were a safeguard on a destructive operation, and that safeguard is replaced rather than dropped. _static removes the reason to selectively preserve unknown files in the output; the deterministic-build contract removes the reason for empty_output_directory; but emptying a directory recursively still needs a guard. The replacement is structural and non-interactive, so it also works in CI, and it runs before the build writes anything, so it covers creating a site in the wrong place as well as emptying one.

Validation rules

OutputDirectoryValidator rejects a configured hyde.output_directory that:

  • resolves to the project root (empty, or only . segments);
  • contains a .. segment;
  • has any path component that is a symbolic link;
  • overlaps a directory the project needs.

The overlap rule is what keeps app, .git, vendor, and the source directories out of reach. The Hyde-owned entries are derived from runtime state — every registered page class's sourceDirectory() (which already includes source_root), the configured media directory, and _static — so a project that renames its source directories is protected at its own names. Only the structural project directories are a fixed list: .git, .github, app, config, node_modules, resources, tests, vendor. Overlap is checked in both directions, so neither app nor app/storage is accepted, and an output directory that would contain a source directory is rejected too. The comparison is case-insensitive on every platform, because case-insensitive filesystems resolve APP to app/ — verified here, where is_dir('APP') is true. realpath() would not have closed this: on macOS it echoes back the requested casing rather than the canonical one. Both sides also go through the same lexical segment normalization, so a media directory or source root configured as ./assets is matched by an output directory of assets; upwards traversal is left intact there and rejected separately for the output path.

This is not the safelist inverted. The safelist trusted a closed set of output names and interrogated everything else; this allows any name and refuses only what Hyde knows it must not destroy. _site, docs, build, dist, public, and foo/bar are all accepted without a prompt.

The symlink rule is what makes the traversal rules sound. Laravel's deleteDirectory() only skips symlinked children — it iterates the root path it is given, so _site -> app would have emptied app/. That is verified by test: with the symlink check removed, the target directory really is emptied. Rejecting links anywhere in the chain also covers an intermediate link whose final directory does not exist yet, where realpath() returns false and no destination-based check can see where the path leads.

Comparing paths as strings was the earlier approach and is deliberately gone: Hyde's path_join() always joins with / while the kernel base path keeps native separators, so a realpath() prefix comparison would have rejected valid fresh builds on Windows. Working from the configured relative path's segments avoids the comparison entirely.

Internal changes

  • Validation runs in BuildSiteCommand::runPreBuildActions() before any work, because a misconfigured output directory is fatal to the whole build rather than to one task. Build tasks report failures without aborting, so a guard only inside CleanSiteDirectory would have skipped the delete and then let compileStaticPages() write the site to the bad path anyway.
  • CleanSiteDirectory::handle() calls the same validator, so the destructive primitive keeps its own safety boundary when the task is invoked directly through BuildTaskService. There the exception is caught as a task failure, but nothing is deleted.
  • CleanSiteDirectory is now registered unconditionally, so BuildTaskServiceUnitTest expectations include it.
  • Fixed StaticFilePassthroughTest leaving an empty _static behind, which made a repeat run fail.

Testing

UnitFramework, FeatureFramework, and FeatureHyde pass, apart from the known pre-existing FeaturedImageUnitTest failure on PHP 8.5 (MediaFile::findHash()). HydeStan reports no errors.

Covered: dotfiles and nested directories removed; _static output disappearing when its source is deleted; project root, .. escape, output-directory symlink, and intermediate symlink all rejected; nothing created outside the project; a nested output directory that does not exist yet still builds; and direct CleanSiteDirectory invocation refusing to empty an invalid destination.

🤖 Generated with Claude Code

@emmadesilva
emmadesilva temporarily deployed to pr-documentation-2586 August 10, 2026 19:05 — with GitHub Actions Inactive
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (04c2b13) to head (573db00).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff             @@
##              master     #2586   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
+ Complexity      1815      1811    -4     
===========================================
  Files            184       184           
  Lines           4506      4494   -12     
===========================================
- Hits            4506      4494   -12     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@emmadesilva
emmadesilva force-pushed the v3/deterministic-output-builds branch from ee7ed2c to 463f970 Compare August 10, 2026 19:20
@emmadesilva
emmadesilva temporarily deployed to pr-documentation-2586 August 10, 2026 19:20 — with GitHub Actions Inactive
@emmadesilva
emmadesilva force-pushed the v3/deterministic-output-builds branch from 463f970 to e7eee16 Compare August 10, 2026 19:23
@emmadesilva
emmadesilva temporarily deployed to pr-documentation-2586 August 10, 2026 19:23 — with GitHub Actions Inactive
@emmadesilva
emmadesilva force-pushed the v3/deterministic-output-builds branch from e7eee16 to de42011 Compare August 10, 2026 19:33
@emmadesilva
emmadesilva temporarily deployed to pr-documentation-2586 August 10, 2026 19:34 — with GitHub Actions Inactive
@emmadesilva
emmadesilva force-pushed the v3/deterministic-output-builds branch from de42011 to 11a8e5b Compare August 10, 2026 19:51
@emmadesilva
emmadesilva temporarily deployed to pr-documentation-2586 August 10, 2026 19:51 — with GitHub Actions Inactive
emmadesilva and others added 3 commits August 10, 2026 22:21
The output directory is now wholly owned by Hyde and recreated from source
on every build, replacing the selective HTML/JSON cleaning that existed
because there was no source-controlled home for arbitrary root files.

Removes the `empty_output_directory` and `safe_output_directories` options
along with the interactive confirmation, and guards against a misconfigured
output directory resolving outside the project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers dotfiles and nested directories being removed, static file output
disappearing when its source is deleted, and the build failing when the
output directory resolves outside the project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@emmadesilva
emmadesilva force-pushed the v3/deterministic-output-builds branch from 11a8e5b to 5f76de4 Compare August 10, 2026 20:21
@emmadesilva
emmadesilva temporarily deployed to pr-documentation-2586 August 10, 2026 20:21 — with GitHub Actions Inactive
@emmadesilva
emmadesilva temporarily deployed to pr-documentation-2586 August 11, 2026 01:29 — with GitHub Actions Inactive
@emmadesilva
emmadesilva force-pushed the v3/deterministic-output-builds branch from 7df55b2 to ffc20ca Compare August 11, 2026 01:55
@emmadesilva
emmadesilva temporarily deployed to pr-documentation-2586 August 11, 2026 01:55 — with GitHub Actions Inactive
@emmadesilva
emmadesilva force-pushed the v3/deterministic-output-builds branch from ffc20ca to b702110 Compare August 11, 2026 01:56
@emmadesilva
emmadesilva temporarily deployed to pr-documentation-2586 August 11, 2026 01:56 — with GitHub Actions Inactive
emmadesilva and others added 3 commits August 11, 2026 04:33
The output directory guard rejected symbolic links, upwards traversal, and
any overlap with a list of project directories. Building outside the
project is a real use case, such as a Hyde project in a home directory
compiling into the server's public directory, and the symlink check broke
builds pointing the output directory at a deploy target.

Choosing an output directory that holds other files is a deliberate
setting, so the build no longer second-guesses it. The project root is
different: it is what an unfilled value resolves to rather than a
directory anyone means to hand over, and emptying it destroys the project
instead of the site. That leaves it as the only case worth checking, so it
is checked where the build can still abort on it.

The guard also ran in CleanSiteDirectory, where BuildTask::run() catches
the exception, so it printed a failure and let the build continue with
exit code 0. Only the command can abort, so the check lives there alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An absolute output directory is resolved relative to the project root
rather than rejected, so describing the path as one that must be a
subdirectory of the project was misleading for the case users would hit.

Name CNAME alongside the existing _static examples instead of arguing
against keeping it in the compiled site, which is a practice only
existing users have, and one the upgrade guide already covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The upgrade step prescribed deleting `hyde.empty_output_directory` from a
published config file, but that option was never shipped in
`config/hyde.php` and never documented, so no user has an entry to delete.

The remaining instructions promised a configuration error for any output
directory outside the project or overlapping one the project needs, which
overstates what the build checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@emmadesilva
emmadesilva force-pushed the v3/deterministic-output-builds branch from b702110 to ad4dc93 Compare August 11, 2026 02:33
@emmadesilva
emmadesilva temporarily deployed to pr-documentation-2586 August 11, 2026 02:33 — with GitHub Actions Inactive
@emmadesilva
emmadesilva merged commit 5c78a74 into master Aug 11, 2026
21 checks passed
@emmadesilva
emmadesilva deleted the v3/deterministic-output-builds branch August 11, 2026 02:42
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