Skip to content

Commit 441c6bd

Browse files
Bionttyrann0us
andauthored
Add duplicate run detection to skip redundant builds (#253)
* Add duplicate run detection to skip redundant builds Introduce a `Check if already built for this SHA` step that inspects the remote build branch before any toolchain setup. If the current commit SHA is already embedded in `style.css` (themes) or the main plugin PHP file (plugins), the entire compilation pipeline is skipped and the workflow proceeds directly to packaging the existing build branch content. Key changes: - New early-exit step reads `SHA:` header from the remote build branch and sets `SKIP_BUILD=true` when a match is found, bypassing install, compile, and commit steps - All subsequent build steps are guarded with `SKIP_BUILD != 'true'` conditions - `Determine package type` and `Prepare WordPress *` steps moved earlier (before npm/composer install) so the SHA can be embedded before compilation - SHA field handling changed from a plain replace to an upsert: inserts a new `SHA:` line after `Version:` when the field is absent, replaces it when present - Downstream jobs remain unaffected; the artifact output is always populated regardless of whether the build was fresh or reused - Library projects do not embed a `SHA:` field and always rebuild - Trailing whitespace cleaned up throughout the file * chore: merge prepare package steps * chore: merge check and prepare package steps * Update .github/workflows/build-and-distribute.yml Co-authored-by: Philipp Bammes <8144115+tyrann0us@users.noreply.github.com> Signed-off-by: Moritz Meißelbach <arbelzapf@gmail.com> * Add concurrency serialization to build-and-distribute job Prevent duplicate builds when the same SHA is triggered more than once (e.g., push + manual dispatch racing). By setting `cancel-in-progress: false`, a queued second run will detect the already-built artifact and resolve without repeating the full build. - Add `concurrency` group scoped to SHA + branch name on the job - Update docs to reflect the new serialization behavior and recommend `cancel-in-progress: false` in caller workflows --------- Signed-off-by: Moritz Meißelbach <arbelzapf@gmail.com> Co-authored-by: Philipp Bammes <p.bammes@syde.com> Co-authored-by: Philipp Bammes <8144115+tyrann0us@users.noreply.github.com>
1 parent cbf5f65 commit 441c6bd

2 files changed

Lines changed: 126 additions & 87 deletions

File tree

.github/workflows/build-and-distribute.yml

Lines changed: 104 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ on:
8787

8888
jobs:
8989
build-and-distribute:
90+
# Serialize concurrent builds targeting the same SHA + branch.
91+
# cancel-in-progress: false queues the second run; it will then detect
92+
# the already-built SHA and resolve transparently via artifact generation.
93+
concurrency:
94+
group: build-and-distribute-${{ github.sha }}-${{ inputs.BUILT_BRANCH_NAME || github.ref_name }}
95+
cancel-in-progress: false
9096
timeout-minutes: 10
9197
runs-on: ubuntu-latest
9298
outputs:
@@ -134,57 +140,57 @@ jobs:
134140
run: |
135141
# Fetch latest public release
136142
LATEST_RELEASE=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || echo "")
137-
143+
138144
if [ -z "$LATEST_RELEASE" ]; then
139145
echo "No releases found, using default version 0.0.0."
140146
LATEST_RELEASE="0.0.0"
141147
else
142148
echo "Latest release found: $LATEST_RELEASE"
143149
fi
144-
150+
145151
# Get short SHA (first 7 characters)
146152
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
147153
echo "Short SHA: $SHORT_SHA"
148-
154+
149155
# Strip 'dev/' prefix from branch name for version string (if it exists)
150156
ORIGIN_BRANCH="${{ github.ref_name }}"
151157
VERSION_BRANCH="${ORIGIN_BRANCH#dev/}"
152-
158+
153159
# Normalize branch name for semver pre-release identifier
154160
# Replace invalid characters with hyphens and convert to lowercase
155161
NORMALIZED_BRANCH=$(echo "$VERSION_BRANCH" | sed 's/[^a-zA-Z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g' | tr '[:upper:]' '[:lower:]')
156-
162+
157163
# Construct semver with pre-release identifier using short SHA
158164
PACKAGE_VERSION="${LATEST_RELEASE}+${NORMALIZED_BRANCH}.${SHORT_SHA}"
159165
echo "Generated package version: $PACKAGE_VERSION"
160-
166+
161167
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_ENV
162168
163169
- name: Inspect origin branch and determine build branch
164170
run: |
165171
ORIGIN_BRANCH="${{ github.ref_name }}"
166172
echo "Origin branch: $ORIGIN_BRANCH"
167-
173+
168174
# Branch scheme:
169175
# dev/main -> main (stable production code with assets, where releases are created)
170176
# dev/ABC-123 -> ABC-123 (feature branch with compiled assets)
171177
# dev/xyz -> xyz (any other branch)
172-
178+
173179
# Determine the build branch name
174180
if [ -n "${{ inputs.BUILT_BRANCH_NAME }}" ]; then
175181
BUILD_BRANCH="${{ inputs.BUILT_BRANCH_NAME }}"
176182
else
177183
# Strip 'dev/' prefix from origin branch to get build branch (if it exists)
178184
BUILD_BRANCH="${ORIGIN_BRANCH#dev/}"
179185
fi
180-
186+
181187
echo "Build branch: $BUILD_BRANCH"
182188
echo "BUILT_BRANCH_NAME=$BUILD_BRANCH" >> $GITHUB_ENV
183189
184190
- name: Validate branch strategy
185191
run: |
186192
ORIGIN_BRANCH="${{ github.ref_name }}"
187-
193+
188194
# Check if we would commit to the same branch as the origin
189195
if [ "$ORIGIN_BRANCH" = "${{ env.BUILT_BRANCH_NAME }}" ]; then
190196
echo "❌ ERROR: Build artifacts would be committed to the same branch as the source code!"
@@ -203,34 +209,89 @@ jobs:
203209
echo ""
204210
exit 1
205211
fi
206-
212+
207213
echo "✅ Branch strategy validated:"
208214
echo " Source branch: $ORIGIN_BRANCH"
209215
echo " Build branch: ${{ env.BUILT_BRANCH_NAME }}"
210216
217+
- name: Determine package type
218+
run: |
219+
# Check for WordPress theme
220+
if [ -f "style.css" ] && grep -q "Theme Name:" style.css; then
221+
echo "Package type: WordPress Theme"
222+
echo "PACKAGE_FILE=style.css" >> $GITHUB_ENV
223+
# Check for WordPress plugin
224+
else
225+
# Find the first PHP file with "Plugin Name" header
226+
PLUGIN_FILE=$(grep -rl --include "*.php" "Plugin Name:" . 2>/dev/null | head -n1)
227+
if [ -n "$PLUGIN_FILE" ]; then
228+
echo "Package type: WordPress Plugin"
229+
echo "Plugin file found: $PLUGIN_FILE"
230+
echo "PACKAGE_FILE=$PLUGIN_FILE" >> $GITHUB_ENV
231+
# Nothing to do for generic library
232+
else
233+
echo "Package type: Library"
234+
fi
235+
fi
236+
237+
- name: Check if already built for this SHA
238+
run: |
239+
SHA="${{ github.sha }}"
240+
BUILD_BRANCH="${{ env.BUILT_BRANCH_NAME }}"
241+
PACKAGE_FILE="${{ env.PACKAGE_FILE }}"
242+
243+
if [ -z "$PACKAGE_FILE" ]; then
244+
echo "No package file (library) — proceeding with build..."
245+
elif ! git show-ref -q "refs/remotes/origin/$BUILD_BRANCH"; then
246+
echo "Build branch does not exist yet — proceeding with build..."
247+
elif git show "origin/$BUILD_BRANCH:$PACKAGE_FILE" 2>/dev/null | grep -qF "SHA: $SHA"; then
248+
echo "Already built: SHA $SHA found in $PACKAGE_FILE on build branch $BUILD_BRANCH."
249+
echo "SKIP_BUILD=true" >> $GITHUB_ENV
250+
else
251+
echo "No existing build found for SHA $SHA — proceeding with build..."
252+
fi
253+
211254
- name: Checkout the build branch with clean slate
212255
run: |
213-
# Check if build branch exists remotely
214-
if git show-ref -q refs/remotes/origin/${{ env.BUILT_BRANCH_NAME }}; then
215-
echo "Build branch exists, checking out and cleaning..."
216-
git checkout ${{ env.BUILT_BRANCH_NAME }}
217-
218-
# Clean everything except .git directory for a fresh start
219-
git rm -rf . || true
220-
git clean -fxd
221-
echo "✅ Cleaned existing build branch for fresh build"
256+
if [ "${{ env.SKIP_BUILD }}" = "true" ]; then
257+
# Build already completed for this SHA — check out existing compiled build branch for artifact generation.
258+
git checkout "${{ env.BUILT_BRANCH_NAME }}"
259+
echo "✅ Checked out existing build branch (skipping rebuild)."
222260
else
223-
echo "Build branch doesn't exist, creating new branch..."
224-
git checkout -b ${{ env.BUILT_BRANCH_NAME }}
225-
echo "✅ Created new build branch"
261+
# Check if build branch exists remotely
262+
if git show-ref -q refs/remotes/origin/${{ env.BUILT_BRANCH_NAME }}; then
263+
echo "Build branch exists, checking out and cleaning..."
264+
git checkout ${{ env.BUILT_BRANCH_NAME }}
265+
266+
# Clean everything except .git directory for a fresh start
267+
git rm -rf . || true
268+
git clean -fxd
269+
echo "✅ Cleaned existing build branch for fresh build"
270+
else
271+
echo "Build branch doesn't exist, creating new branch..."
272+
git checkout -b ${{ env.BUILT_BRANCH_NAME }}
273+
echo "✅ Created new build branch"
274+
fi
275+
276+
# Copy all files from source branch
277+
git checkout ${{ github.ref_name }} -- .
278+
echo "✅ Copied source files to build branch"
279+
fi
280+
281+
- name: Prepare package
282+
if: ${{ env.SKIP_BUILD != 'true' && env.PACKAGE_FILE != '' }}
283+
run: |
284+
echo "Updating ${{ env.PACKAGE_FILE }}..."
285+
sed -Ei "s/Version: .*/Version: ${{ env.PACKAGE_VERSION }}/g" "${{ env.PACKAGE_FILE }}"
286+
if grep -q "SHA:" "${{ env.PACKAGE_FILE }}"; then
287+
sed -Ei "s/SHA: .*/SHA: ${{ github.sha }}/g" "${{ env.PACKAGE_FILE }}"
288+
else
289+
sed -Ei "/Version: /a SHA: ${{ github.sha }}" "${{ env.PACKAGE_FILE }}"
226290
fi
227-
228-
# Copy all files from source branch
229-
git checkout ${{ github.ref_name }} -- .
230-
echo "✅ Copied source files to build branch"
231291
232292
- name: Check for composer.json
233293
id: check-composer
294+
if: ${{ env.SKIP_BUILD != 'true' }}
234295
run: |
235296
if [ -f "composer.json" ]; then
236297
echo "Composer project detected (composer.json exists)"
@@ -241,7 +302,7 @@ jobs:
241302
fi
242303
243304
- name: Set up PHP
244-
if: ${{ env.HAS_COMPOSER == 'true' }}
305+
if: ${{ env.SKIP_BUILD != 'true' && env.HAS_COMPOSER == 'true' }}
245306
uses: shivammathur/setup-php@v2
246307
with:
247308
php-version: ${{ inputs.PHP_VERSION }}
@@ -250,102 +311,69 @@ jobs:
250311
coverage: none
251312

