Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
116 changes: 116 additions & 0 deletions .github/workflows/release-stamp.yml
Original file line number Diff line number Diff line change
@@ -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}"
147 changes: 147 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml → .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: CI
name: Tests

on:
push:
Expand Down
13 changes: 6 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,9 @@
"psr-4": {
"Parisek\\Twig\\Tests\\": "tests"
}
},
"scripts": {
"test": "vendor/bin/phpunit",
"phpstan": "vendor/bin/phpstan analyse"
}
}
Loading