diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5865804 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Distribution: keep `composer require` lean — only the namespaced PHP +# (src/ + the root AttributeExtension.php), composer.json, LICENSE.txt and +# README.md ship; everything below is dev-only. +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.vscode export-ignore +/.upstream export-ignore +/composer.lock export-ignore +/phpunit.xml.dist export-ignore +/phpstan.neon export-ignore +/tests export-ignore +CHANGELOG.md export-ignore +AGENTS.md export-ignore +CLAUDE.md export-ignore diff --git a/.github/workflows/release-stamp.yml b/.github/workflows/release-stamp.yml new file mode 100644 index 0000000..c5f9c53 --- /dev/null +++ b/.github/workflows/release-stamp.yml @@ -0,0 +1,116 @@ +name: Stamp Release + +# Manual release entrypoint. Stamps `[Unreleased]` in CHANGELOG to the +# given version + date, runs the full test/PHPStan suite as a guard, +# commits, tags, and pushes. The tag push then triggers `release.yml` +# which creates the GitHub Release with notes derived from CHANGELOG. + +on: + workflow_dispatch: + inputs: + version: + description: 'New version (semver, e.g. 1.5.0 — without v prefix)' + required: true + type: string + +permissions: + contents: write + actions: write # dispatch release.yml in the final step (gh workflow run) + +jobs: + stamp: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate inputs + env: + VERSION: ${{ inputs.version }} + run: | + if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::version must be semver X.Y.Z (without 'v' prefix); got: $VERSION" + exit 1 + fi + if git rev-parse "v${VERSION}" >/dev/null 2>&1; then + echo "::error::tag v${VERSION} already exists" + exit 1 + fi + + - name: Validate CHANGELOG has [Unreleased] with content + run: | + if ! grep -q '^## \[Unreleased\]' CHANGELOG.md; then + echo "::error::CHANGELOG.md has no [Unreleased] section" + exit 1 + fi + CONTENT=$(awk ' + /^## \[Unreleased\]/{flag=1; next} + /^## \[/{flag=0} + flag + ' CHANGELOG.md | grep -v '^[[:space:]]*$' | head -1) + if [ -z "$CONTENT" ]; then + echo "::error::[Unreleased] section is empty — nothing to release" + exit 1 + fi + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - run: composer install --no-interaction --prefer-dist + + - name: Run tests + run: composer test + + - name: Run PHPStan + run: composer phpstan + + - name: Stamp CHANGELOG + env: + VERSION: ${{ inputs.version }} + run: | + # Replace `## [Unreleased]` with a fresh empty `[Unreleased]` + # block followed by the new stamped version block, so the next + # cycle has somewhere to accumulate entries. UTC date avoids + # off-by-one against the runner's local timezone. + python3 <<'PY' + import os, re, pathlib, datetime + version = os.environ['VERSION'] + date = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d') + path = pathlib.Path('CHANGELOG.md') + text = path.read_text() + replacement = f"## [Unreleased]\n\n## [{version}] - {date}" + new = re.sub(r'^## \[Unreleased\]', replacement, text, count=1, flags=re.M) + if new == text: + raise SystemExit("failed to stamp CHANGELOG (no [Unreleased] heading found)") + path.write_text(new) + PY + + - name: Configure git identity + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + - name: Commit, tag, push + env: + VERSION: ${{ inputs.version }} + run: | + git add CHANGELOG.md + git commit -m "Release ${VERSION}" + git tag "v${VERSION}" + git push origin HEAD:main + git push origin "v${VERSION}" + + - name: Trigger release workflow + # Tag pushes made by GITHUB_TOKEN don't fire downstream `on: push: tags` + # workflows by GitHub design. Dispatch release.yml explicitly instead — + # this requires the job's `actions: write` permission (declared above), + # or the API returns 403 "Resource not accessible by integration". + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + gh workflow run release.yml --field tag="v${VERSION}" + echo "Dispatched release.yml for v${VERSION}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ba36956 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,147 @@ +name: Release + +# Three trigger modes: +# 1. `push` of a `vX.Y.Z` tag (manual `git push origin vX.Y.Z` from a maintainer's +# terminal) — runs end-to-end automatically. +# 2. `workflow_dispatch` (Run workflow button in Actions UI) with a `tag` input — +# re-generates the GitHub Release for any existing tag. Useful when an earlier +# run failed, when release notes need refreshing, or when manually back-filling +# an older tag. +# 3. Cross-workflow dispatch from `release-stamp.yml` (which can't rely on its own +# tag push to fire this workflow — pushes made by the default GITHUB_TOKEN +# don't trigger downstream workflows by design). The stamp workflow calls +# `gh workflow run release.yml --field tag=vX.Y.Z` after pushing the tag. + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + tag: + description: 'Existing tag (e.g. v1.4.1) to (re)generate the GitHub Release for' + required: true + type: string + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve tag and previous tag + id: meta + env: + INPUT_TAG: ${{ inputs.tag }} + EVENT: ${{ github.event_name }} + run: | + if [ "$EVENT" = "workflow_dispatch" ]; then + TAG="$INPUT_TAG" + if ! printf '%s' "$TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::tag must match vX.Y.Z; got: $TAG" + exit 1 + fi + if ! git rev-parse "$TAG" >/dev/null 2>&1; then + echo "::error::tag $TAG does not exist in this repository" + exit 1 + fi + else + TAG="${GITHUB_REF#refs/tags/}" + fi + VERSION="${TAG#v}" + PREV=$(git tag --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true) + { + echo "tag=${TAG}" + echo "version=${VERSION}" + echo "prev_tag=${PREV}" + } >> "$GITHUB_OUTPUT" + + - name: Build release notes + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + VERSION: ${{ steps.meta.outputs.version }} + PREV: ${{ steps.meta.outputs.prev_tag }} + run: | + # Extract the CHANGELOG section for this version (between + # "## [VERSION]" and the next "## [" heading). Use the tag's + # CHANGELOG, not main's — manual re-runs against an older tag + # should pick up that tag's content, not whatever is on main now. + CHANGELOG=$(git show "${TAG}:CHANGELOG.md" 2>/dev/null \ + | awk -v v="$VERSION" ' + /^## \[/ { + if ($0 ~ "^## \\[" v "\\]") { found=1; next } + else if (found) { exit } + } + found { print } + ' | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}') + + # PRs merged between previous tag and this tag — parse `(#N)` + # suffixes from squash-merge commit subjects. + PR_LIST="" + if [ -n "$PREV" ]; then + PR_NUMBERS=$(git log "${PREV}..${TAG}" --pretty=format:'%s' \ + | grep -oE '\(#[0-9]+\)' | grep -oE '[0-9]+' | sort -un) + for n in $PR_NUMBERS; do + TITLE=$(gh pr view "$n" --repo "$REPO" --json title --jq .title 2>/dev/null || true) + [ -n "$TITLE" ] && PR_LIST="${PR_LIST}- #${n} — ${TITLE}"$'\n' + done + fi + + { + if [ -n "$CHANGELOG" ]; then + echo "## What's Changed" + echo + echo "$CHANGELOG" + echo + fi + if [ -n "$PR_LIST" ]; then + echo "## Pull Requests" + echo + printf '%s' "$PR_LIST" + echo + fi + if [ -n "$PREV" ]; then + echo "**Full Changelog**: https://github.com/${REPO}/compare/${PREV}...${TAG}" + fi + } > release-notes.md + + echo "--- release notes preview ---" + cat release-notes.md + + - name: Create or update GitHub release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.meta.outputs.tag }} + run: | + # `--latest=false` on create; we'll re-mark below only when this + # tag is the highest semver, so a back-dated tag push doesn't + # steal the Latest badge from a newer release. On manual re-runs + # against an existing release, just refresh the notes. + if gh release view "$TAG" >/dev/null 2>&1; then + gh release edit "$TAG" --notes-file release-notes.md + echo "Updated existing release for $TAG" + else + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file release-notes.md \ + --latest=false + echo "Created release for $TAG" + fi + + - name: Mark as latest if highest semver + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.meta.outputs.tag }} + run: | + HIGHEST=$(git tag --sort=-v:refname \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + if [ "$HIGHEST" = "$TAG" ]; then + gh release edit "$TAG" --latest + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/tests.yml similarity index 98% rename from .github/workflows/ci.yml rename to .github/workflows/tests.yml index eaf1484..b371149 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/tests.yml @@ -1,4 +1,4 @@ -name: CI +name: Tests on: push: diff --git a/AGENTS.md b/AGENTS.md index 7e57281..269f819 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,16 +72,15 @@ Level 5, not 6. Level 6 surfaces 17 `missingType.iterableValue` / `missingType.p - **CHANGELOG.md**: every behavior-affecting PR adds an entry under `## [Unreleased]` with [Keep a Changelog](https://keepachangelog.com/) categories. - **Squash-merge PRs** into `master` so the merge commit subject ends with `(#N)`. The existing tag history (`v1.0.0`–`v1.6.0`) is built on this convention. -## Release process +## Release process — DO NOT bypass -Currently manual: +Automated by two workflows (mirrors `parisek/timber-kit`). **Never stamp + tag manually** unless the workflow is broken: -1. Stamp the `[Unreleased]` heading in `CHANGELOG.md` to `[X.Y.Z] - YYYY-MM-DD`. -2. `git tag -a vX.Y.Z -m "..."` + `git push origin vX.Y.Z`. -3. Packagist auto-imports (~60s; webhook wired). -4. Create the GitHub Release (`gh release create vX.Y.Z --notes-file <(awk …)`) — use `--latest=false` for back-dated patches so they don't steal the Latest badge. +1. Trigger **Stamp Release** (Actions tab → `Stamp Release` → Run workflow → enter `X.Y.Z`, no `v` prefix). +2. It validates the version, requires a non-empty `[Unreleased]`, runs `composer test` + `composer phpstan` as guards, stamps `[Unreleased]` → `[X.Y.Z] - DATE` (UTC, leaving a fresh empty `[Unreleased]`), commits `Release X.Y.Z`, tags `vX.Y.Z`, pushes, then dispatches `release.yml`. +3. `release.yml` extracts that tag's CHANGELOG section + the merged-PR list and creates the GitHub Release (`--latest` only when it's the highest semver, so back-dated patches don't steal the badge). Packagist auto-imports the tag (~60s; webhook wired). -No release-automation workflow yet. If one lands, mirror `parisek/timber-kit`'s `release-stamp.yml` + `release.yml` shape. +`release.yml` also runs on a manual `vX.Y.Z` tag push and via `workflow_dispatch` (re-generate notes for an existing tag). ## Style diff --git a/composer.json b/composer.json index 8af9491..94300c9 100644 --- a/composer.json +++ b/composer.json @@ -23,5 +23,9 @@ "psr-4": { "Parisek\\Twig\\Tests\\": "tests" } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "phpstan": "vendor/bin/phpstan analyse" } }