Skip to content

fix: atomic commits for multi-stack dependency builds#608

Open
ivanovac wants to merge 10 commits into
masterfrom
fix-multi-stack-atomic-commits-clean
Open

fix: atomic commits for multi-stack dependency builds#608
ivanovac wants to merge 10 commits into
masterfrom
fix-multi-stack-atomic-commits-clean

Conversation

@ivanovac

@ivanovac ivanovac commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR fixes the issue where multi-stack dependency builds (e.g., cflinuxfs4 + cflinuxfs5) would only create buildpack PRs with ONE stack instead of both. The root cause was actually two separate bugs that needed fixing.

Problem

When a new dependency version is built for multiple stacks, the resulting buildpack PR should include entries for ALL stacks, but it was only including one.

Evidence: Openresty 1.29.2.3 was built for both cflinuxfs4 and cflinuxfs5, but the update job created nginx-buildpack PR #403 with only cflinuxfs5.

Root Causes

Bug #1: Non-Atomic Commits (Fixed by merge-build-metadata task)

Original behavior: Each parallel build task independently committed to the builds git repo:

  1. cflinuxfs4 task: writes JSON → commits → pushes (Commit A)
  2. cflinuxfs5 task: writes JSON → commits → pushes (Commit B)
  3. Update job: fetches builds repo → gets one commit → only sees ONE JSON file

Git history example:

80eb1d4726 Build openresty - 1.29.2.3 - cflinuxfs5
6cba8ac82a Build openresty - 1.29.2.3 - cflinuxfs4

Fix: Added merge-build-metadata task that creates a single atomic commit after all stacks complete:

fa3a82154a Build openresty - 1.29.2.3 - cflinuxfs4,cflinuxfs5

Bug #2: 4-Part Version Comparison Failure (Fixed in dependencies.rb)

The real bug discovered during testing: Even with atomic commits working correctly, the first stack (cflinuxfs4) still wasn't being added to the manifest!

Root cause: Openresty uses 4-part versions (e.g., 1.29.2.3, 1.29.2.1), but the SemVer library only supports 3-part versions (major.minor.patch). When latest?() compared versions:

# Both versions parsed as 1.29.2 (4th part ignored!)
new_ver = SemVer.parse("1.29.2.3")  # => 1.29.2
old_ver = SemVer.parse("1.29.2.1")  # => 1.29.2
new_ver > old_ver  # => false (should be true!)

This caused the script to think 1.29.2.3 was NOT newer than 1.29.2.1, so it refused to add the cflinuxfs4 entry.

Debug output that revealed the bug:

DEBUG latest?: Comparing 1.29.2.3 (@patch=2) vs 1.29.2.1 (@patch=2) for stacks ["cflinuxfs4"]
DEBUG latest?: v1.29.2 > v1.29.2 = false
DEBUG Dependencies.switch: Not adding (not latest)

Fix: Added fallback to Gem::Version which correctly handles arbitrary version part counts:

if new_ver.nil? || old_ver.nil? || (new_ver == old_ver && @dep['version'] != d['version'])
  new_ver_gem = Gem::Version.new(@dep['version'])  # => 1.29.2.3
  old_ver_gem = Gem::Version.new(d['version'])     # => 1.29.2.1
  next new_ver_gem > old_ver_gem  # => true ✓
end

Solution

1. Add SKIP_INDIVIDUAL_COMMIT flag to build-binary task

  • When set to true, build tasks skip individual git commits
  • Preserves existing SKIP_COMMIT flag for parity tests

2. Create merge-build-metadata task

  • Accepts multiple stack metadata inputs
  • Merges all JSON files into single directory
  • Creates one atomic commit: Build <dep> - <version> - <stack1>,<stack2>

3. Fix version comparison in dependencies.rb

  • Added Gem::Version fallback in latest?() method
  • Handles 4-part versions (and any number of parts) correctly
  • Only uses fallback when SemVer fails or produces incorrect results

4. Restructure dependency-builds pipeline

Multi-stack build jobs now follow this pattern:

- in_parallel:
  - task: build-binary-cflinuxfs4
    params: {SKIP_INDIVIDUAL_COMMIT: "true"}
  - task: build-binary-cflinuxfs5
    params: {SKIP_INDIVIDUAL_COMMIT: "true"}
- in_parallel:
  - put: s3 (cflinuxfs4)
  - put: s3 (cflinuxfs5)
- task: merge-build-metadata
- put: builds (single atomic commit)

5. Update binary-builder resource

Testing Results