252313
- name: Install Composer dependencies
253-
if: ${{ env.HAS_COMPOSER == 'true' }}
314+
if: ${{ env.SKIP_BUILD != 'true' && env.HAS_COMPOSER == 'true' }}
254315
uses: ramsey/composer-install@v3
255316
env:
256317
COMPOSER_AUTH: '${{ secrets.COMPOSER_AUTH_JSON }}'
257318
with:
258319
composer-options: ${{ inputs.COMPOSER_ARGS }}
259320

260321
- name: Set up Node
322+
if: ${{ env.SKIP_BUILD != 'true' }}
261323
uses: inpsyde/reusable-workflows/.github/actions/setup-node@main
262324
with:
263325
node-version: ${{ inputs.NODE_VERSION }}
264326
registry-url: ${{ inputs.NPM_REGISTRY_DOMAIN }}
265327
node-auth-token: ${{ secrets.NPM_REGISTRY_TOKEN }}
266328
node-options: ${{ inputs.NODE_OPTIONS }}
267329

268-
- name: Determine package type
269-
run: |
270-
# Check for WordPress theme (style.css with Theme Name header)
271-
if [ -f "style.css" ] && grep -q "Theme Name:" style.css; then
272-
echo "Package type: WordPress Theme"
273-
echo "PACKAGE_TYPE=wordpress-theme" >> $GITHUB_ENV
274-
# Check for WordPress plugin (PHP file with Plugin Name header)
275-
else
276-
# Find the first PHP file with "Plugin Name:" header
277-
PLUGIN_FILE=$(grep -rl --include "*.php" "Plugin Name:" . 2>/dev/null | head -n1)
278-
if [ -n "$PLUGIN_FILE" ]; then
279-
echo "Package type: WordPress Plugin"
280-
echo "Plugin file found: $PLUGIN_FILE"
281-
echo "PACKAGE_TYPE=wordpress-plugin" >> $GITHUB_ENV
282-
echo "PLUGIN_MAIN_FILE=$PLUGIN_FILE" >> $GITHUB_ENV
283-
# Fallback to generic library
284-
else
285-
echo "Package type: Library"
286-
echo "PACKAGE_TYPE=library" >> $GITHUB_ENV
287-
fi
288-
fi
289-
290-
- name: Prepare WordPress Plugin
291-
if: ${{ env.PACKAGE_TYPE == 'wordpress-plugin' }}
292-
run: |
293-
PLUGIN_FILE="${{ env.PLUGIN_MAIN_FILE }}"
294-
echo "Updating plugin file: $PLUGIN_FILE"
295-
sed -Ei "s/Version: .*/Version: ${{ env.PACKAGE_VERSION }}/g" "$PLUGIN_FILE"
296-
sed -Ei "s/SHA: .*/SHA: ${{ github.sha }}/g" "$PLUGIN_FILE"
297-
298-
- name: Prepare WordPress Theme
299-
if: ${{ env.PACKAGE_TYPE == 'wordpress-theme' }}
300-
run: |
301-
# Update theme style.css file
302-
echo "Updating theme style.css."
303-
sed -Ei "s/Version: .*/Version: ${{ env.PACKAGE_VERSION }}/g" style.css
304-
sed -Ei "s/SHA: .*/SHA: ${{ github.sha }}/g" style.css
305-
306330
- name: Update version in package.json
331+
if: ${{ env.SKIP_BUILD != 'true' }}
307332
run: npm version ${{ env.PACKAGE_VERSION }} --no-git-tag-version --allow-same-version
308333

