diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index abec622fe3..64ec009a04 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -7,6 +7,7 @@ use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Vcs\Comment; +use Appwrite\Vcs\Validator\BuildTrigger; use Utopia\Config\Config; use Utopia\Console; use Utopia\Database\Database; @@ -534,22 +535,15 @@ trait Deployment private function isResourceBuildable(Document $resource, string $providerBranch, array $providerAffectedFiles, string $logBase): bool { - $allowedBranches = $resource->getAttribute('providerBranches', []); - if (!$this->matchesPatterns($providerBranch, $allowedBranches)) { + $branchTrigger = new BuildTrigger($resource->getAttribute('providerBranches', [])); + if (!$branchTrigger->isValid($providerBranch)) { Span::add("{$logBase}.build.skipped.reason", 'branch'); return false; } - $allowedPaths = $resource->getAttribute('providerPaths', []); - if (!empty($allowedPaths) && !empty($providerAffectedFiles)) { - $pathMatched = false; - foreach ($providerAffectedFiles as $file) { - if ($this->matchesPatterns($file, $allowedPaths)) { - $pathMatched = true; - break; - } - } - if (!$pathMatched) { + $pathTrigger = new BuildTrigger($resource->getAttribute('providerPaths', [])); + foreach ($providerAffectedFiles as $file) { + if (!$pathTrigger->isValid($file)) { Span::add("{$logBase}.build.skipped.reason", 'path'); return false; } @@ -557,46 +551,4 @@ trait Deployment return true; } - - private function matchesPatterns(string $subject, array $patterns): bool - { - if (empty($patterns)) { - return true; - } - - $include = array_filter($patterns, fn ($p) => !str_starts_with($p, '!')); - $exclude = array_filter($patterns, fn ($p) => str_starts_with($p, '!')); - - foreach ($include as $pattern) { - if ($this->matchGlob($subject, $pattern)) { - return true; - } - } - - foreach ($exclude as $pattern) { - if ($this->matchGlob($subject, substr($pattern, 1))) { - return false; - } - } - - return empty($include); - } - - private function matchGlob(string $subject, string $pattern): bool - { - $regex = preg_replace_callback( - '/\*\*|\*|\?|[^*?]+/', - static function (array $m): string { - return match ($m[0]) { - '**' => '.*', - '*' => '[^/]*', - '?' => '[^/]', - default => preg_quote($m[0], '/'), - }; - }, - $pattern - ); - - return (bool) preg_match('/^' . $regex . '$/', $subject); - } } diff --git a/src/Appwrite/Vcs/Validator/BuildTrigger.php b/src/Appwrite/Vcs/Validator/BuildTrigger.php new file mode 100644 index 0000000000..44296ae73a --- /dev/null +++ b/src/Appwrite/Vcs/Validator/BuildTrigger.php @@ -0,0 +1,91 @@ +patterns)) { + return true; + } + + $include = array_filter($this->patterns, fn ($p) => !str_starts_with($p, '!')); + $exclude = array_filter($this->patterns, fn ($p) => str_starts_with($p, '!')); + + foreach ($include as $pattern) { + if ($this->matchGlob($value, $pattern)) { + return true; + } + } + + foreach ($exclude as $pattern) { + if ($this->matchGlob($value, substr($pattern, 1))) { + return false; + } + } + + return empty($include); + } + + public function getDescription(): string + { + return 'Value must match at least one inclusion pattern and must not match any exclusion pattern.'; + } + + public function isArray(): bool + { + return false; + } + + public function getType(): string + { + return self::TYPE_STRING; + } + + private function matchGlob(string $subject, string $pattern): bool + { + $regex = ''; + $len = strlen($pattern); + $i = 0; + + while ($i < $len) { + $char = $pattern[$i]; + + if ($char === '*' && isset($pattern[$i + 1]) && $pattern[$i + 1] === '*') { + $prevSlash = $i === 0 || $pattern[$i - 1] === '/'; + $nextSlash = isset($pattern[$i + 2]) && $pattern[$i + 2] === '/'; + + if ($prevSlash && $nextSlash) { + // a/**/b → zero or more intermediate dirs (matches a/b, a/x/b, a/x/y/b) + // **/foo → zero or more leading dirs (matches foo, a/foo, a/b/foo) + $regex .= '(?:.+/)?'; + $i += 3; // consume ** and the trailing / + } else { + // foo/** → everything inside (matches foo/a, foo/a/b) + $regex .= '.*'; + $i += 2; + } + } elseif ($char === '*') { + $regex .= '[^/]*'; // anything except a path separator + $i++; + } elseif ($char === '?') { + $regex .= '[^/]'; // any single character except a path separator + $i++; + } else { + $regex .= preg_quote($char, '~'); + $i++; + } + } + + return (bool) preg_match('~^' . $regex . '$~', $subject); + } +} diff --git a/tests/unit/Vcs/Validator/BuildTriggerTest.php b/tests/unit/Vcs/Validator/BuildTriggerTest.php new file mode 100644 index 0000000000..7dbf4e8204 --- /dev/null +++ b/tests/unit/Vcs/Validator/BuildTriggerTest.php @@ -0,0 +1,86 @@ +assertTrue($validator->isValid('anything')); + $this->assertTrue($validator->isValid('main')); + } + + public function testExactMatch(): void + { + $validator = new BuildTrigger(['main']); + $this->assertTrue($validator->isValid('main')); + $this->assertFalse($validator->isValid('develop')); + } + + public function testSingleWildcard(): void + { + $validator = new BuildTrigger(['feature/*']); + $this->assertTrue($validator->isValid('feature/foo')); + $this->assertFalse($validator->isValid('feature/foo/bar')); // * does not cross / + $this->assertFalse($validator->isValid('main')); + } + + public function testDashInPattern(): void + { + $validator = new BuildTrigger(['feature/test-*']); + $this->assertTrue($validator->isValid('feature/test-1')); + $this->assertTrue($validator->isValid('feature/test-abc')); + $this->assertFalse($validator->isValid('feature/other')); + } + + public function testDoubleWildcardEnd(): void + { + $validator = new BuildTrigger(['src/**']); + $this->assertTrue($validator->isValid('src/foo.js')); + $this->assertTrue($validator->isValid('src/a/b/c.js')); + } + + public function testDoubleWildcardMiddle(): void + { + $validator = new BuildTrigger(['a/**/b']); + $this->assertTrue($validator->isValid('a/b')); // zero intermediate dirs + $this->assertTrue($validator->isValid('a/x/b')); // one + $this->assertTrue($validator->isValid('a/x/y/b')); // two + $this->assertFalse($validator->isValid('a/b/c')); + } + + public function testDoubleWildcardStart(): void + { + $validator = new BuildTrigger(['**/foo']); + $this->assertTrue($validator->isValid('foo')); + $this->assertTrue($validator->isValid('a/foo')); + $this->assertTrue($validator->isValid('a/b/foo')); + $this->assertFalse($validator->isValid('foobar')); + } + + public function testOrSemantics(): void + { + $validator = new BuildTrigger(['main', 'develop']); + $this->assertTrue($validator->isValid('main')); + $this->assertTrue($validator->isValid('develop')); + $this->assertFalse($validator->isValid('feature/x')); + } + + public function testInclusionTakesPrecedenceOverExclusion(): void + { + $validator = new BuildTrigger(['!feature/*', 'feature/abc']); + $this->assertTrue($validator->isValid('feature/abc')); // inclusion wins + $this->assertFalse($validator->isValid('feature/xyz')); // excluded + } + + public function testOnlyExclusions(): void + { + $validator = new BuildTrigger(['!main']); + $this->assertFalse($validator->isValid('main')); + $this->assertTrue($validator->isValid('develop')); // not excluded, passes by default + } +}