Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
* text=auto eol=lf

/.github export-ignore
/tests export-ignore
/docs export-ignore
/AGENTS.md export-ignore
/CONTRIBUTING.md export-ignore
/qa-adoption.json export-ignore
/phpcs.xml export-ignore
/phpcs.xml.dist export-ignore
/phpstan.neon export-ignore
/phpstan.neon.dist export-ignore
/phpunit*.xml* export-ignore
/.php-cs-fixer* export-ignore
/.gitignore export-ignore
/.gitattributes export-ignore
/.env* export-ignore
/auth.json export-ignore
/.npmrc export-ignore
/composer.review*.json export-ignore
/composer.review*.lock export-ignore
18 changes: 18 additions & 0 deletions .github/workflows/dependency-canary.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Dependency compatibility

on:
schedule:
- cron: '29 4 * * 3'
workflow_dispatch:

permissions:
contents: read

jobs:
compatibility:
permissions:
contents: read
issues: write
uses: sympress/workflows/.github/workflows/dependency-canary.yml@177fa0d727b278d2103052ec77c102b4a4c492a0
with:
php_version: '8.5'
2 changes: 1 addition & 1 deletion .github/workflows/qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ permissions:

jobs:
qa:
uses: sympress/workflows/.github/workflows/sympress-qa.yml@v1
uses: sympress/workflows/.github/workflows/sympress-qa.yml@177fa0d727b278d2103052ec77c102b4a4c492a0
with:
php_version: '8.5'
45 changes: 33 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,13 @@ sympress new shop-platform \
Use a repository-provided manifest:

```bash
sympress new my-site --manifest=https://github.com/SymPress/starter
sympress new my-site --template-version=<reviewed-tag-or-SHA> --allow-template-execution
```

The CLI also tries to discover a manifest in the selected template repository by
default. Use `--no-remote-manifest` for offline or fully pinned runs.
default, from the same resolved immutable Git SHA used to materialize the template.
Use --no-remote-manifest with an explicit local/offline catalog when discovery
is unnecessary; creation still resolves a non-SHA selected version before download.

## Update A Project

Expand Down Expand Up @@ -130,14 +132,17 @@ By default the update command patches `composer.json`, updates

## What It Does

1. Runs `composer create-project sympress/starter <dir> --no-install`.
1. Resolves the selected version/ref once to an immutable Git SHA, clones without
checkout and checks out that SHA detached with Git hooks disabled. Composer
installation, root scripts and plugins are not executed during materialization.
2. Writes project metadata and selected package suggestions to `composer.json`.
3. Creates `.env` from `.env.example` and fills first-run values:
`WP_HOME`, `WP_SITEURL`, `WP_ADMIN_USERNAME`, `WP_ADMIN_PASSWORD`,
`DDEV_PROJECT_NAME` and `DDEV_PROJECT_TLD`.
4. Writes `.sympress/project.json` so future update runs know the project type,
starter template and repository.
5. Runs the starter setup command, unless `--no-setup` is passed.
starter template, repository and actual immutable template SHA.
5. Runs the setup command from the checked-out manifest only when --allow-template-execution
explicitly authorizes it. --no-setup and dry-run never execute setup.

## Project Types

Expand Down Expand Up @@ -166,8 +171,9 @@ sympress project:create [directory] [options]
- `--template-version=1.0.x-dev`
- `--manifest=./sympress-cli.json`
- `--manifest=https://github.com/SymPress/starter`
- `--manifest-ref=main`
- `--manifest-ref=<40-character-Git-SHA>`
- `--no-remote-manifest`
- `--allow-template-execution`
- `--no-setup`
- `--dry-run`

Expand All @@ -179,7 +185,7 @@ Update options:
- `--repository=https://github.com/SymPress/starter`
- `--template-version=1.0.x-dev`
- `--manifest=./sympress-cli.json`
- `--manifest-ref=main`
- `--manifest-ref=<40-character-Git-SHA>`
- `--no-remote-manifest`
- `--no-install`
- `--dry-run`
Expand Down Expand Up @@ -243,11 +249,26 @@ The canonical format is defined by
[`schema/repository-manifest.schema.json`](schema/repository-manifest.schema.json).
Malformed entries fail as a whole instead of being silently ignored.

A remote manifest is trusted executable input because `setupCommand` is run as
an argv array after project creation. Remote loading requires HTTPS. Pin
`--manifest-ref` to a reviewed tag or commit for reproducible automation. Use
`--no-setup` to inspect generated files before running a repository-provided
setup command.
Repository manifests are read from the same selected immutable SHA as template
files; there is no fallback to main. A snapshot manifest cannot redirect the
already selected package/repository/default ref. Explicit raw HTTPS JSON URLs and
local files are independent catalogs with their own provenance; remote Git
catalogs require a 40-character --manifest-ref. Materialization still inspects the
checked-out manifest before setup.

--allow-template-execution authorizes setup commands and all executable behavior
those commands invoke (including Composer scripts/plugins). Review the repository,
SHA and argv before using it. Without the flag, setup refuses before cloning;
--no-setup can be used to inspect the source first. No shell is used and command
failures propagate. The initial Git clone/checkout stage disables Git hooks and
does not run Composer. A created project records the immutable SHA; update
catalog loading follows the selected target SHA, and changing source/version
invalidates the previous recorded identity.

Dotenv literal values round-trip comments, dollars, quotes, backslashes, Unicode
and whitespace. Only WP_SITEURL intentionally expands ${WP_HOME}/wp. The .env
file is created/written with mode 0600. Completion and dry-run output never print
an admin password; the credential is stored privately in .env.

## Extending

Expand Down
6 changes: 4 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"require-dev": {
"phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^11.5",
"squizlabs/php_codesniffer": "^3.13"
"squizlabs/php_codesniffer": "^3.13",
"symfony/dotenv": "^8.1"
},
"autoload": {
"psr-4": {
Expand Down Expand Up @@ -65,5 +66,6 @@
"optimize-autoloader": true,
"sort-packages": true
},
"prefer-stable": true
"prefer-stable": true,
"minimum-stability": "stable"
}
9 changes: 9 additions & 0 deletions docs/releases/0.1.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# 0.1.0 release candidate

This candidate contains the 2026-10-01 review repairs and requires the stable
SymPress dependency train. It has not been published yet. CI consumers use the
reviewed workflows commit 177fa0d727b278d2103052ec77c102b4a4c492a0.

Local exact-commit Composer archives verify dependency resolution and distribution
contents before publication. They do not imply that prospective tags exist on
GitHub or Packagist. Historical tags remain unchanged.
2 changes: 1 addition & 1 deletion src/Catalog/DefaultTemplateCatalog.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public function __construct(array $templates = [])
label: 'SymPress Starter',
packageName: 'sympress/starter',
repositoryUrl: 'https://github.com/SymPress/starter',
description: 'WordPress website starter with DDEV, WPStarter, SymPress kernel, WP-CLI'
description: 'WordPress website starter with DDEV, SymPress Runtime, SymPress kernel, WP-CLI'
. ' and quality tooling.',
),
];
Expand Down
13 changes: 12 additions & 1 deletion src/Command/CreateProjectCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,18 @@ protected function configure(): void
InputOption::VALUE_REQUIRED,
'Manifest file, URL or GitHub repository to load template metadata from.',
)
->addOption('manifest-ref', null, InputOption::VALUE_REQUIRED, 'Git ref used for remote manifests.', 'main')
->addOption(
'manifest-ref',
null,
InputOption::VALUE_REQUIRED,
'Explicit immutable Git SHA for an independent manifest.'
)
->addOption(
'allow-template-execution',
null,
InputOption::VALUE_NONE,
'Trust the selected repository revision and its setup commands.'
)
->addOption(
'no-remote-manifest',
null,
Expand Down
41 changes: 27 additions & 14 deletions src/Command/UpdateProjectCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ protected function configure(): void
InputOption::VALUE_REQUIRED,
'Manifest file, URL or GitHub repository to load template metadata from.',
)
->addOption('manifest-ref', null, InputOption::VALUE_REQUIRED, 'Git ref used for remote manifests.', 'main')
->addOption('manifest-ref', null, InputOption::VALUE_REQUIRED, 'Immutable Git SHA for remote manifests.')
->addOption(
'no-remote-manifest',
null,
Expand Down Expand Up @@ -82,7 +82,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
try {
$projectDir = $this->projectDir($input, getcwd() ?: '.');
$metadata = $this->metadataStore->read($projectDir);
[$templates, $profiles, $packages] = $this->catalogs($input, $metadata);
[$templates, $profiles, $packages, $revision] = $this->catalogs($input, $metadata);
$level = $this->level($input, $io);
$targetType = $this->targetType($input, $io, $level, $metadata, $profiles);
$targetTemplate = $this->targetTemplate($input, $level, $metadata);
Expand All @@ -99,6 +99,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$targetTemplate,
$templateRepository,
$templateVersion,
$revision,
);

return $this->updater->apply(
Expand Down Expand Up @@ -137,7 +138,7 @@ private function projectDir(InputInterface $input, string $cwd): string
}

/**
* @return array{0: DefaultTemplateCatalog, 1: DefaultProfileCatalog, 2: DefaultPackageCatalog}
* @return array{0: DefaultTemplateCatalog, 1: DefaultProfileCatalog, 2: DefaultPackageCatalog, 3: string|null}
*/
private function catalogs(InputInterface $input, ProjectMetadata $metadata): array
{
Expand All @@ -156,19 +157,31 @@ private function catalogs(InputInterface $input, ProjectMetadata $metadata): arr
}

if ((bool) $input->getOption('no-remote-manifest')) {
return [$templates, $profiles, $packages];
return [$templates, $profiles, $packages, null];
}

$remoteManifest = $this->manifestLoader->loadFromRepository(
$metadata->templateRepositoryUrl,
$this->manifestRef($input),
);

$selected = $templates->get($this->stringOption($input, 'template') ?? $metadata->templateId);
$changed = $selected->id !== $metadata->templateId;
$repository = $this->stringOption($input, 'repository')
?? ($changed ? $selected->repositoryUrl : $metadata->templateRepositoryUrl);
$version = $this->stringOption($input, 'template-version')
?? ($changed ? $selected->defaultVersion : $metadata->templateVersion);
$revision = $repository === $metadata->templateRepositoryUrl && $version === $metadata->templateVersion
? $metadata->templateRevision : null;
$revision ??= (new \SymPress\Cli\Repository\TemplateRevision())->resolve($repository, $version);
$remoteManifest = $this->manifestLoader->loadFromRepository($repository, $revision);
if ($remoteManifest === null) {
return [$templates, $profiles, $packages];
return [$templates, $profiles, $packages, $revision];
}

return $this->applyManifest($remoteManifest, $templates, $profiles, $packages);
foreach ($remoteManifest->templates as $candidate) {
if (
$candidate->id === $selected->id && ($candidate->repositoryUrl !== $selected->repositoryUrl
|| $candidate->packageName !== $selected->packageName
|| $candidate->defaultVersion !== $selected->defaultVersion)
) {
throw new RuntimeException('Snapshot manifest cannot redirect the selected template.');
}
}
return [...$this->applyManifest($remoteManifest, $templates, $profiles, $packages), $revision];
}

private function level(InputInterface $input, SymfonyStyle $io): string
Expand Down Expand Up @@ -250,7 +263,7 @@ private function applyManifest(

private function manifestRef(InputInterface $input): string
{
return $this->stringOption($input, 'manifest-ref') ?: 'main';
return $this->stringOption($input, 'manifest-ref') ?: '';
}

private function stringOption(InputInterface $input, string $name): ?string
Expand Down
44 changes: 33 additions & 11 deletions src/Generator/EnvFileEditor.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,25 @@ public function apply(string $projectDir, ProjectConfiguration $configuration):
$envFile = $projectDir . '/.env';
$envExample = $projectDir . '/.env.example';

if (!is_file($envFile)) {
if (is_file($envExample)) {
$this->filesystem->copy($envExample, $envFile);
} else {
$this->filesystem->dumpFile($envFile, '');
$previous = umask(0077);
try {
if (!is_file($envFile)) {
if (is_file($envExample)) {
$this->filesystem->copy($envExample, $envFile);
} else {
$this->filesystem->dumpFile($envFile, '');
}
}
}

chmod($envFile, 0600);
} finally {
umask($previous);
}
$this->update($envFile, [
'DDEV_PROJECT_NAME' => $configuration->projectSlug,
'DDEV_PROJECT_TLD' => $configuration->ddevTld,
'WP_HOME' => $configuration->wpHome(),
'WP_SITEURL' => '${WP_HOME}/wp',
'WP_SITEURL' => '${WP_HOME}',
'WP_ADMIN_USERNAME' => $configuration->wpAdminUsername,
'WP_ADMIN_PASSWORD' => $configuration->wpAdminPassword,
]);
Expand All @@ -53,19 +59,35 @@ private function update(string $envFile, array $values): void

foreach ($lines as $index => $line) {
foreach ($values as $key => $value) {
if (str_starts_with((string) $line, $key . '=')) {
$lines[$index] = $key . '=' . $value;
if (preg_match('/^(?:export\s+)?' . preg_quote($key, '/') . '\s*=/', trim((string) $line)) === 1) {
if ($key === 'WP_SITEURL') {
// The selected template owns its WordPress root/subdirectory layout.
$seen[$key] = true;
continue;
}
$lines[$index] = $key . '=' . ($key === 'WP_SITEURL' ? $value : $this->quote($value));
$seen[$key] = true;
}
}
}

foreach ($values as $key => $value) {
if (!isset($seen[$key])) {
$lines[] = $key . '=' . $value;
$lines[] = $key . '=' . ($key === 'WP_SITEURL' ? $value : $this->quote($value));
}
}

$this->filesystem->dumpFile($envFile, implode("\n", $lines) . "\n");
$previous = umask(0077);
try {
$this->filesystem->dumpFile($envFile, implode("\n", $lines) . "\n");
chmod($envFile, 0600);
} finally {
umask($previous);
}
}

private function quote(string $value): string
{
return '"' . str_replace(['\\', '"', '$', "\r", "\n"], ['\\\\', '\\"', '\\$', '\\r', '\\n'], $value) . '"';
}
}
Loading
Loading