309334
- name: Update version in composer.json
310-
if: ${{ env.HAS_COMPOSER == 'true' }}
335+
if: ${{ env.SKIP_BUILD != 'true' && env.HAS_COMPOSER == 'true' }}
311336
run: |
312337
echo "Updating composer.json version to ${{ env.PACKAGE_VERSION }}."
313338
jq --arg version "${{ env.PACKAGE_VERSION }}" '.version = $version' composer.json > composer.json.tmp && mv composer.json.tmp composer.json
314339
echo "✅ Updated composer.json version field."
315340
316341
- name: Execute custom code before archive creation
342+
if: ${{ env.SKIP_BUILD != 'true' }}
317343
run: |
318344
${{ inputs.PRE_SCRIPT }}
319345
320346
- name: Compile assets
347+
if: ${{ env.SKIP_BUILD != 'true' }}
321348
run: npm run build
322349

323350
- name: Run WordPress Translation Downloader
324-
if: ${{ env.HAS_COMPOSER == 'true' }}
351+
if: ${{ env.SKIP_BUILD != 'true' && env.HAS_COMPOSER == 'true' }}
325352
run: composer wp-translation-downloader:download
326353

327354
- name: Run PHP-Scoper
328-
if: ${{ env.HAS_COMPOSER == 'true' && hashFiles('scoper.inc.php') != '' }}
355+
if: ${{ env.SKIP_BUILD != 'true' && env.HAS_COMPOSER == 'true' && hashFiles('scoper.inc.php') != '' }}
329356
# The sed call appends the Git commit SHA to the Composer autoload cache key to ensure unique identification for prefixed files.
330357
# This prevents Composer from skipping autoloaded files due to hash collisions based on relative paths.
331358
run: |
332359
php-scoper add-prefix --force --output-dir=build
333360
composer --working-dir=build dump-autoload -o
334-
sed -i "s/'__composer_autoload_files'/\'__composer_autoload_files_${{ github.sha }}'/g" "build/vendor/composer/autoload_real.php"
361+
sed -i "s/'__composer_autoload_files'/\'__composer_autoload_files_${{ github.sha }}'/g" "build/vendor/composer/autoload_real.php"
335362
rsync -av ./build/ . && rm -rf ./build/
336363
337364
- name: Apply .distignore file
338-
if: ${{ hashFiles('.distignore') != '' }}
365+
if: ${{ env.SKIP_BUILD != 'true' && hashFiles('.distignore') != '' }}
339366
# Configure git to (gracefully) use .distignore file during packaging instead of the regular .gitignore file.
340367
# Then clean up all files possibly mentioned in .distignore (e.g., source files).
341368
run: |
342369
find . -name ".gitignore" -delete
343-
git config core.excludesFile .distignore
370+
git config core.excludesFile .distignore
344371
git rm -rf --cached .
345372
git add .
346-
git clean -Xdf
373+
git clean -Xdf
347374
348375
- name: Git add, commit, and push
376+
if: ${{ env.SKIP_BUILD != 'true' }}
349377
run: |
350378
git add -A
351379
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."))
@@ -359,10 +387,10 @@ jobs:
359387
run: |
360388
# Create package directory for artifact
361389
mkdir -p "./artifact-staging/${{ env.PACKAGE_NAME }}"
362-
390+
363391
# Copy all files to the package directory (excluding .git)
364392
rsync -av --exclude='.git' --exclude='./artifact-staging' ./ "./artifact-staging/${{ env.PACKAGE_NAME }}/"
365-
393+
366394
echo "✅ Prepared artifact with package structure:"
367395
echo " Package name: ${{ env.PACKAGE_NAME }}"
368396
echo " Artifact structure: artifact-staging/${{ env.PACKAGE_NAME }}/"

