Skip to content
Open
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
181 changes: 105 additions & 76 deletions .github/workflows/build-and-distribute.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -146,57 +152,57 @@ 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 }}"
else
# 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!"
Expand All @@ -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)"
Expand All @@ -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 }}
Expand All @@ -262,14 +323,15 @@ 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 }}'
with:
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 }}
Expand All @@ -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."))
Expand All @@ -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 }}/"
Expand Down
33 changes: 22 additions & 11 deletions docs/build-and-distribute.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -201,7 +202,7 @@ on:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false

jobs:
build-and-distribute:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
Loading