Files
TijdVoorDeTest/.github/workflows/ci.yml
T
Marijn 1d3e99d2b2 feat: user settings page (#191)
* feat: user settings page (#182)

- Settings link in the backoffice nav next to Logout
- Language selector (Dutch only, noop save)
- Change password form with current-password check
- Change email form that re-triggers email confirmation
- Resend confirmation email button for unconfirmed addresses
- Disabled Download data button with Soon(tm) popover
- Delete account with password confirmation modal, removes
  seasons the user is the sole owner of
- Well-known URLs: change-password redirect and security.txt

* feat: base security.txt Expires on the container build time

The BUILD_TIME build arg is baked into the prod image as an env var
and set by CI at image build. security.txt expires one year after the
build, so the file goes stale when deployments stop. Dev and test fall
back to one year from the request time.

* refactor: extract shared controller functionality

- AbstractController: authenticatedUser property hook and
  assertSameSeason() (moved from QuestionBankController)
- EmailVerifier::sendDefaultConfirmation() replaces the duplicated
  confirmation email block in RegistrationController and
  SettingsController
- QuizController: deduplicate candidate-data preparation into
  buildCandidateData()
- Drop manual 422 status handling in QuizQuestionController,
  QuestionBankController and SettingsController: render() already
  returns 422 for submitted invalid forms passed as parameters

* fix: address PR review comments

- Catch UniqueConstraintViolationException when changing email to
  handle the race between the uniqueness check and the flush
- Avoid else-only block in UserRepository::deleteUser
- Align duplicate-email translation with the validators domain

* fix: apply code-review findings for PR #191

- Invalidate outstanding ResetPasswordRequests after password or email change to close the account-takeover window (tokens otherwise remain valid)
- Exclude the current user from email uniqueness check so submitting your own address no longer returns an error
- Surface transport failures from sendDefaultConfirmation via a warning flash instead of silently showing success
- Move Dockerfile ARG BUILD_TIME/ENV to after all build steps so changing the build timestamp no longer busts the composer/asset cache
- Throw in prod when BUILD_TIME is missing (WellKnownController) so the security.txt Expires goes stale as intended when deployments stop; fall back to 'now' only in dev/test
2026-07-08 14:38:32 +02:00

312 lines
12 KiB
YAML

name: CI
on:
push:
branches:
- main
tags:
- '*'
pull_request: ~
workflow_dispatch: ~
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build:
name: Build Dev Image
runs-on: ubuntu-latest
timeout-minutes: 15
if: "!startsWith(github.ref, 'refs/tags/')"
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Lint Dockerfile
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Build Docker images
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
with:
pull: true
files: |
compose.yaml
compose.override.yaml
set: |
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
*.cache-from=type=gha,scope=refs/heads/main-devbuild
*.cache-to=type=gha,scope=${{github.ref}}-devbuild,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
quality:
name: Code Quality
runs-on: ubuntu-latest
timeout-minutes: 20
needs: build
if: "!startsWith(github.ref, 'refs/tags/')"
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Load Docker images
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
with:
load: true
files: |
compose.yaml
compose.override.yaml
set: |
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
- 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: Assert all checks passed
if: always()
run: |
failed=0
check() {
local name="$1" outcome="$2"
if [[ "$outcome" == "failure" ]]; then
echo "::error::$name failed"
failed=1
fi
}
check "Twig Lint" "${{ steps.twig_lint.outcome }}"
check "Coding Style" "${{ steps.cs.outcome }}"
check "Twig Coding Style" "${{ steps.twig_cs.outcome }}"
check "PHPStan" "${{ steps.phpstan.outcome }}"
check "Rector" "${{ steps.rector.outcome }}"
exit $failed
tests:
name: Tests
runs-on: ubuntu-latest
timeout-minutes: 20
needs: build
if: "!startsWith(github.ref, 'refs/tags/')"
permissions:
checks: write
pull-requests: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Load Docker images
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
with:
load: true
files: |
compose.yaml
compose.override.yaml
set: |
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
- name: Start services
run: docker compose up php database --wait --no-build
- name: Build SCSS
run: docker compose exec -T php bin/console sass:build
- name: Create test database
run: docker compose exec -T php bin/console -e test doctrine:database:create
- name: Run migrations
run: docker compose exec -T php bin/console -e test doctrine:migrations:migrate --no-interaction
- name: Load fixtures
run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test
- name: Run PHPUnit
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
- name: Publish PHPUnit test results
if: always()
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
with:
report_paths: var/phpunit/junit.xml
check_name: PHPUnit
- name: Doctrine Schema Validator
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
verify-prior-run:
name: Verify Prior CI Run
runs-on: ubuntu-latest
timeout-minutes: 20
if: startsWith(github.ref, 'refs/tags/')
permissions:
actions: write
steps:
- name: Wait for and verify successful CI run on this commit
env:
GH_TOKEN: ${{ github.token }}
run: |
max_attempts=30
attempt=0
triggered=false
while [[ $attempt -lt $max_attempts ]]; do
attempt=$((attempt + 1))
success_count=$(gh api \
"repos/${{ github.repository }}/actions/workflows/ci.yml/runs?head_sha=${{ github.sha }}&status=success&per_page=5" \
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }})] | length")
if [[ "$success_count" -gt 0 ]]; then
echo "Found $success_count prior successful CI run(s) for ${{ github.sha }}."
exit 0
fi
in_progress_count=$(gh api \
"repos/${{ github.repository }}/actions/workflows/ci.yml/runs?head_sha=${{ github.sha }}&per_page=10" \
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }}) | select(.status == \"in_progress\" or .status == \"queued\" or .status == \"waiting\" or .status == \"requested\" or .status == \"pending\")] | length")
if [[ "$in_progress_count" -gt 0 ]]; then
echo "CI in progress (attempt $attempt/$max_attempts), waiting 30s..."
sleep 30
continue
fi
if [[ "$triggered" == "false" ]]; then
echo "No prior CI run found for ${{ github.sha }}. Triggering CI on ${{ github.ref }}..."
gh workflow run ci.yml --repo "${{ github.repository }}" --ref "${{ github.ref }}"
triggered=true
echo "Triggered. Waiting 20s for run to register..."
sleep 20
continue
fi
failed_conclusion=$(gh api \
"repos/${{ github.repository }}/actions/workflows/ci.yml/runs?head_sha=${{ github.sha }}&per_page=10" \
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }}) | select(.status == \"completed\") | select(.conclusion != \"success\")] | first | .conclusion // empty" \
--raw-output)
if [[ -n "$failed_conclusion" ]]; then
echo "::error::Triggered CI run on main failed with conclusion: $failed_conclusion. Fix the issue before re-tagging."
exit 1
fi
echo "Waiting for triggered run to register (attempt $attempt/$max_attempts)..."
sleep 20
done
echo "::error::Timed out waiting for CI run to complete for ${{ github.sha }}."
exit 1
build-deploy:
name: Build and Deploy
permissions:
contents: read
packages: write
environment:
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }}
url: ${{ vars.URL }}
needs: [quality, tests, verify-prior-run]
runs-on: ubuntu-latest
timeout-minutes: 15
if: >-
always() && !cancelled() && !failure() &&
((github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/'))
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
run: |
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
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
- name: Build and Push Docker images
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
with:
pull: true
push: true
files: |
compose.yaml
compose.build.yaml
set: |
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
*.cache-from=type=gha,scope=refs/heads/main-devbuild
*.cache-from=type=gha,scope=${{github.ref}}
*.cache-from=type=gha,scope=refs/heads/main
*.cache-to=type=gha,scope=${{github.ref}},mode=max
*.args.BUILD_TIME=${{ steps.meta.outputs.build_time }}
*.tags=${{ steps.meta.outputs.full_name }}
- name: Create Sentry release
uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
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}}
IMAGE_TAG: ${{steps.meta.outputs.tag}}
SENTRY_RELEASE: ${{steps.meta.outputs.sentry_version}}
run: |
curl -v -X POST "${PORTAINER_WEBHOOK}?IMAGE_TAG=${IMAGE_TAG}&SENTRY_RELEASE=${SENTRY_RELEASE}" --fail-with-body