✅ Build Job (build-openresty-1.29.x #16)

  • Both stacks built successfully in parallel
  • Merge task created atomic commit: fa3a82154a Build openresty - 1.29.2.3 - cflinuxfs4,cflinuxfs5
  • Builds repo contains both JSON files:
    • binary-builds-new/openresty/1.29.2.3-cflinuxfs4.json
    • binary-builds-new/openresty/1.29.2.3-cflinuxfs5.json

✅ Update Job (update-openresty-1.29.x-nginx #17 - after fixes)

  • Script found both JSON files
  • Applied Gem::Version fallback for 4-part version comparison
  • Created PR with BOTH stacks in manifest:
- name: openresty
  version: 1.29.2.3
  uri: https://buildpacks.cloudfoundry.org/dependencies/openresty/openresty_1.29.2.3_linux_x64_cflinuxfs4_b722993f.tgz
  sha256: b722993f3363b64db8a2a0216fb5ef12bc18d617bf5b01849ea8d27f269aea03
  cf_stacks:
  - cflinuxfs4
  source: http://openresty.org/download/openresty-1.29.2.3.tar.gz
  source_sha256: 315e49fa4568747fec4bdada9614d2ba287e7aed4b3430d7ea25685e24cc43ff
- name: openresty
  version: 1.29.2.3
  uri: https://buildpacks.cloudfoundry.org/dependencies/openresty/openresty_1.29.2.3_linux_x64_cflinuxfs5_906093c9.tgz
  sha256: 906093c930e81127ae098293f94300c10be41d89864584eb4c8ece2edffec617
  cf_stacks:
  - cflinuxfs5
  source: http://openresty.org/download/openresty-1.29.2.3.tar.gz
  source_sha256: 315e49fa4568747fec4bdada9614d2ba287e7aed4b3430d7ea25685e24cc43ff

SUCCESS! ✅ Both stacks are now included with their respective URLs and SHA256 hashes.

Files Changed

New files:

  • tasks/merge-build-metadata/task.yml - Concourse task definition
  • tasks/merge-build-metadata/merge.sh - Bash script that merges JSON files and commits atomically

Modified files:

  • tasks/build-binary/build.sh - Add SKIP_INDIVIDUAL_COMMIT flag (lines 151-156)
  • tasks/update-buildpack-dependency/run.rb - Restructured to collect all stacks before processing
  • tasks/update-buildpack-dependency/dependencies.rb - Fix latest?() to handle 4-part versions
  • pipelines/dependency-builds/pipeline.yml - Restructure multi-stack jobs + update binary-builder branch

Scope

Applies to: All multi-stack dependency builds in the pipeline (currently only nginx/openresty uses multi-stack builds)

Does NOT affect:

  • Any-stack builds (unchanged - single commit as before)
  • Single-stack builds (would use merge task pattern if converted to multi-stack in future)
  • Parity test builds (SKIP_COMMIT flag preserved separately)

Post-Merge Tasks

After merging this PR:

  1. Update pipelines/dependency-builds/pipeline.yml to point buildpacks-ci resource back to branch: master (currently temporarily pointing to PR branch for testing)
  2. Re-render and apply pipeline to Concourse
  3. Monitor next scheduled openresty build to verify it works on master branch

Related Issues

This resolves the ongoing issue where buildpack PRs for multi-stack dependencies only included one stack, requiring manual intervention to add the missing stack entries.

ivanovac added 2 commits April 3, 2026 16:48
This commit fixes the race condition where multi-stack dependency builds
(e.g., cflinuxfs4 + cflinuxfs5) would create separate git commits per stack,
causing the update job to only see one stack's metadata file and generate
incomplete buildpack PRs.

Changes:
- Add SKIP_INDIVIDUAL_COMMIT flag to build.sh to skip per-stack commits
- Create merge-build-metadata task to atomically commit all stacks together
- Restructure dependency-builds pipeline to:
  1. Build all stacks in parallel with SKIP_INDIVIDUAL_COMMIT=true
  2. Upload artifacts to S3 in parallel
  3. Merge metadata and create single atomic commit with format:
     'Build <dep> - <version> - <stack1>,<stack2>'
- Preserve existing behavior for any-stack and single-stack builds
- Keep SKIP_COMMIT flag intact for parity tests

Root cause: Each parallel build task independently committed to the builds
git repo, creating separate commits. The update job would fetch at a single
commit SHA and only see one stack's JSON file.

Solution: Skip individual commits during parallel builds, then merge all
metadata files and commit atomically in a single step after builds complete.

This ensures buildpack PRs include all built stacks in the manifest.
PR #97 (Go rewrite) was merged to main branch in binary-builder repo.
The go-binary-builder-cflinuxfs4-parity branch no longer exists.
ivanovac added 8 commits April 3, 2026 17:41
The merge.sh script was not properly staging changes after rsync,
causing 'unstaged changes' errors when the git resource tried to
rebase and push.

Now the script unconditionally stages all changes with 'git add'
before checking if there's anything to commit.
…uilds

When building for multiple stacks (cflinuxfs4 + cflinuxfs5), each stack
produces a unique binary with its own URL and SHA256. The manifest must
contain separate entries for each stack.

Previous behavior: The loop called Dependencies.switch() on each iteration,
causing the second stack to replace the first stack's entry.

New behavior:
- For any-stack builds: Create ONE entry with all stacks (same URL/SHA256)
- For multi-stack builds: Create SEPARATE entries per stack (different URLs/SHA256s)

This ensures buildpack PRs include all built stacks, not just the last one processed.
Openresty uses 4-part versions (e.g., 1.29.2.3), but SemVer only supports
3-part versions. When SemVer parses "1.29.2.3" and "1.29.2.1", both
become "1.29.2" (ignoring the 4th part), causing them to be equal.

This prevented the first stack (cflinuxfs4) from being added because
latest?() incorrectly returned false.

Solution: Fallback to Gem::Version for comparison when:
- SemVer parsing fails, OR
- SemVer makes different versions appear equal

Gem::Version handles arbitrary version part counts correctly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant