Skip to content

fix: give init the working directory instead of the current one - #36

Merged
refsz merged 3 commits into
mainfrom
ci/harden-release
Aug 19, 2026
Merged

fix: give init the working directory instead of the current one#36
refsz merged 3 commits into
mainfrom
ci/harden-release

Conversation

@refsz

@refsz refsz commented Aug 19, 2026

Copy link
Copy Markdown
Owner

The 0.2.0 tag would have failed its own release workflow. I built the PHAR locally to check the release path before tagging, and the smoke test broke immediately.

What is broken

release.yml smoke-tests the PHAR like this:

php build/sputnik.phar --working-dir="$dir" init
php build/sputnik.phar --working-dir="$dir" example

InitCommand resolved its target with getcwd(), so the scaffold landed in the caller's directory - in CI that is the checkout root - while $dir received only .sputnik/ (the container cache, which does honour the working directory). The second command then failed:

Command "example" is not defined.

git tag --contains on the commit that added the smoke test returns nothing: it came after 0.1.0, so it has never run. The 0.2.0 tag would have been its first execution, failing after the tag was already public.

The bug is not limited to CI - --working-dir is documented as "all paths are resolved relative to this directory" (docs/cli.md:27), and for init that was simply false. Running sputnik --working-dir=/some/project init scaffolded into the wrong place.

The fix

InitCommand receives its target directory; Kernel passes the working directory it has already resolved.

The getcwd() guard is not dropped - it stays in Kernel::__construct, unchanged, where the resolution actually happens. It now exists once instead of twice.

Why the tests did not catch it

Every InitCommandTest case chdir()s into its temp directory, which makes target and cwd the same path - the exact condition under which the bug is invisible. They now pass the target explicitly, plus two regression tests that run from a directory which must stay empty:

  • one against InitCommand directly,
  • one through Kernel::createApplication().

Both are needed: I mutated Kernel back to new InitCommand((string) getcwd()) and the direct test still passed - it bypasses the wiring. Only the Kernel test failed. That mutation is why the second test exists.

The gate itself

Two holes, same shape: assertions that only ever ran somewhere else.

1. The PHAR is now built and smoke-tested on every pull request, so a tag can no longer be the first time that path executes. Both workflows call one script (.github/scripts/smoke-phar.sh) instead of duplicating inline steps, and it covers what the old steps did not:

  • argv survival - an argument containing ; must arrive whole; a shell would have split it. This is the container host-leak defect from feat!: run commands as argv instead of through a shell #30 as a regression test on the real binary.
  • secret masking - a variables.secrets value must appear as *** on the echoed command line, in the program's output, and in a message the task writes itself, and the raw value must appear nowhere.
  • a config the validator must reject - the pre-0.2 executor: "ddev exec {command}" string form.

Proof the assertions bite: I disabled the RedactingConsoleOutput wrapper in bin/sputnik, rebuilt, and the script exited 1 with the raw secret visible in the log. Reverted, exit 0.

2. tests/E2E now runs in CI. It asserts against build/sputnik.phar and marks itself skipped when the archive is absent - which it always was, so eight tests have never executed anywhere but a developer's machine. The PHAR job restores the dev dependencies after the build (the archive must not contain them) and runs the suite: 45 tests, 107 assertions, green locally.

mkdocs build --strict now also runs on pull requests, so a broken docs build is no longer something main discovers after the merge. Deployment stays push-only, and the pages concurrency group moved to the deploy job so a docs PR cannot cancel a live deploy.

Verified

vendor/bin/phpunit                        744 tests, 1286 assertions, OK
vendor/bin/phpunit --testsuite e2e        45 tests, 107 assertions, OK (against a built PHAR)
vendor/bin/phpstan analyse                [OK] No errors
vendor/bin/php-cs-fixer fix --dry-run     0 of 153 files
vendor/bin/rector --dry-run               [OK]
.github/scripts/smoke-phar.sh             exit 0

CI on the first commit was green on all eight jobs, including the new PHAR one.

🤖 Generated with Claude Code

https://claude.ai/code/session_018CTvnzcNYmFgm2HQcm821A

refsz added 3 commits August 19, 2026 20:43
Restores the typed class constant in EnvironmentDetector that had to be
reverted under 8.2, and marks the redacting decorators' overrides with
#[Override].

The attribute is not decoration here: a masking leak found earlier today was
exactly a method that no longer matched its parent - ConsoleSectionOutput's
decorated overwrite() path bypassed write() and writeln(), and nothing failed.
With #[Override], a renamed or resignatured Symfony method is a fatal error at
load time instead of output that quietly stops being redacted. Verified by
renaming one override and watching the class refuse to load.

Deliberately not included: typing every existing constant, which would be a
style sweep with no analytical gain, and rewriting withFormatter() as a clone -
8.3 permits reinitialising readonly properties only inside __clone(), which
cannot receive the new value, so the current constructor call stays correct.
The release workflow's smoke test runs `init --working-dir="$dir"` and then a
task from that directory. It never worked: InitCommand resolved its target with
getcwd(), so the scaffold landed in the caller's directory while $dir stayed
empty, and the task run failed with "Command example is not defined".

`git tag --contains` shows the smoke test was added after 0.1.0, so it has never
executed - the 0.2.0 tag would have been its first run, and it would have failed
after the tag was already public.

Two changes, one cause:

- InitCommand takes its target directory, and Kernel passes the working
  directory it already resolved. The getcwd() guard is not lost, it stays in
  Kernel::__construct where the resolution actually happens.
- The PHAR is now built and smoke-tested on every pull request, so a tag can no
  longer be the first execution of that path. Both workflows call one script,
  which also covers what the old inline steps did not: argv survival (an
  argument containing a semicolon) and secret masking, neither of which a unit
  test can observe from inside the archive.

The existing tests missed this because they chdir() into the temp directory,
making the target and the cwd the same path. They now pass the target
explicitly, and two regression tests run from a directory that must stay empty -
one against InitCommand, one through Kernel, since only the second covers the
wiring.

Also runs `mkdocs build --strict` on pull requests, so a broken docs build stops
being something main finds out about after the merge.
tests/E2E asserts against build/sputnik.phar and marks itself skipped when the
archive is absent, which it always was in CI - eight tests that have never run.
The PHAR job now restores the dev dependencies after the build and runs the
suite, so those assertions are part of the gate rather than a local-only habit.
@refsz
refsz merged commit 77f50d3 into main Aug 19, 2026
8 checks passed
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