mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-08 16:40:15 +02:00
1d3e99d2b2
* 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
312 lines
12 KiB
YAML
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
|