From 2d21c642b37fdce32659d17e13dd97d3fe071872 Mon Sep 17 00:00:00 2001 From: Renn F Date: Fri, 16 Oct 2026 05:09:01 +0200 Subject: [PATCH] ci(release): gate the tag on the pull request CI instead of re-running checks Closes #91 --- .github/workflows/release.yml | 167 +++++++++++++++++----------------- AGENTS.md | 2 +- 2 files changed, 85 insertions(+), 84 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c84209..2fd139c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,104 +1,105 @@ name: Release Gate +# A pushed v* tag is the release for guard-core-php (there is no publish step). +# This workflow only confirms that the tagged commit is good. Nothing is +# linted or tested here: the tagged commit must already have passed the push +# workflows listed in REQUIRED_WORKFLOWS below (ci.yml, live-smoke.yml, ecosystem-gate.yml). A release +# can therefore never fail on a check its pull request did not run. +# +# Composer audit is not repeated here: it is a job of ci.yml, which runs it +# on the same commit against the same composer.json, so a vulnerable +# dependency set already fails ci.yml above. Packagist serves the tag as +# soon as it is pushed, so no check has to wait for the tag to exist. +# +# Re-check: run this workflow by hand (Actions > Release Gate > Run workflow) +# with the tag, for example v4.4.0. It checks out that tag, so a fixed +# workflow on the default branch can re-check an existing tag. on: push: tags: - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag to check, for example v4.4.0' + required: true permissions: {} +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +env: + REQUIRED_WORKFLOWS: >- + .github/workflows/ci.yml + .github/workflows/live-smoke.yml + .github/workflows/ecosystem-gate.yml + jobs: - test: - name: Test at tag (php ${{ matrix.php }}) + verify: + name: Tagged commit passed CI runs-on: ubuntu-latest permissions: contents: read - strategy: - fail-fast: false - matrix: - php: ['8.2', '8.3', '8.4'] - services: - redis: - image: redis:7-alpine - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 5s - --health-timeout 3s - --health-retries 10 + actions: read + outputs: + tag: ${{ steps.tag.outputs.tag }} steps: - - name: Checkout tag - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Set up PHP - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 - with: - php-version: ${{ matrix.php }} - extensions: mbstring - coverage: none - - - name: Install dependencies - run: composer install --no-interaction --no-progress - - - name: php -l sweep - run: for f in $(find src bin -name '*.php'); do php -l "$f" > /dev/null || exit 1; done && echo LINT_OK - - - name: test_state - run: php bin/test_state.php - - - name: test_ratelimit - env: - REDIS_HOST: 127.0.0.1 - run: php bin/test_ratelimit.php - - - name: test_pipeline + - name: Resolve the tag + id: tag env: - REDIS_HOST: 127.0.0.1 - run: php bin/test_pipeline.php - - - name: test_m3b - env: - REDIS_HOST: 127.0.0.1 - run: php bin/test_m3b.php - - - name: test_m3c - run: php bin/test_m3c.php - - - name: test_m4 - env: - REDIS_HOST: 127.0.0.1 - run: php bin/test_m4.php - - - name: conformance - run: php bin/conformance.php + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + REF_NAME: ${{ github.ref_name }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + tag="$INPUT_TAG" + else + tag="$REF_NAME" + fi + if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::tag '$tag' is not of the form v1.2.3" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" - composer-audit: - name: Composer audit at tag - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout tag + - name: Checkout the tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: refs/tags/${{ steps.tag.outputs.tag }} persist-credentials: false - - name: Set up PHP - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 - with: - php-version: '8.3' - coverage: none - - - name: Install dependencies - run: composer install --no-interaction --no-progress - - - name: Composer audit + # The newest push run of each required workflow on the tagged commit + # must have succeeded. A tag pushed right after a merge waits for them + # (up to an hour) instead of failing. + - name: Require green CI on the tagged commit + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} run: | - if [ -n "$(composer show --locked --direct 2>/dev/null)" ]; then - composer audit --locked - else - echo "No locked dependencies to audit (platform-only package); nothing to fail on." - fi + sha=$(git rev-parse HEAD) + for _ in $(seq 1 60); do + runs=$(gh api "repos/$REPO/actions/runs?head_sha=$sha&event=push&per_page=100" \ + --jq '[.workflow_runs | group_by(.path)[] | max_by(.run_number) | {path, status, conclusion}]') + waiting=0 + for wf in $REQUIRED_WORKFLOWS; do + run=$(jq -c --arg p "$wf" '.[] | select(.path == $p)' <<< "$runs") + if [ -z "$run" ]; then + echo "$wf: no push run on $sha yet"; waiting=1; continue + fi + status=$(jq -r .status <<< "$run"); conclusion=$(jq -r .conclusion <<< "$run") + if [ "$status" != "completed" ]; then + echo "$wf: $status"; waiting=1 + elif [ "$conclusion" != "success" ]; then + echo "::error::$wf concluded $conclusion on $sha; fix the default branch and release a new tag" + exit 1 + else + echo "ok: $wf success on $sha" + fi + done + [ "$waiting" = "0" ] && exit 0 + sleep 60 + done + echo "::error::required workflows did not finish on $sha within an hour" + exit 1 diff --git a/AGENTS.md b/AGENTS.md index c29ac74..6771c72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ The `Makefile` wraps `install`, `test`, `lint`, `bump-version` and `clean`; ever | `composer install` then `docker compose -f examples/simple_app/docker-compose.yml up --build -d --wait` | Live smoke stack for the simple example (advanced: `examples/advanced_app/docker-compose.yml`) | | `pip install mkdocs-material && mkdocs build --strict` | Build the documentation site (CI deploys it on master) | -CI (`.github/workflows/ci.yml`) runs on every push and PR with a PHP matrix of 8.2/8.3/8.4 and a `redis:7-alpine` service: the `php -l` sweep, every `bin/` runner listed in the workflow (`test_*.php` and the `conformance*.php` replays, Redis-backed ones with `REDIS_HOST=127.0.0.1`), a pcov coverage job with a 100% line gate (`.github/coverage-runner.php`, waivers in `.github/coverage-unreachable.php`), plus a `composer audit` job (no-ops cleanly on the zero-dependency lock). Additional workflows: live smoke (both example apps, dockerized), ecosystem gate (the four adapter repos run their suites against this checkout), docs site (strict build + gh-deploy on master), issue link, labeler, stale, summary, greetings, sync labels, release gate at `v*` tags, and scheduled weekly audit + lint. +CI (`.github/workflows/ci.yml`) runs on every push and PR with a PHP matrix of 8.2/8.3/8.4 and a `redis:7-alpine` service: the `php -l` sweep, every `bin/` runner listed in the workflow (`test_*.php` and the `conformance*.php` replays, Redis-backed ones with `REDIS_HOST=127.0.0.1`), a pcov coverage job with a 100% line gate (`.github/coverage-runner.php`, waivers in `.github/coverage-unreachable.php`), plus a `composer audit` job (no-ops cleanly on the zero-dependency lock). Additional workflows: live smoke (both example apps, dockerized), ecosystem gate (the four adapter repos run their suites against this checkout), docs site (strict build + gh-deploy on master), issue link, labeler, stale, summary, greetings, sync labels, release gate at `v*` tags (requires the push runs of `ci.yml`, `live-smoke.yml` and `ecosystem-gate.yml` on the tagged commit to have succeeded; nothing is re-run), and scheduled weekly audit + lint. ## Testing Guidelines