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
2 changes: 1 addition & 1 deletion .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
}
}

$finder->exclude(['fixtures']);
$finder->exclude(['fixtures', 'build', 'vendor']);

$config = (new PhpCsFixer\Config())
->setRules([
Expand Down
1 change: 1 addition & 0 deletions box.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"files": [
"LICENSE",
"bin/horde-components",
"phpstan-bootstrap.php",
".php-cs-fixer.dist.php"
],
"finder": [
Expand Down
1 change: 1 addition & 0 deletions box.json.dist
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"files": [
"LICENSE",
"bin/horde-components",
"phpstan-bootstrap.php",
".php-cs-fixer.dist.php"
],
"finder": [
Expand Down
12 changes: 7 additions & 5 deletions data/ci/bootstrap-github.sh.template
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ NC=''

# Logging functions
log_info() {
if [ -n "$GITHUB_ACTIONS" ]; then
echo "::notice::$*"
else
echo "[INFO] $*"
fi
# Bootstrap-stage progress lines stay as plain stdout under
# GitHub Actions. Routing them through ::notice:: would flood the
# Annotations panel with non-actionable entries. log_warn and
# log_error still emit workflow commands because bootstrap-level
# problems (missing GITHUB_TOKEN, download failure, etc.) belong
# in the panel.
echo "[INFO] $*"
}

log_warn() {
Expand Down
15 changes: 10 additions & 5 deletions data/ci/workflow.yml.template
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,25 @@ on:

jobs:
ci:
name: CI
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
checks: write

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Cache horde-components.phar
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: {{WORK_DIR}}/bin
key: components-phar-${{ hashFiles('bin/ci-bootstrap.sh') }}

- name: Cache QC tools (PHPUnit, PHPStan, PHP-CS-Fixer)
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: {{WORK_DIR}}/tools
key: ci-tools-${{ hashFiles('.horde.yml') }}
Expand All @@ -44,9 +49,9 @@ jobs:

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: ci-results-${{ github.run_number }}
name: ci-results-pr${{ github.event.pull_request.number || github.run_number }}-${{ github.sha }}
path: |
{{WORK_DIR}}/lanes/*/{{COMPONENT_NAME}}/build/*.json
retention-days: 30
14 changes: 11 additions & 3 deletions src/Ci/Config/CiConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,18 +159,26 @@ public function getTestLanes(): array
$testablePhpVersions = $this->getTestablePhpVersions();

foreach ($testablePhpVersions as $phpVersion) {
// Dev lane
// Dev lane — minimum-stability=dev so transitive deps may
// resolve to dev branches.
$lanes[] = [
'php' => $phpVersion,
'stability' => 'dev',
'dir' => $this->workDir . '/lanes/php' . $phpVersion . '-dev/' . $this->componentName,
];

// Stable lane (or component's own stability)
// Second lane uses the component's own declared stability
// (alpha, beta, RC, stable). The directory name carries the
// same string so downstream readers (RunCommand::discoverLanes,
// log labels, artifact globs) all see one consistent label.
// Previously this was hardcoded to "-stable" while the
// stability field carried the component's value, causing the
// same lane to appear as both "php8.5-stable" and
// "php8.5-alpha" in different log lines.
$lanes[] = [
'php' => $phpVersion,
'stability' => $this->componentStability,
'dir' => $this->workDir . '/lanes/php' . $phpVersion . '-stable/' . $this->componentName,
'dir' => $this->workDir . '/lanes/php' . $phpVersion . '-' . $this->componentStability . '/' . $this->componentName,
];
}

Expand Down
173 changes: 173 additions & 0 deletions src/Ci/GitHubAnnotations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php

/**
* Copyright 2013-2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @package Components
* @author Ralf Lang <ralf.lang@ralf-lang.de>
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
*/

declare(strict_types=1);

namespace Horde\Components\Ci;

/**
* Emit GitHub Actions workflow-command annotations on stdout.
*
* The runner picks up lines of the form
*
* ::error file=path,line=N,title=T::message text
* ::warning file=path,line=N::message text
* ::notice file=path::message text
*
* and surfaces them as line-anchored annotations on the PR diff plus
* entries in the run's "Annotations" sidebar. See
* https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
*
* Path values must be **relative to `$GITHUB_WORKSPACE`** for the runner
* to anchor an annotation to a diff line. Absolute paths from the lane
* directory (`/tmp/horde-ci/lanes/.../Victim/src/X.php`) won't anchor;
* use {@see relativizeForAnnotation()} to strip the lane prefix.
*
* Outside GitHub Actions (no `GITHUB_ACTIONS=true` env var) every
* emit method is a silent no-op so local invocations don't print
* meaningless `::error::` lines.
*
* @category Horde
* @package Components
* @author Ralf Lang <ralf.lang@ralf-lang.de>
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
*/
class GitHubAnnotations
{
/**
* Emit a workflow-command line at error severity.
*
* @param string $message Plain message body. Newlines and `%` are
* escaped to GitHub's wire format.
* @param string|null $file Path relative to $GITHUB_WORKSPACE, or null
* to emit a sidebar-only annotation.
* @param int|null $line 1-based line number, or null.
* @param string|null $title Short title shown above the message.
*/
public static function error(
string $message,
?string $file = null,
?int $line = null,
?string $title = null
): void {
self::emit('error', $message, $file, $line, $title);
}

public static function warning(
string $message,
?string $file = null,
?int $line = null,
?string $title = null
): void {
self::emit('warning', $message, $file, $line, $title);
}

public static function notice(
string $message,
?string $file = null,
?int $line = null,
?string $title = null
): void {
self::emit('notice', $message, $file, $line, $title);
}

/**
* Strip a lane/component prefix from an absolute path so the runner
* can anchor the annotation to the PR diff.
*
* Example: a finding for
* /tmp/horde-ci/lanes/php8.3-dev/Victim/src/ComposerJsonFile.php
* with componentDir
* /tmp/horde-ci/lanes/php8.3-dev/Victim
* returns "src/ComposerJsonFile.php".
*
* If the absolute path is not under componentDir, returns it unchanged.
*/
public static function relativizeForAnnotation(
string $absolute,
string $componentDir
): string {
$componentDir = rtrim($componentDir, '/') . '/';
if (str_starts_with($absolute, $componentDir)) {
return substr($absolute, strlen($componentDir));
}
return $absolute;
}

/**
* Whether annotations will actually be emitted in the current
* environment. Useful for callers that want to skip building the
* data when no one is listening.
*/
public static function isActive(): bool
{
return getenv('GITHUB_ACTIONS') === 'true';
}

private static function emit(
string $severity,
string $message,
?string $file,
?int $line,
?string $title
): void {
if (!self::isActive()) {
return;
}

$params = [];
if ($file !== null && $file !== '') {
$params[] = 'file=' . self::escapeProperty($file);
}
if ($line !== null && $line > 0) {
$params[] = 'line=' . $line;
}
if ($title !== null && $title !== '') {
$params[] = 'title=' . self::escapeProperty($title);
}

$paramStr = $params === [] ? '' : ' ' . implode(',', $params);
echo '::' . $severity . $paramStr . '::' . self::escapeData($message) . PHP_EOL;
}

/**
* Escape per GitHub's workflow-command spec for the message body.
*
* Newlines have to be escaped as %0A or the runner will treat
* the next line as a new command (or, worse, swallow it).
*/
private static function escapeData(string $value): string
{
return strtr($value, [
'%' => '%25',
"\r" => '%0D',
"\n" => '%0A',
]);
}

/**
* Escape for property values (file=, line=, title=). Property values
* also need `:` and `,` escaped because those are field separators.
*/
private static function escapeProperty(string $value): string
{
return strtr($value, [
'%' => '%25',
"\r" => '%0D',
"\n" => '%0A',
':' => '%3A',
',' => '%2C',
]);
}
}
Loading
Loading