diff --git a/.github/scripts/renovate-changelog.sh b/.github/scripts/renovate-changelog.sh new file mode 100755 index 0000000..5f4ab6d --- /dev/null +++ b/.github/scripts/renovate-changelog.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Called by Renovate postUpgradeTasks. Appends a single bullet under the +# `## [Unreleased]` section of CHANGELOG.md so the auto-release workflow has +# content to promote into a tagged release after the Renovate PR merges to main. +set -euo pipefail + +BRANCH="${1:-renovate/unknown}" +TITLE="${2:-Update dependencies}" + +# Skip if CHANGELOG.md was already touched this run (grouped PRs fire the hook +# more than once; we only want one bullet per PR). +if git diff --cached --name-only | grep -qx CHANGELOG.md; then + exit 0 +fi +if git diff --name-only | grep -qx CHANGELOG.md; then + exit 0 +fi + +ENTRY="- Renovate: ${TITLE} (\`${BRANCH}\`)" + +python3 - "$ENTRY" <<'PY' +import re, sys, pathlib +entry = sys.argv[1] +p = pathlib.Path("CHANGELOG.md") +text = p.read_text(encoding="utf-8") + +pattern = re.compile(r"(## \[Unreleased\]\n)(.*?)(\n## \[)", re.DOTALL) +m = pattern.search(text) +if not m: + # No [Unreleased] section yet — insert one after the intro paragraph + # (the first blank line after the H1 header). + head, _, rest = text.partition("\n\n") + text = f"{head}\n\n## [Unreleased]\n\n{entry}\n\n{rest}" +else: + body = m.group(2).rstrip("\n") + sep = "\n" if body else "" + text = pattern.sub( + lambda _m: f"{_m.group(1)}{body}{sep}{entry}\n{_m.group(3)}", + text, count=1, + ) + +p.write_text(text, encoding="utf-8") +PY diff --git a/.github/workflows/auto-release.yaml b/.github/workflows/auto-release.yaml new file mode 100644 index 0000000..59c472b --- /dev/null +++ b/.github/workflows/auto-release.yaml @@ -0,0 +1,117 @@ +# After a Renovate PR merges into `main`, promote the `[Unreleased]` section +# of CHANGELOG.md to the next patch version, commit, tag, push, and back-merge +# main into develop so the branches stay in sync. +# +# Required secrets: +# RELEASE_TOKEN PAT or GitHub App token with contents:write + workflows. +# Needed for the tag push to trigger github_build_release.yml +# (the default GITHUB_TOKEN cannot trigger other workflows) +# and for the back-merge push to `develop`. +# +# Gating: the existing pr.yaml is required by branch protection, so by the time +# a PR is merged into main, CI has already passed. We do not re-wait for checks +# on the merge commit (it has no associated check runs after a squash-merge). + +name: Auto Release on Renovate Merge + +on: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: read + +jobs: + release: + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.user.login == 'renovate[bot]' + runs-on: ubuntu-latest + steps: + - name: Checkout main with full history + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + + - name: Compute next patch version + id: version + run: | + latest=$(git tag -l '[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -1) + : "${latest:=0.0.0}" + IFS=. read -r maj min pat <<<"$latest" + next="${maj}.${min}.$((pat + 1))" + { + echo "current=$latest" + echo "next=$next" + echo "date=$(date -u +%Y-%m-%d)" + } >>"$GITHUB_OUTPUT" + echo "Will release: $latest -> $next" + + - name: Bail if [Unreleased] is empty + id: gate + run: | + body=$(awk '/^## \[Unreleased\]/{f=1;next} /^## \[/{f=0} f' CHANGELOG.md \ + | grep -v '^[[:space:]]*$' || true) + if [ -z "$body" ]; then + echo "Nothing under [Unreleased]; skipping release." + echo "skip=true" >>"$GITHUB_OUTPUT" + else + echo "skip=false" >>"$GITHUB_OUTPUT" + fi + + - name: Promote [Unreleased] -> ${{ steps.version.outputs.next }} + if: steps.gate.outputs.skip != 'true' + env: + VER: ${{ steps.version.outputs.next }} + DATE: ${{ steps.version.outputs.date }} + run: | + python3 - <<'PY' + import os, re, pathlib + ver = os.environ["VER"] + date = os.environ["DATE"] + p = pathlib.Path("CHANGELOG.md") + text = p.read_text(encoding="utf-8") + text = re.sub( + r"## \[Unreleased\]\n", + f"## [Unreleased]\n\n## [{ver}] - {date}\n", + text, count=1, + ) + p.write_text(text, encoding="utf-8") + PY + + - name: Commit on main, tag, push + if: steps.gate.outputs.skip != 'true' + env: + VER: ${{ steps.version.outputs.next }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git commit -m "release: ${VER}" + git tag -a "${VER}" -m "Release ${VER}" + git push origin main + git push origin "${VER}" + + - name: Back-merge main into develop + if: steps.gate.outputs.skip != 'true' + env: + VER: ${{ steps.version.outputs.next }} + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + git fetch origin develop + git checkout -B develop origin/develop + if git merge --no-ff main -m "chore: back-merge hotfix ${VER} into develop"; then + git push origin develop + echo "Back-merged ${VER} to develop." + else + git merge --abort || true + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base develop \ + --head main \ + --title "Back-merge hotfix ${VER} into develop" \ + --body "Automated back-merge from \`main\` conflicted with \`develop\`. Resolve and merge to keep branches in sync after release ${VER}." + fi diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 5625c95..b7b2436 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -55,13 +55,15 @@ jobs: docker compose exec -e XDEBUG_MODE=coverage phpfpm bin/console --env=test doctrine:migrations:migrate --no-interaction --quiet docker compose exec -e XDEBUG_MODE=coverage phpfpm vendor/bin/phpunit --coverage-clover=coverage/unit.xml - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage/unit.xml - fail_ci_if_error: true - flags: unittests + # Codecov upload disabled on the fork — no CODECOV_TOKEN secret. + # Re-enable when promoting to upstream. + # - name: Upload coverage to Codecov + # uses: codecov/codecov-action@v5 + # with: + # token: ${{ secrets.CODECOV_TOKEN }} + # files: ./coverage/unit.xml + # fail_ci_if_error: true + # flags: unittests fixtures: runs-on: ubuntu-latest diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml new file mode 100644 index 0000000..69a1f1f --- /dev/null +++ b/.github/workflows/renovate.yaml @@ -0,0 +1,62 @@ +# Self-hosted Renovate runner. Runs on a schedule and on demand. +# Self-hosted is required because postUpgradeTasks (which writes CHANGELOG.md +# inline with the dep bump) is disabled on the Mend Renovate SaaS app. +# +# Required secrets: +# RENOVATE_TOKEN PAT or GitHub App token with repo write + workflows. +# Pushes by the default GITHUB_TOKEN don't re-trigger Actions, +# so a PAT/App token is needed to make CI re-run on +# Renovate's branch updates. + +name: Renovate + +on: + schedule: + - cron: "0 11 * * 1-5" # weekdays 11:00 UTC = 13:00 Copenhagen (CEST) / 12:00 (CET) + workflow_dispatch: + inputs: + logLevel: + description: Renovate log level + type: choice + default: info + options: [debug, info, warn, error] + dryRun: + description: Dry-run (no PRs) + type: choice + default: "null" + options: ["null", "full"] + +permissions: + contents: read + +concurrency: + group: renovate + cancel-in-progress: false + +jobs: + renovate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + # PHP/Composer are needed so Renovate can resolve composer.lock + # updates inside its container. + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + tools: composer:v2 + coverage: none + + - name: Run Renovate + uses: renovatebot/github-action@v43 + with: + configurationFile: renovate.json + token: ${{ secrets.RENOVATE_TOKEN }} + renovate-version: latest + env: + LOG_LEVEL: ${{ inputs.logLevel || 'info' }} + RENOVATE_DRY_RUN: ${{ inputs.dryRun || 'null' }} + RENOVATE_REPOSITORIES: ${{ github.repository }} + # Allow the changelog script to run from postUpgradeTasks. + RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS: '["^bash \\.github/scripts/renovate-changelog\\.sh"]' + RENOVATE_ALLOW_POST_UPGRADE_COMMAND_TEMPLATING: "true" diff --git a/.gitignore b/.gitignore index 5fb8656..39d1d8e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ phpstan.neon .twig-cs-fixer.cache .playwright-mcp + +# Working docs not meant to ship +RENOVATE_PLAN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d3c760..83a4725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#81](https://github.com/itk-dev/devops_itksites/pull/81) 5564: Asset Mapper migration - Add Symfony Asset Mapper bundle and importmap +- Add Renovate auto-patch + auto-release pipeline (Phase 1 fork validation) ## [1.11.0] - 2026-05-19 diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..afa00a6 --- /dev/null +++ b/renovate.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":dependencyDashboard", + ":semanticCommitsDisabled", + ":maintainLockFilesWeekly", + "schedule:weekdays" + ], + "baseBranches": ["main", "develop"], + "timezone": "Europe/Copenhagen", + "labels": ["dependencies", "renovate"], + "prConcurrentLimit": 5, + "prHourlyLimit": 2, + "rangeStrategy": "bump", + "rebaseWhen": "behind-base-branch", + "platformAutomerge": true, + "ignoreUnstable": true, + "respectLatest": true, + + "vulnerabilityAlerts": { + "enabled": true, + "labels": ["security", "dependencies"], + "automerge": true, + "automergeType": "pr", + "minimumReleaseAge": "0 days", + "prPriority": 10, + "commitMessagePrefix": "security:", + "matchBaseBranches": ["main"] + }, + "osvVulnerabilityAlerts": true, + + "lockFileMaintenance": { + "enabled": true, + "schedule": ["before 6am on Monday"], + "automerge": true, + "automergeType": "pr", + "commitMessageAction": "Refresh", + "matchBaseBranches": ["main"] + }, + + "composer": { "enabled": true, "ignorePlatformReqs": [] }, + "npm": { "enabled": true }, + "github-actions": { "enabled": true, "pinDigests": true }, + "docker-compose": { "enabled": true, "pinDigests": true }, + "dockerfile": { "enabled": true, "pinDigests": true }, + + "packageRules": [ + { + "description": "Hotfix: auto-merge patches/digests/lockfile on main after 7-day soak", + "matchBaseBranches": ["main"], + "matchUpdateTypes": [ + "patch", + "pin", + "digest", + "lockFileMaintenance" + ], + "automerge": true, + "automergeType": "pr", + "automergeStrategy": "squash", + "minimumReleaseAge": "7 days" + }, + { + "description": "Normal flow: minor/major on develop, dashboard approval, never auto-merge", + "matchBaseBranches": ["develop"], + "matchUpdateTypes": ["minor", "major"], + "dependencyDashboardApproval": true, + "automerge": false + }, + { + "description": "Suppress minor/major against main (those belong on develop)", + "matchBaseBranches": ["main"], + "matchUpdateTypes": ["minor", "major"], + "enabled": false + }, + { + "description": "Suppress patches against develop (they reach develop via back-merge after release)", + "matchBaseBranches": ["develop"], + "matchUpdateTypes": [ + "patch", + "pin", + "digest", + "lockFileMaintenance" + ], + "enabled": false + }, + { + "description": "Group Symfony patches into one PR on main", + "matchBaseBranches": ["main"], + "matchPackagePatterns": ["^symfony/"], + "matchUpdateTypes": ["patch"], + "groupName": "symfony (patch)" + }, + { + "description": "Group Doctrine patches on main", + "matchBaseBranches": ["main"], + "matchPackagePatterns": ["^doctrine/"], + "matchUpdateTypes": ["patch"], + "groupName": "doctrine (patch)" + }, + { + "description": "Group api-platform patches on main", + "matchBaseBranches": ["main"], + "matchPackagePatterns": ["^api-platform/"], + "matchUpdateTypes": ["patch"], + "groupName": "api-platform (patch)" + }, + { + "description": "Never auto-bump the PHP platform requirement", + "matchBaseBranches": ["develop"], + "matchPackageNames": ["php"], + "rangeStrategy": "in-range-only", + "automerge": false, + "dependencyDashboardApproval": true + }, + { + "description": "GitHub Actions: pin digests, auto-merge digest/patch on main", + "matchBaseBranches": ["main"], + "matchManagers": ["github-actions"], + "matchUpdateTypes": ["digest", "patch"], + "automerge": true + } + ], + + "postUpdateOptions": ["composerUpdateAllDependencies"], + + "postUpgradeTasks": { + "commands": [ + "bash .github/scripts/renovate-changelog.sh \"{{{branchName}}}\" \"{{{prTitle}}}\"" + ], + "fileFilters": ["CHANGELOG.md"], + "executionMode": "branch" + } +}