diff --git a/.build-rsync-exclude b/.build-rsync-exclude index 7f0ff11..643873b 100644 --- a/.build-rsync-exclude +++ b/.build-rsync-exclude @@ -20,5 +20,6 @@ package.json package-lock.json Gruntfile.js README.md +scripts/ aryo-activity-log/ aryo-activity-log.*.zip diff --git a/.github/workflows/php-coding-standards.yml b/.github/workflows/php-coding-standards.yml index 3eae26c..d3389ef 100644 --- a/.github/workflows/php-coding-standards.yml +++ b/.github/workflows/php-coding-standards.yml @@ -4,6 +4,7 @@ on: push: pull_request: workflow_dispatch: + workflow_call: concurrency: cancel-in-progress: true diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 818496b..b99dd77 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -3,6 +3,7 @@ name: PHPUnit Tests on: push: pull_request: + workflow_call: concurrency: cancel-in-progress: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7ed76f6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,175 @@ +name: Release + +on: + workflow_dispatch: + inputs: + bump: + description: Version bump + required: true + type: choice + options: [patch, minor, major] + dry_run: + description: Dry run (no git push, no WP.org commit) + required: false + type: boolean + default: false + +concurrency: + group: wordpress-org-release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + phpcs: + if: >- + github.ref == 'refs/heads/master' + && ( github.actor == 'KingYes' || github.actor == 'bainternet' || github.actor == 'arielk' ) + && startsWith( github.repository, 'elementor/' ) + uses: ./.github/workflows/php-coding-standards.yml + + phpunit: + if: >- + github.ref == 'refs/heads/master' + && ( github.actor == 'KingYes' || github.actor == 'bainternet' || github.actor == 'arielk' ) + && startsWith( github.repository, 'elementor/' ) + uses: ./.github/workflows/phpunit.yml + + release: + needs: [phpcs, phpunit] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '7.4' + tools: composer:v2 + + - uses: actions/cache@v4 + with: + path: vendor + key: composer-${{ hashFiles('composer.json') }} + restore-keys: | + composer- + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress + + - name: Lint + run: composer lint + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install npm dependencies + run: npm install + + - name: Compute next version + run: | + VERSION=$(node scripts/next-version.js ${{ inputs.bump }}) + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "TAG=v$VERSION" >> "$GITHUB_ENV" + echo "Next version: $VERSION" + + - name: Validate readme changelog + run: node scripts/validate-readme-changelog.js "${{ env.VERSION }}" + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Bump version + run: npm version ${{ inputs.bump }} --no-git-tag-version + + - name: Sync version to plugin files + run: npm run version:sync + + - name: Build plugin directory + run: npm run package + + - name: Validate build + env: + PLUGIN_SLUG: aryo-activity-log + PLUGIN_VERSION: ${{ env.VERSION }} + run: bash scripts/validate-build-files.sh + + - name: Create zip + run: | + zip -r aryo-activity-log.${{ env.VERSION }}.zip ./aryo-activity-log/* + + - name: Commit version bump + if: ${{ inputs.dry_run != true }} + run: | + git add package.json package-lock.json aryo-activity-log.php readme.txt + git commit -m "Internal: Release ${{ env.VERSION }}" + + - name: Create tag + if: ${{ inputs.dry_run != true }} + run: git tag "${{ env.TAG }}" + + - name: Extract changelog from readme + id: changelog + run: | + node scripts/extract-changelog-section.js "${{ env.VERSION }}" > /tmp/changelog_section.txt + + { + echo "body<> "$GITHUB_OUTPUT" + + - name: Push commit and tag + if: ${{ inputs.dry_run != true }} + run: | + git push origin master + git push origin "${{ env.TAG }}" + + - name: Create GitHub Release + if: ${{ inputs.dry_run != true }} + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + tag_name: ${{ env.TAG }} + name: ${{ env.TAG }} + body: ${{ steps.changelog.outputs.body }} + files: aryo-activity-log.${{ env.VERSION }}.zip + + - name: Deploy to WordPress.org + if: ${{ inputs.dry_run != true }} + uses: 10up/action-wordpress-plugin-deploy@54bd289b8525fd23a5c365ec369185f2966529c2 # stable + env: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + SLUG: aryo-activity-log + VERSION: ${{ env.VERSION }} + BUILD_DIR: aryo-activity-log + + - name: Deploy to WordPress.org (dry run) + if: ${{ inputs.dry_run == true }} + uses: 10up/action-wordpress-plugin-deploy@54bd289b8525fd23a5c365ec369185f2966529c2 # stable + with: + dry-run: true + env: + SLUG: aryo-activity-log + VERSION: ${{ env.VERSION }} + BUILD_DIR: aryo-activity-log + + - name: Summary + run: | + if [[ "${{ inputs.dry_run }}" == "true" ]]; then + echo "::notice::Dry run complete for v${{ env.VERSION }}. No commits, tags, or WP.org deploys were made." + else + echo "::notice::Released v${{ env.VERSION }} to GitHub and WordPress.org." + fi diff --git a/package.json b/package.json index 54373c2..44aab4e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "scripts": { "clean": "rimraf $npm_package_name $npm_package_name.*.zip", "package": "npm run clean && rsync -av --exclude-from=.build-rsync-exclude . $npm_package_name", - "package:zip": "npm run package && zip -r $npm_package_name.$npm_package_version.zip ./$npm_package_name/*" + "package:zip": "npm run package && zip -r $npm_package_name.$npm_package_version.zip ./$npm_package_name/*", + "version:next": "node scripts/next-version.js", + "version:sync": "node scripts/sync-version.js", + "version:validate-changelog": "node scripts/validate-readme-changelog.js" }, "devDependencies": { "rimraf": "^6.0.0" diff --git a/scripts/extract-changelog-section.js b/scripts/extract-changelog-section.js new file mode 100644 index 0000000..37d4b40 --- /dev/null +++ b/scripts/extract-changelog-section.js @@ -0,0 +1,44 @@ +'use strict'; + +const fs = require( 'fs' ); +const path = require( 'path' ); + +const root = path.resolve( __dirname, '..' ); +const version = process.argv[ 2 ] || require( path.join( root, 'package.json' ) ).version; +const readme = fs.readFileSync( path.join( root, 'readme.txt' ), 'utf8' ); + +const changelogMarker = '== Changelog =='; +const changelogIndex = readme.indexOf( changelogMarker ); + +if ( changelogIndex === -1 ) { + console.error( 'readme.txt is missing "== Changelog ==" section.' ); + process.exit( 1 ); +} + +const changelogBody = readme.slice( changelogIndex + changelogMarker.length ); + +const sectionRegex = new RegExp( + `= ${ version.replace( /\./g, '\\.' ) }(\\s+-\\s+\\d{4}-\\d{2}-\\d{2})?\\s*=` +); + +const headerMatch = changelogBody.match( sectionRegex ); + +if ( ! headerMatch ) { + console.error( `Changelog section for version ${ version } not found.` ); + process.exit( 1 ); +} + +const headerIndex = changelogBody.indexOf( headerMatch[ 0 ] ); +const afterHeader = changelogBody.slice( headerIndex + headerMatch[ 0 ].length ); +const nextSectionMatch = afterHeader.match( /\n= \d+\.\d+\.\d+/ ); +const sectionContent = nextSectionMatch + ? afterHeader.slice( 0, nextSectionMatch.index ) + : afterHeader; + +const lines = sectionContent + .trim() + .split( '\n' ) + .map( ( l ) => l.trim() ) + .filter( Boolean ); + +console.log( lines.join( '\n' ) ); diff --git a/scripts/next-version.js b/scripts/next-version.js new file mode 100644 index 0000000..2f064ef --- /dev/null +++ b/scripts/next-version.js @@ -0,0 +1,30 @@ +'use strict'; + +const path = require( 'path' ); + +const bump = process.argv[ 2 ]; + +if ( ! [ 'patch', 'minor', 'major' ].includes( bump ) ) { + console.error( 'Usage: node next-version.js ' ); + process.exit( 1 ); +} + +const { version } = require( path.resolve( __dirname, '..', 'package.json' ) ); +const parts = version.split( '.' ).map( Number ); + +switch ( bump ) { + case 'major': + parts[ 0 ]++; + parts[ 1 ] = 0; + parts[ 2 ] = 0; + break; + case 'minor': + parts[ 1 ]++; + parts[ 2 ] = 0; + break; + case 'patch': + parts[ 2 ]++; + break; +} + +console.log( parts.join( '.' ) ); diff --git a/scripts/sync-version.js b/scripts/sync-version.js new file mode 100644 index 0000000..1fa9d02 --- /dev/null +++ b/scripts/sync-version.js @@ -0,0 +1,27 @@ +'use strict'; + +const fs = require( 'fs' ); +const path = require( 'path' ); + +const root = path.resolve( __dirname, '..' ); +const { version } = require( path.join( root, 'package.json' ) ); + +const pluginFile = path.join( root, 'aryo-activity-log.php' ); +let plugin = fs.readFileSync( pluginFile, 'utf8' ); + +plugin = plugin.replace( + /^(Version:\s*)\d+\.\d+\.\d+/m, + `$1${ version }` +); +fs.writeFileSync( pluginFile, plugin ); + +const readmeFile = path.join( root, 'readme.txt' ); +let readme = fs.readFileSync( readmeFile, 'utf8' ); + +readme = readme.replace( + /^Stable tag: \d+\.\d+\.\d+/m, + `Stable tag: ${ version }` +); +fs.writeFileSync( readmeFile, readme ); + +console.log( `Synced version ${ version } to aryo-activity-log.php and readme.txt` ); diff --git a/scripts/validate-build-files.sh b/scripts/validate-build-files.sh new file mode 100755 index 0000000..33b570f --- /dev/null +++ b/scripts/validate-build-files.sh @@ -0,0 +1,80 @@ +#!/bin/bash +set -eo pipefail + +if [[ -z "$PLUGIN_SLUG" ]]; then + echo "Set the PLUGIN_SLUG env var" + exit 1 +fi + +if [[ -z "$PLUGIN_VERSION" ]]; then + echo "Set the PLUGIN_VERSION env var" + exit 1 +fi + +REPO_ROOT="${GITHUB_WORKSPACE:-.}" +PLUGIN_PATH="${REPO_ROOT}/${PLUGIN_SLUG}" +EXCLUDE_FILE="${REPO_ROOT}/.build-rsync-exclude" + +if [[ ! -d "$PLUGIN_PATH" ]]; then + echo "BUILD_DIR '$PLUGIN_PATH' does not exist" + exit 1 +fi + +cd "$PLUGIN_PATH" + +PLUGIN_MAIN_FILE="${PLUGIN_SLUG}.php" + +if [[ ! -f "$PLUGIN_MAIN_FILE" ]]; then + echo "${PLUGIN_MAIN_FILE} does not exist in build dir" + exit 1 +fi + +if [[ ! -f "readme.txt" ]]; then + echo "readme.txt does not exist in build dir" + exit 1 +fi + +if [[ $(grep -c "Version: $PLUGIN_VERSION" "$PLUGIN_MAIN_FILE") -eq 0 ]]; then + echo "${PLUGIN_MAIN_FILE} does not contain Version: $PLUGIN_VERSION" + EXISTING_VERSION=$(sed -n 's/.*Version: \(.*\)/\1/p' "$PLUGIN_MAIN_FILE") + echo "Found: $EXISTING_VERSION" + exit 1 +fi + +if [[ $(grep -c "Stable tag: $PLUGIN_VERSION" "readme.txt") -eq 0 ]]; then + echo "readme.txt does not contain Stable tag: $PLUGIN_VERSION" + EXISTING_VERSION=$(sed -n 's/.*Stable tag: \(.*\)/\1/p' "readme.txt") + echo "Found: $EXISTING_VERSION" + exit 1 +fi + +if [[ ! -f "$EXCLUDE_FILE" ]]; then + echo "WARNING: $EXCLUDE_FILE not found, skipping exclude-list check" +else + ERRORS=0 + while IFS= read -r entry; do + # Skip empty lines, comments, and glob-only patterns (e.g. *.zip) + [[ -z "$entry" ]] && continue + [[ "$entry" == \#* ]] && continue + + # Strip trailing slash for directory entries + clean="${entry%/}" + + # Skip self-references and glob patterns + [[ "$clean" == "$PLUGIN_SLUG" ]] && continue + [[ "$clean" == *"*"* ]] && continue + + if [[ -e "$clean" ]]; then + echo "ERROR: '$clean' from .build-rsync-exclude found in build directory" + ERRORS=1 + fi + done < "$EXCLUDE_FILE" + + if [[ $ERRORS -ne 0 ]]; then + exit 1 + fi +fi + +echo "Build validation passed for ${PLUGIN_SLUG} v${PLUGIN_VERSION}" +echo "Contents:" +ls -la diff --git a/scripts/validate-readme-changelog.js b/scripts/validate-readme-changelog.js new file mode 100644 index 0000000..5ba908a --- /dev/null +++ b/scripts/validate-readme-changelog.js @@ -0,0 +1,53 @@ +'use strict'; + +const fs = require( 'fs' ); +const path = require( 'path' ); + +const root = path.resolve( __dirname, '..' ); + +const version = process.argv[ 2 ] || require( path.join( root, 'package.json' ) ).version; +const readme = fs.readFileSync( path.join( root, 'readme.txt' ), 'utf8' ); + +const changelogMarker = '== Changelog =='; +const changelogIndex = readme.indexOf( changelogMarker ); + +if ( changelogIndex === -1 ) { + console.error( 'readme.txt is missing "== Changelog ==" section.' ); + process.exit( 1 ); +} + +const changelogBody = readme.slice( changelogIndex + changelogMarker.length ); + +// Match "= X.Y.Z =" or "= X.Y.Z - YYYY-MM-DD =" (date suffix is optional) +const sectionRegex = new RegExp( + `= ${ version.replace( /\./g, '\\.' ) }(\\s+-\\s+\\d{4}-\\d{2}-\\d{2})?\\s*=` +); + +const headerMatch = changelogBody.match( sectionRegex ); + +if ( ! headerMatch ) { + console.error( + `readme.txt is missing a changelog section for version ${ version }.\n` + + `Add "= ${ version } =" (optionally with " - YYYY-MM-DD") with release notes under "== Changelog ==" before dispatching the release.` + ); + process.exit( 1 ); +} + +const headerIndex = changelogBody.indexOf( headerMatch[ 0 ] ); +const afterHeader = changelogBody.slice( headerIndex + headerMatch[ 0 ].length ); +const nextSectionMatch = afterHeader.match( /\n= \d+\.\d+\.\d+/ ); +const sectionContent = nextSectionMatch + ? afterHeader.slice( 0, nextSectionMatch.index ) + : afterHeader; + +const hasBullet = /^\* /m.test( sectionContent ); + +if ( ! hasBullet ) { + console.error( + `readme.txt changelog section for version ${ version } has no release notes.\n` + + `Add at least one "* ..." line under the section header before dispatching the release.` + ); + process.exit( 1 ); +} + +console.log( `Changelog section for version ${ version } is valid.` );