diff --git a/.github/workflows/build-and-distribute.yml b/.github/workflows/build-and-distribute.yml index de676838..9458cd04 100644 --- a/.github/workflows/build-and-distribute.yml +++ b/.github/workflows/build-and-distribute.yml @@ -87,6 +87,12 @@ on: jobs: build-and-distribute: + # Serialize concurrent builds targeting the same SHA + branch. + # cancel-in-progress: false queues the second run; it will then detect + # the already-built SHA and resolve transparently via artifact generation. + concurrency: + group: build-and-distribute-${{ github.sha }}-${{ inputs.BUILT_BRANCH_NAME || github.ref_name }} + cancel-in-progress: false timeout-minutes: 10 runs-on: ubuntu-latest outputs: @@ -146,42 +152,42 @@ jobs: run: | # Fetch latest public release LATEST_RELEASE=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || echo "") - + if [ -z "$LATEST_RELEASE" ]; then echo "No releases found, using default version 0.0.0." LATEST_RELEASE="0.0.0" else echo "Latest release found: $LATEST_RELEASE" fi - + # Get short SHA (first 7 characters) SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) echo "Short SHA: $SHORT_SHA" - + # Strip 'dev/' prefix from branch name for version string (if it exists) ORIGIN_BRANCH="${{ github.ref_name }}" VERSION_BRANCH="${ORIGIN_BRANCH#dev/}" - + # Normalize branch name for semver pre-release identifier # Replace invalid characters with hyphens and convert to lowercase NORMALIZED_BRANCH=$(echo "$VERSION_BRANCH" | sed 's/[^a-zA-Z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g' | tr '[:upper:]' '[:lower:]') - + # Construct semver with pre-release identifier using short SHA PACKAGE_VERSION="${LATEST_RELEASE}+${NORMALIZED_BRANCH}.${SHORT_SHA}" echo "Generated package version: $PACKAGE_VERSION" - + echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_ENV - name: Inspect origin branch and determine build branch run: | ORIGIN_BRANCH="${{ github.ref_name }}" echo "Origin branch: $ORIGIN_BRANCH" - + # Branch scheme: # dev/main -> main (stable production code with assets, where releases are created) # dev/ABC-123 -> ABC-123 (feature branch with compiled assets) # dev/xyz -> xyz (any other branch) - + # Determine the build branch name if [ -n "${{ inputs.BUILT_BRANCH_NAME }}" ]; then BUILD_BRANCH="${{ inputs.BUILT_BRANCH_NAME }}" @@ -189,14 +195,14 @@ jobs: # Strip 'dev/' prefix from origin branch to get build branch (if it exists) BUILD_BRANCH="${ORIGIN_BRANCH#dev/}" fi - + echo "Build branch: $BUILD_BRANCH" echo "BUILT_BRANCH_NAME=$BUILD_BRANCH" >> $GITHUB_ENV - name: Validate branch strategy run: | ORIGIN_BRANCH="${{ github.ref_name }}" - + # Check if we would commit to the same branch as the origin if [ "$ORIGIN_BRANCH" = "${{ env.BUILT_BRANCH_NAME }}" ]; then echo "❌ ERROR: Build artifacts would be committed to the same branch as the source code!" @@ -215,34 +221,89 @@ jobs: echo "" exit 1 fi - + echo "✅ Branch strategy validated:" echo " Source branch: $ORIGIN_BRANCH" echo " Build branch: ${{ env.BUILT_BRANCH_NAME }}" + - name: Determine package type + run: | + # Check for WordPress theme + if [ -f "style.css" ] && grep -q "Theme Name:" style.css; then + echo "Package type: WordPress Theme" + echo "PACKAGE_FILE=style.css" >> $GITHUB_ENV + # Check for WordPress plugin + else + # Find the first PHP file with "Plugin Name" header + PLUGIN_FILE=$(grep -rl --include "*.php" "Plugin Name:" . 2>/dev/null | head -n1) + if [ -n "$PLUGIN_FILE" ]; then + echo "Package type: WordPress Plugin" + echo "Plugin file found: $PLUGIN_FILE" + echo "PACKAGE_FILE=$PLUGIN_FILE" >> $GITHUB_ENV + # Nothing to do for generic library + else + echo "Package type: Library" + fi + fi + + - name: Check if already built for this SHA + run: | + SHA="${{ github.sha }}" + BUILD_BRANCH="${{ env.BUILT_BRANCH_NAME }}" + PACKAGE_FILE="${{ env.PACKAGE_FILE }}" + + if [ -z "$PACKAGE_FILE" ]; then + echo "No package file (library) — proceeding with build..." + elif ! git show-ref -q "refs/remotes/origin/$BUILD_BRANCH"; then + echo "Build branch does not exist yet — proceeding with build..." + elif git show "origin/$BUILD_BRANCH:$PACKAGE_FILE" 2>/dev/null | grep -qF "SHA: $SHA"; then + echo "Already built: SHA $SHA found in $PACKAGE_FILE on build branch $BUILD_BRANCH." + echo "SKIP_BUILD=true" >> $GITHUB_ENV + else + echo "No existing build found for SHA $SHA — proceeding with build..." + fi + - name: Checkout the build branch with clean slate run: | - # Check if build branch exists remotely - if git show-ref -q refs/remotes/origin/${{ env.BUILT_BRANCH_NAME }}; then - echo "Build branch exists, checking out and cleaning..." - git checkout ${{ env.BUILT_BRANCH_NAME }} - - # Clean everything except .git directory for a fresh start - git rm -rf . || true - git clean -fxd - echo "✅ Cleaned existing build branch for fresh build" + if [ "${{ env.SKIP_BUILD }}" = "true" ]; then + # Build already completed for this SHA — check out existing compiled build branch for artifact generation. + git checkout "${{ env.BUILT_BRANCH_NAME }}" + echo "✅ Checked out existing build branch (skipping rebuild)." else - echo "Build branch doesn't exist, creating new branch..." - git checkout -b ${{ env.BUILT_BRANCH_NAME }} - echo "✅ Created new build branch" + # Check if build branch exists remotely + if git show-ref -q refs/remotes/origin/${{ env.BUILT_BRANCH_NAME }}; then + echo "Build branch exists, checking out and cleaning..." + git checkout ${{ env.BUILT_BRANCH_NAME }} + + # Clean everything except .git directory for a fresh start + git rm -rf . || true + git clean -fxd + echo "✅ Cleaned existing build branch for fresh build" + else + echo "Build branch doesn't exist, creating new branch..." + git checkout -b ${{ env.BUILT_BRANCH_NAME }} + echo "✅ Created new build branch" + fi + + # Copy all files from source branch + git checkout ${{ github.ref_name }} -- . + echo "✅ Copied source files to build branch" + fi + + - name: Prepare package + if: ${{ env.SKIP_BUILD != 'true' && env.PACKAGE_FILE != '' }} + run: | + echo "Updating ${{ env.PACKAGE_FILE }}..." + sed -Ei "s/Version: .*/Version: ${{ env.PACKAGE_VERSION }}/g" "${{ env.PACKAGE_FILE }}" + if grep -q "SHA:" "${{ env.PACKAGE_FILE }}"; then + sed -Ei "s/SHA: .*/SHA: ${{ github.sha }}/g" "${{ env.PACKAGE_FILE }}" + else + sed -Ei "/Version: /a SHA: ${{ github.sha }}" "${{ env.PACKAGE_FILE }}" fi - - # Copy all files from source branch - git checkout ${{ github.ref_name }} -- . - echo "✅ Copied source files to build branch" - name: Check for composer.json id: check-composer + if: ${{ env.SKIP_BUILD != 'true' }} run: | if [ -f "composer.json" ]; then echo "Composer project detected (composer.json exists)" @@ -253,7 +314,7 @@ jobs: fi - name: Set up PHP - if: ${{ env.HAS_COMPOSER == 'true' }} + if: ${{ env.SKIP_BUILD != 'true' && env.HAS_COMPOSER == 'true' }} uses: shivammathur/setup-php@v2 with: php-version: ${{ inputs.PHP_VERSION }} @@ -262,7 +323,7 @@ jobs: coverage: none - name: Install Composer dependencies - if: ${{ env.HAS_COMPOSER == 'true' }} + if: ${{ env.SKIP_BUILD != 'true' && env.HAS_COMPOSER == 'true' }} uses: ramsey/composer-install@v3 env: COMPOSER_AUTH: '${{ secrets.COMPOSER_AUTH_JSON }}' @@ -270,6 +331,7 @@ jobs: composer-options: ${{ inputs.COMPOSER_ARGS }} - name: Set up node + if: ${{ env.SKIP_BUILD != 'true' }} uses: actions/setup-node@v4 env: NODE_OPTIONS: ${{ inputs.NODE_OPTIONS }} @@ -280,89 +342,56 @@ jobs: cache: npm - name: Install dependencies + if: ${{ env.SKIP_BUILD != 'true' }} run: npm ci - - name: Determine package type - run: | - # Check for WordPress theme (style.css with Theme Name header) - if [ -f "style.css" ] && grep -q "Theme Name:" style.css; then - echo "Package type: WordPress Theme" - echo "PACKAGE_TYPE=wordpress-theme" >> $GITHUB_ENV - # Check for WordPress plugin (PHP file with Plugin Name header) - else - # Find the first PHP file with "Plugin Name:" header - PLUGIN_FILE=$(grep -rl --include "*.php" "Plugin Name:" . 2>/dev/null | head -n1) - if [ -n "$PLUGIN_FILE" ]; then - echo "Package type: WordPress Plugin" - echo "Plugin file found: $PLUGIN_FILE" - echo "PACKAGE_TYPE=wordpress-plugin" >> $GITHUB_ENV - echo "PLUGIN_MAIN_FILE=$PLUGIN_FILE" >> $GITHUB_ENV - # Fallback to generic library - else - echo "Package type: Library" - echo "PACKAGE_TYPE=library" >> $GITHUB_ENV - fi - fi - - - name: Prepare WordPress Plugin - if: ${{ env.PACKAGE_TYPE == 'wordpress-plugin' }} - run: | - PLUGIN_FILE="${{ env.PLUGIN_MAIN_FILE }}" - echo "Updating plugin file: $PLUGIN_FILE" - sed -Ei "s/Version: .*/Version: ${{ env.PACKAGE_VERSION }}/g" "$PLUGIN_FILE" - sed -Ei "s/SHA: .*/SHA: ${{ github.sha }}/g" "$PLUGIN_FILE" - - - name: Prepare WordPress Theme - if: ${{ env.PACKAGE_TYPE == 'wordpress-theme' }} - run: | - # Update theme style.css file - echo "Updating theme style.css." - sed -Ei "s/Version: .*/Version: ${{ env.PACKAGE_VERSION }}/g" style.css - sed -Ei "s/SHA: .*/SHA: ${{ github.sha }}/g" style.css - - name: Update version in package.json + if: ${{ env.SKIP_BUILD != 'true' }} run: npm version ${{ env.PACKAGE_VERSION }} --no-git-tag-version --allow-same-version - name: Update version in composer.json - if: ${{ env.HAS_COMPOSER == 'true' }} + if: ${{ env.SKIP_BUILD != 'true' && env.HAS_COMPOSER == 'true' }} run: | echo "Updating composer.json version to ${{ env.PACKAGE_VERSION }}." jq --arg version "${{ env.PACKAGE_VERSION }}" '.version = $version' composer.json > composer.json.tmp && mv composer.json.tmp composer.json echo "✅ Updated composer.json version field." - name: Execute custom code before archive creation + if: ${{ env.SKIP_BUILD != 'true' }} run: | ${{ inputs.PRE_SCRIPT }} - name: Compile assets + if: ${{ env.SKIP_BUILD != 'true' }} run: npm run build - name: Run WordPress Translation Downloader - if: ${{ env.HAS_COMPOSER == 'true' }} + if: ${{ env.SKIP_BUILD != 'true' && env.HAS_COMPOSER == 'true' }} run: composer wp-translation-downloader:download - name: Run PHP-Scoper - if: ${{ env.HAS_COMPOSER == 'true' && hashFiles('scoper.inc.php') != '' }} + if: ${{ env.SKIP_BUILD != 'true' && env.HAS_COMPOSER == 'true' && hashFiles('scoper.inc.php') != '' }} # The sed call appends the Git commit SHA to the Composer autoload cache key to ensure unique identification for prefixed files. # This prevents Composer from skipping autoloaded files due to hash collisions based on relative paths. run: | php-scoper add-prefix --force --output-dir=build composer --working-dir=build dump-autoload -o - sed -i "s/'__composer_autoload_files'/\'__composer_autoload_files_${{ github.sha }}'/g" "build/vendor/composer/autoload_real.php" + sed -i "s/'__composer_autoload_files'/\'__composer_autoload_files_${{ github.sha }}'/g" "build/vendor/composer/autoload_real.php" rsync -av ./build/ . && rm -rf ./build/ - name: Apply .distignore file - if: ${{ hashFiles('.distignore') != '' }} + if: ${{ env.SKIP_BUILD != 'true' && hashFiles('.distignore') != '' }} # Configure git to (gracefully) use .distignore file during packaging instead of the regular .gitignore file. # Then clean up all files possibly mentioned in .distignore (e.g., source files). run: | find . -name ".gitignore" -delete - git config core.excludesFile .distignore + git config core.excludesFile .distignore git rm -rf --cached . git add . - git clean -Xdf + git clean -Xdf - name: Git add, commit, and push + if: ${{ env.SKIP_BUILD != 'true' }} run: | git add -A git commit -m "[BOT] Add build artifact from ${{ github.ref_name }} -> ${{ env.BUILT_BRANCH_NAME }}." --no-verify || ((echo "HAS_GIT_CHANGES=yes" >> $GITHUB_ENV) && (echo "No changes to commit.")) @@ -376,10 +405,10 @@ jobs: run: | # Create package directory for artifact mkdir -p "./artifact-staging/${{ env.PACKAGE_NAME }}" - + # Copy all files to the package directory (excluding .git) rsync -av --exclude='.git' --exclude='./artifact-staging' ./ "./artifact-staging/${{ env.PACKAGE_NAME }}/" - + echo "✅ Prepared artifact with package structure:" echo " Package name: ${{ env.PACKAGE_NAME }}" echo " Artifact structure: artifact-staging/${{ env.PACKAGE_NAME }}/" diff --git a/docs/build-and-distribute.md b/docs/build-and-distribute.md index 68671fc8..91eaeed3 100644 --- a/docs/build-and-distribute.md +++ b/docs/build-and-distribute.md @@ -5,14 +5,15 @@ This action can be used to build plugin and theme archives and push them to corr To achieve that, the reusable workflow: 1. Inspects the origin branch and determines the correlating build branch (strips `dev/` prefix) -2. Installs dependencies (including dev-dependencies) defined in `composer.json` -3. Installs Node.js dependencies and compiles assets via `npm run build` -4. Updates version information in plugin/theme headers and `package.json` -5. Executes [WordPress Translation Downloader](https://github.com/inpsyde/wp-translation-downloader) if configured by the package -6. Executes [PHP-Scoper](https://github.com/humbug/php-scoper) if configured by the package -7. Applies `.distignore` file filtering if present -8. Commits and pushes the build artifact to the determined build branch -9. Uploads the build as a GitHub Actions artifact for download +2. Checks whether the build branch already contains the current commit SHA — if so, skips steps 3–8 entirely (see [Duplicate run detection](#duplicate-run-detection)) +3. Installs dependencies (including dev-dependencies) defined in `composer.json` +4. Installs Node.js dependencies and compiles assets via `npm run build` +5. Updates version and SHA information in plugin/theme headers and `package.json` +6. Executes [WordPress Translation Downloader](https://github.com/inpsyde/wp-translation-downloader) if configured by the package +7. Executes [PHP-Scoper](https://github.com/humbug/php-scoper) if configured by the package +8. Applies `.distignore` file filtering if present +9. Commits and pushes the build artifact to the determined build branch +10. Uploads the build as a GitHub Actions artifact for download ## Branch naming convention @@ -177,7 +178,7 @@ By default, the workflow strips the `dev/` prefix from the origin branch to dete - Ensure your `package.json` includes a `build` script for asset compilation - Use `.distignore` to exclude development files from the final build - Consider using [path filters](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#example-including-paths) to avoid unnecessary builds when only documentation changes -- Use [concurrency settings](https://docs.github.com/en/actions/using-jobs/using-concurrency) to prevent conflicts when multiple pushes occur rapidly +- Use [concurrency settings](https://docs.github.com/en/actions/using-jobs/using-concurrency) to prevent conflicts when multiple pushes occur rapidly — the workflow serializes concurrent builds for the same SHA internally, so a queued second run will skip the actual build and resolve via the pre-built artifact ```yml name: Build and push assets @@ -201,7 +202,7 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: build-and-distribute: @@ -249,7 +250,7 @@ This makes the workflow flexible enough to handle both full-stack WordPress proj The workflow handles version information for both plugins and themes: - Updates `Version:` header in the main plugin file -- Updates `SHA:` header with the current commit hash +- Upserts `SHA:` header with the current commit hash — replaces the existing value if the field is present, or inserts a new line after `Version:` if it is absent - Updates version in `package.json` and `composer.json` ### Asset Compilation @@ -272,6 +273,16 @@ If a `scoper.inc.php` file is present, the workflow will: 2. Rebuild the autoloader for the scoped dependencies 3. Ensure unique autoload cache keys to prevent conflicts +### Duplicate run detection + +The workflow detects when the build branch already contains a build for the current commit SHA and skips the entire compilation pipeline in that case. + +After each successful build, the commit SHA is written into the `SHA:` header of `style.css` (themes) or the main plugin PHP file (plugins). On subsequent runs for the same commit, the workflow reads that field from the remote build branch _before_ any toolchain setup. If the SHA matches, all build and compile steps are skipped and the workflow proceeds directly to packaging the existing build branch content and uploading the artifact. + +This is transparent to downstream jobs: the `artifact` output is always populated, whether the build was freshly compiled or reused. + +**Applies to WordPress plugins and themes only.** Library projects do not embed a `SHA:` field, so they always rebuild. + ### Distignore Support If a `.distignore` file is present, the workflow will: