Compare commits

..

2 Commits

Author SHA1 Message Date
Marijn 815e7b17be Add missing quiz/nav.html.twig and test for template reference integrity (#166)
Adds the previously uncommitted quiz nav partial that broke main, and
introduces TemplateReferencesTest which scans all Twig templates for
extends/include/embed references and asserts each target file exists —
preventing this class of missing-template mistake from reaching CI undetected.
2026-07-02 21:08:21 +00:00
Marijn 764f59e6a7 Improve GitHub Actions CI: parallelise jobs, continue-on-error, timeouts, cache optimisation (#165)
* Strip v-prefix from version tag before passing to Sentry

GitHub tags follow the v1.2.3 convention, but Sentry requires bare
semver (1.2.3) to recognise releases as valid semver. Extract a
sentry_version output in the meta step that strips the leading v.

* Parallelize CI: split quality and tests jobs, add continue-on-error

- Split the single tests job into parallel quality and tests jobs,
  saving ~4 min wall-clock time per run
- Quality checks (lint, CS, PHPStan, Rector) now all run with
  continue-on-error so every failure is visible in one pass; a
  final Assert step fails the job if any check failed
- Add cache:warmup before PHPStan so the Symfony dev container XML
  exists and the Symfony extension has full type information
- Use per-job GHA cache scopes to avoid parallel cache write races
- Use cache mode=min on PRs, mode=max on main/tags
- Add timeout-minutes (20/20/15) to all jobs
- Remove dead if:false Mercure reachability step
- Fix Portainer webhook URL quoting
- build-deploy now needs: [quality, tests]

* Simplify build-deploy job name and environment expressions

* Use static name for build-deploy job (expressions not evaluated when skipped)

* build-deploy only needs tests, not quality (quality is informational)

* Revert: build-deploy needs both quality and tests
2026-07-02 23:06:23 +02:00
3 changed files with 140 additions and 16 deletions
+60 -15
View File
@@ -17,12 +17,11 @@ permissions:
contents: read
jobs:
tests:
name: Tests
quality:
name: Code Quality
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
checks: write
pull-requests: write
contents: read
steps:
- name: Checkout
@@ -40,26 +39,68 @@ jobs:
compose.yaml
compose.override.yaml
set: |
*.cache-from=type=gha,scope=${{github.ref}}
*.cache-from=type=gha,scope=${{github.ref}}-quality
*.cache-from=type=gha,scope=refs/heads/main
*.cache-to=type=gha,scope=${{github.ref}},mode=max
*.cache-to=type=gha,scope=${{github.ref}}-quality,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
- name: Start services
run: docker compose up php database --wait --no-build
- name: Warm up dev cache
run: docker compose exec -T php bin/console cache:warmup --env=dev
- name: Lint Twig templates
id: twig_lint
continue-on-error: true
run: docker compose exec -T php bin/console lint:twig --format=github templates
- name: Coding Style
id: cs
continue-on-error: true
run: docker compose exec -T php vendor/bin/php-cs-fixer check --diff --show-progress=none
- name: Twig Coding Style
id: twig_cs
continue-on-error: true
run: docker compose exec -T php vendor/bin/twig-cs-fixer check
- name: Static Analysis (PHPStan)
id: phpstan
continue-on-error: true
run: docker compose exec -T php vendor/bin/phpstan analyse --no-progress --no-ansi --error-format=github
- name: Rector
id: rector
continue-on-error: true
run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github
- name: Check HTTP reachability
run: curl -v --fail-with-body http://localhost
- name: Check Mercure reachability
if: false
run: curl -vkI --fail-with-body https://localhost/.well-known/mercure?topic=test
- name: Assert all checks passed
if: always()
run: |
outcomes="${{ steps.twig_lint.outcome }} ${{ steps.cs.outcome }} ${{ steps.twig_cs.outcome }} ${{ steps.phpstan.outcome }} ${{ steps.rector.outcome }}"
if echo "$outcomes" | grep -q "failure"; then exit 1; fi
tests:
name: Tests
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
checks: write
pull-requests: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker images
uses: docker/bake-action@v5
with:
pull: true
load: true
files: |
compose.yaml
compose.override.yaml
set: |
*.cache-from=type=gha,scope=${{github.ref}}-tests
*.cache-from=type=gha,scope=refs/heads/main
*.cache-to=type=gha,scope=${{github.ref}}-tests,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
- name: Start services
run: docker compose up php database --wait --no-build
- name: Create test database
run: docker compose exec -T php bin/console -e test doctrine:database:create
- name: Run migrations
@@ -78,15 +119,16 @@ jobs:
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
build-deploy:
name: Build and deploy to ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
name: Build and Deploy
permissions:
contents: read
packages: write
environment:
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }}
url: ${{ vars.URL }}
needs: tests
needs: [quality, tests]
runs-on: ubuntu-latest
timeout-minutes: 15
if: (github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout
@@ -106,14 +148,17 @@ jobs:
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
TAG="${GITHUB_REF#refs/tags/}"
SENTRY_VERSION="${TAG#v}"
{
echo "tag=$TAG"
echo "sentry_version=$SENTRY_VERSION"
echo "full_name=ghcr.io/${REPO_LOWER}:$TAG"
} >> "$GITHUB_OUTPUT"
else
SHORT_SHA=$(git rev-parse --short HEAD)
{
echo "tag=$SHORT_SHA"
echo "sentry_version=$SHORT_SHA"
echo "full_name=ghcr.io/${REPO_LOWER}:$SHORT_SHA"
} >> "$GITHUB_OUTPUT"
fi
@@ -139,12 +184,12 @@ jobs:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
release: ${{steps.meta.outputs.tag}}
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
release: ${{steps.meta.outputs.sentry_version}}
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }}
- name: Trigger Portainer Deployment
shell: bash
env:
PORTAINER_WEBHOOK: ${{secrets.PORTAINER_WEBHOOK}}
run: |
curl -v -X POST "$PORTAINER_WEBHOOK"?IMAGE_TAG=${{steps.meta.outputs.tag}} --fail-with-body
curl -v -X POST "${PORTAINER_WEBHOOK}?IMAGE_TAG=${{steps.meta.outputs.tag}}" --fail-with-body
+16
View File
@@ -0,0 +1,16 @@
{% if is_granted('IS_AUTHENTICATED') or app.current_route() == 'tvdt_quiz_select_season' %}
<div class="quiz-topbar">
{% if is_granted('IS_AUTHENTICATED') %}
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Backoffice'|trans }}
</a>
<a href="{{ path('tvdt_login_logout') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Logout'|trans }}
</a>
{% else %}
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Manage Quiz'|trans }}
</a>
{% endif %}
</div>
{% endif %}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Twig;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use function Safe\file_get_contents;
use function Safe\preg_match_all;
final class TemplateReferencesTest extends TestCase
{
private static string $templatesDir;
public static function setUpBeforeClass(): void
{
self::$templatesDir = \dirname(__DIR__, 2).'/templates';
}
/** @return iterable<string, array{string, string}> */
public static function templateReferenceProvider(): iterable
{
$templatesDir = \dirname(__DIR__, 2).'/templates';
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($templatesDir, \RecursiveDirectoryIterator::SKIP_DOTS),
);
foreach ($iterator as $file) {
Assert::assertInstanceOf(\SplFileInfo::class, $file);
if ('twig' !== $file->getExtension()) {
continue;
}
$content = file_get_contents($file->getPathname());
$sourceFile = str_replace($templatesDir.'/', '', $file->getPathname());
// Match extends, include(), and embed tags — capture the quoted template name
preg_match_all(
'/(?:extends|include|embed)\s*\(?[\'"]([^\'"]+)[\'"]\)?/',
$content,
$matches,
);
foreach ($matches[1] as $referencedTemplate) {
yield \sprintf('%s → %s', $sourceFile, $referencedTemplate) => [$sourceFile, $referencedTemplate];
}
}
}
#[DataProvider('templateReferenceProvider')]
public function testReferencedTemplateExists(string $sourceFile, string $referencedTemplate): void
{
$absolutePath = self::$templatesDir.'/'.$referencedTemplate;
$this->assertFileExists(
$absolutePath,
\sprintf("Template '%s' references '%s' which does not exist at '%s'.", $sourceFile, $referencedTemplate, $absolutePath),
);
}
}