docs/build-and-distribute.md

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@ This action can be used to build plugin and theme archives and push them to corr
55
To achieve that, the reusable workflow:
66

77
1. Inspects the origin branch and determines the correlating build branch (strips `dev/` prefix)
8-
2. Installs dependencies (including dev-dependencies) defined in `composer.json`
9-
3. Installs Node.js dependencies and compiles assets via `npm run build`
10-
4. Updates version information in plugin/theme headers and `package.json`
11-
5. Executes [WordPress Translation Downloader](https://github.com/inpsyde/wp-translation-downloader) if configured by the package
12-
6. Executes [PHP-Scoper](https://github.com/humbug/php-scoper) if configured by the package
13-
7. Applies `.distignore` file filtering if present
14-
8. Commits and pushes the build artifact to the determined build branch
15-
9. Uploads the build as a GitHub Actions artifact for download
8+
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))
9+
3. Installs dependencies (including dev-dependencies) defined in `composer.json`
10+
4. Installs Node.js dependencies and compiles assets via `npm run build`
11+
5. Updates version and SHA information in plugin/theme headers and `package.json`
12+
6. Executes [WordPress Translation Downloader](https://github.com/inpsyde/wp-translation-downloader) if configured by the package
13+
7. Executes [PHP-Scoper](https://github.com/humbug/php-scoper) if configured by the package
14+
8. Applies `.distignore` file filtering if present
15+
9. Commits and pushes the build artifact to the determined build branch
16+
10. Uploads the build as a GitHub Actions artifact for download
1617

1718
## Branch naming convention
1819

@@ -177,7 +178,7 @@ By default, the workflow strips the `dev/` prefix from the origin branch to dete
177178
- Ensure your `package.json` includes a `build` script for asset compilation
178179
- Use `.distignore` to exclude development files from the final build
179180
- 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
180-
- Use [concurrency settings](https://docs.github.com/en/actions/using-jobs/using-concurrency) to prevent conflicts when multiple pushes occur rapidly
181+
- 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
181182

182183
```yml
183184
name: Build and push assets
@@ -201,7 +202,7 @@ on:
201202
202203
concurrency:
203204
group: ${{ github.workflow }}-${{ github.ref }}
204-
cancel-in-progress: true
205+
cancel-in-progress: false
205206
206207
jobs:
207208
build-and-distribute:
@@ -249,7 +250,7 @@ This makes the workflow flexible enough to handle both full-stack WordPress proj
249250
The workflow handles version information for both plugins and themes:
250251

251252
- Updates `Version:` header in the main plugin file
252-
- Updates `SHA:` header with the current commit hash
253+
- 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
253254
- Updates version in `package.json` and `composer.json`
254255

255256
### Asset Compilation
@@ -272,6 +273,16 @@ If a `scoper.inc.php` file is present, the workflow will:
272273
2. Rebuild the autoloader for the scoped dependencies
273274
3. Ensure unique autoload cache keys to prevent conflicts
274275

276+
### Duplicate run detection
277+
278+
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.
279+
280+
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.
281+
282+
This is transparent to downstream jobs: the `artifact` output is always populated, whether the build was freshly compiled or reused.
283+
284+
**Applies to WordPress plugins and themes only.** Library projects do not embed a `SHA:` field, so they always rebuild.
285+
275286
### Distignore Support
276287

277288
If a `.distignore` file is present, the workflow will:

0 commit comments

Comments
 (0)