Skip to content

Update downloads tracking snapshot #12

Update downloads tracking snapshot

Update downloads tracking snapshot #12

name: Update Releases Properties
# This workflow runs in individual module repositories (e.g., module-php).
# It updates releases.properties when a PR is opened, a release is edited
# (files changed) or promoted to a full release, or run manually, and it always
# notifies Bearsampp's quickpick dispatcher via a repository_dispatch event.
#
# PR trigger: auto-detects the latest prerelease from the repo's releases.
# Manual trigger: requires release_tag and branch inputs; runs the full action.
# Release event behavior:
# - 'prereleased', or 'published' while still marked as a pre-release:
# full action that adds/updates the new version in releases.properties
# on the version PR's branch (then dispatches to quickpick).
# - 'edited' (files changed): full action that rechecks the file URL and
# updates the stored version/url when the file URL has changed, and
# purges any entries whose files were removed from the release.
# - 'released' (pre-release -> full "latest"): full action. NOTE: GitHub does
# not reliably deliver a 'released'/'edited' event when an existing
# release is converted to latest (known limitation), so the workflow's
# heuristics never RELY on it. The initial 'published' full action
# already keeps releases.properties current; use workflow_dispatch if a
# recombine is ever needed after a conversion.
# - 'deleted' (release removed, or its files removed): full action that
# PURGES the release's version/url entries from releases.properties,
# then dispatches so quickpick recombines without the removed version.
#
# TEST RE-RUN CONTRACT:
# Updating the PR branch (git push) is what makes the module test workflows
# re-run (via the PR "synchronize" event). Every full action — pre-release,
# edited, released, manual — pushes the PR branch only when releases.properties
# actually changed (has_changes == true). A new pre-release therefore adds its
# entry (triggering a test re-run); cosmetic edits (title/notes only) leave
# releases.properties unchanged and do NOT re-run the tests.
on:
pull_request:
types: [opened]
release:
# 'released' covers pre-release -> full release ("latest") conversions.
# 'prereleased' covers a newly created pre-release.
# 'deleted' triggers a purge of the release's version/url entries.
types: [published, prereleased, released, edited, deleted]
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag to process (e.g., php-2025.10.31)'
required: true
branch:
description: 'Branch to commit changes to'
required: true
concurrency:
group: update-releases-${{ github.head_ref || github.event.inputs.branch || github.event.release.target_commitish || 'default' }}
cancel-in-progress: false
jobs:
update-properties:
runs-on: ubuntu-26.04
# Full action for every trigger. A pre-release (`prereleased`, or `published`
# while still marked as a pre-release) updates releases.properties on the
# version PR's branch too, so branch module tests can download the new build.
if: |
!endsWith(github.repository, '.settings')
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
# ---------------------------------------------------------------------
# Resolve WHICH prerelease to read and WHICH branch to update.
#
# The module workflow is: create a version branch (e.g. 9.7.2) + PR,
# build + upload a prerelease (e.g. 2026.8.27), then merge. releases.properties
# must be updated ON that branch so branch module tests (mysql/mariadb-test)
# can download the new version. Ordering is not guaranteed: the PR may be
# opened before or after the prerelease is published, so both triggers are
# idempotent and defer gracefully instead of failing.
# ---------------------------------------------------------------------
- name: Resolve release tag and branch
id: resolve
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
BRANCH="${{ github.head_ref }}"
# Auto-detect the latest prerelease. If none exists yet (the
# prerelease-before-merge is usually created after the PR), do NOT
# fail — defer to the release event, which will update this branch.
RELEASE_TAG=$(curl -sf -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/releases?per_page=10" \
| python3 -c "
import sys, json
for r in json.load(sys.stdin):
if r.get('prerelease'):
print(r['tag_name']); break
")
if [[ -z "$RELEASE_TAG" ]]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "::notice::No prerelease published yet; the release event will update this branch once 2026.*.* is published."
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
exit 0
fi
elif [[ "${{ github.event_name }}" == "release" ]]; then
RELEASE_TAG="${{ github.event.release.tag_name }}"
# The release has the data but no branch. If a version PR is open,
# push the update to its branch so branch tests see the new version;
# otherwise fall back to main.
BRANCH=$(curl -sf -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/pulls?state=open&per_page=100" \
| python3 -c "
import sys, json
for pr in json.load(sys.stdin):
if pr['head']['ref'] != 'main':
print(pr['head']['ref']); break
")
BRANCH="${BRANCH:-main}"
else
RELEASE_TAG="${{ github.event.inputs.release_tag }}"
BRANCH="${{ github.event.inputs.branch }}"
fi
echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
echo "Release tag: $RELEASE_TAG"
echo "Branch: $BRANCH"
- name: Checkout repository
if: steps.resolve.outputs.skip != 'true'
uses: actions/checkout@v6
with:
token: ${{ secrets.GH_PAT }}
ref: ${{ steps.resolve.outputs.branch }}
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install dependencies
run: |
pip install requests packaging
- name: Extract module name from repository
id: extract_module
run: |
# Extract module name from repo name (e.g., "module-php" -> "php")
REPO_NAME="${{ github.event.repository.name }}"
echo "Repository name: ${REPO_NAME}"
# Remove "module-" prefix (case-insensitive)
if [[ "${REPO_NAME}" == module-* ]]; then
MODULE_NAME="${REPO_NAME#module-}"
elif [[ "${REPO_NAME}" == Module-* ]]; then
MODULE_NAME="${REPO_NAME#Module-}"
else
echo "ERROR: Repository name does not start with 'module-'"
echo "Expected format: module-MODULENAME (e.g., module-php)"
exit 1
fi
# Convert to lowercase
MODULE_NAME=$(echo "${MODULE_NAME}" | tr '[:upper:]' '[:lower:]')
echo "module_name=${MODULE_NAME}" >> $GITHUB_OUTPUT
echo "Module name: ${MODULE_NAME}"
- name: Update releases properties
if: steps.resolve.outputs.skip != 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
MODULE_NAME: ${{ steps.extract_module.outputs.module_name }}
EVENT_NAME: ${{ github.event_name }}
RELEASE_ACTION: ${{ github.event.action }}
RELEASE_RAW_TAG: ${{ github.event.release.tag_name }}
run: |
python << 'EOF'
import os
import re
import requests
from packaging import version
from collections import OrderedDict
# Get environment variables
release_tag = os.environ['RELEASE_TAG']
repo_owner = os.environ['REPO_OWNER']
repo_name = os.environ['REPO_NAME']
module_name = os.environ['MODULE_NAME']
github_token = os.environ['GITHUB_TOKEN']
event_name = os.environ.get('EVENT_NAME', '')
release_action = os.environ.get('RELEASE_ACTION', '')
raw_release_tag = os.environ.get('RELEASE_RAW_TAG', '')
is_delete = event_name == 'release' and release_action == 'deleted'
# Strip module name prefix from tag if present (e.g., "postgresql-2025.11.22" -> "2025.11.22")
if release_tag.lower().startswith(f"{module_name.lower()}-"):
original_tag = release_tag
release_tag = release_tag[len(module_name)+1:]
print(f"Stripped module prefix from tag: {original_tag} -> {release_tag}")
print(f"Module name: {module_name}")
# Read existing properties file
properties_file = "releases.properties"
if not os.path.exists(properties_file):
print(f"Properties file not found: {properties_file}")
if is_delete:
print(f"Nothing to purge (properties file missing)")
exit(0)
print(f"Creating new properties file...")
with open(properties_file, 'w', encoding='utf-8') as f:
f.write(f"# {module_name.upper()} Releases Properties\n")
f.write(f"# Auto-generated and maintained by automation\n\n")
with open(properties_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Parse existing properties
properties = OrderedDict()
header_lines = []
in_header = True
for line in lines:
stripped = line.strip()
if in_header and (stripped.startswith('#') or stripped == ''):
header_lines.append(line)
else:
in_header = False
if '=' in line and not stripped.startswith('#'):
key, value = line.split('=', 1)
properties[key.strip()] = value.strip()
original_release_tag = release_tag
match_tags = {raw_release_tag} if raw_release_tag else set()
if original_release_tag:
match_tags.add(original_release_tag)
if release_tag and release_tag != original_release_tag:
match_tags.add(release_tag)
def purge_release_entries():
# Every stored URL embeds the release tag in the download path
# (e.g. .../releases/download/2026.9.19/...), so matching on that
# removes the version/url pair(s) that came from this release.
removed = []
for key, url in list(properties.items()):
if any(tag and f"/releases/download/{tag}/" in url for tag in match_tags):
print(f"Removing: {key} = {url}")
del properties[key]
removed.append(key)
return removed
if is_delete:
# The release itself was deleted (tag no longer exists): purge
# every stored entry that shipped from it.
print(f"Release deleted: {raw_release_tag} — purging its entries from releases.properties")
purged = purge_release_entries()
if purged:
print(f"Purged {len(purged)} entries for deleted release")
else:
print(f"No stored entries referenced the deleted release tag")
else:
print(f"Processing release: {release_tag}")
# Construct API URL
api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/tags/{release_tag}"
headers = {
'Authorization': f'token {github_token}',
'Accept': 'application/vnd.github.v3+json'
}
print(f"Fetching release information from: {api_url}")
# Fetch release data
response = requests.get(api_url, headers=headers)
response.raise_for_status()
release_data = response.json()
# Extract assets that end with .7z only
assets = []
# Use bearsampp-module format as the filename prefix (e.g., "bearsampp-postgresql")
filename_prefix = f"bearsampp-{module_name}"
print(f"Looking for .7z files with prefix: {filename_prefix}")
print(f"Available assets:")
for asset in release_data.get('assets', []):
print(f" - {asset['name']}")
for asset in release_data.get('assets', []):
filename = asset['name']
# Check if file ends with .7z or .7z.exe
if filename.endswith('.7z') or filename.endswith('.7z.exe'):
download_url = asset['browser_download_url']
version_match = None
# All modules use bearsampp-prefix format (e.g., "bearsampp-apache-2.4.66-2025.12.8.7z" -> "2.4.66")
# Optional (?:-1)? handles revision-style versions like "bearsampp-ruby-4.0.6-1-2026.8.9.7z" -> "4.0.6-1".
# Arbitrary build counters (e.g. Apache "-260617") are NOT captured as part of the version.
# Optional (?:-?(?:RC|beta|alpha|dev)\d*)? preserves prerelease suffixes in the key,
# both hyphenated (e.g., "bearsampp-mariadb-13.0.1-RC-2026.8.23.7z" -> "13.0.1-RC")
# and no-separator (e.g., "bearsampp-php-8.6.0beta3-2026.9.13.7z" -> "8.6.0beta3").
# Legacy: also handle PortableGit format for older Git releases
version_match = re.search(
rf'{filename_prefix}-(\d+\.\d+(?:\.\d+)?(?:\.\d+)?(?:-1)?(?:-?(?:RC|beta|alpha|dev)\d*)?)(?:-\d+)?(?=-20\d{{2}}\.\d{{1,2}}\.\d{{1,2}}|\.7z|$)',
filename,
re.IGNORECASE
)
if not version_match and module_name.lower() == 'git':
# Fallback: legacy PortableGit format (e.g., "PortableGit-2.55.0.2-64-bit.7z.exe" -> "2.55.0.2")
version_match = re.search(
r'PortableGit-(\d+\.\d+(?:\.\d+)*)(?=-(?:\d+-)?bit|\.7z)',
filename,
re.IGNORECASE
)
if version_match:
ver = version_match.group(1)
assets.append({
'version': ver,
'url': download_url,
'filename': filename
})
print(f"Found: {filename} -> Version: {ver}")
else:
print(f"Warning: Could not extract version from: {filename}")
# Debug: print the filename for troubleshooting
print(f" Filename: {filename}")
if module_name.lower() == 'git':
print(f" Expected PortableGit format")
if not assets:
if release_action == 'edited':
# The release still exists but its files were removed
# (e.g. the tag remains but the .7z assets are gone).
print(f"Release edited: no .7z files remain (files removed) — purging its entries from releases.properties")
purged = purge_release_entries()
if purged:
print(f"Purged {len(purged)} entries for the removed files")
else:
print(f"No stored entries referenced the removed files")
else:
print(f"No .7z assets found in release")
exit(0)
# Fix malformed version entries by re-extracting correct versions from URLs
# This handles cases like "2025.7.2.7" or "18.1-2025" that should be "18.1"
# NOTE: Existing entries are never removed — releases.properties is the complete history.
# Stale/404 URLs are kept because the asset may still exist under a different URL
# (e.g. re-hosted under a newer release or on the website's bin/archived/ path).
fixed_properties = OrderedDict()
for key, url in properties.items():
# Try to extract the correct version from the URL
# URL format: .../bearsampp-apache-2.4.66-2025.12.8.7z (for apache)
# URL format: .../bearsampp-postgresql-18.1-2025.12.8.7z (for other modules)
# URL format: .../PortableGit-2.55.0.2-64-bit.7z.exe (for Git)
url_version_match = re.search(
rf'{filename_prefix}-(\d+\.\d+(?:\.\d+)?(?:\.\d+)?(?:-1)?(?:-?(?:RC|beta|alpha|dev)\d*)?)(?:-\d+)?(?=-20\d{{2}}\.\d{{1,2}}\.\d{{1,2}}|\.7z|/|$)',
url,
re.IGNORECASE
)
if not url_version_match and module_name.lower() == 'git':
url_version_match = re.search(
r'PortableGit-(\d+\.\d+(?:\.\d+)*)(?=-(?:\d+-)?bit|\.7z)',
url,
re.IGNORECASE
)
if url_version_match:
correct_version = url_version_match.group(1)
# If the key is malformed (date-like or has trailing year or has -64 suffix), fix it
if re.match(r'^20\d{2}\.', key) or re.search(r'-20\d{2}$', key) or re.search(r'-\d+-bit$', key, re.IGNORECASE):
print(f"Fixing malformed entry: {key} -> {correct_version}")
fixed_properties[correct_version] = url
else:
# Keep valid entries as-is
fixed_properties[key] = url
else:
# If we can't extract version from URL, keep the original entry
fixed_properties[key] = url
properties = fixed_properties
# Repair stale URLs by searching all releases for replacement assets.
# Old releases sometimes have their assets deleted/reorganized (e.g. assets
# moved from 2022.12.03 to 2024.4.20). We search all releases to find a
# working URL for any version whose current URL returns 404.
print(f"\nChecking for stale URLs and searching for replacements...")
version_url_map = {}
page = 1
while True:
releases_api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases?per_page=100&page={page}"
releases_response = requests.get(releases_api_url, headers=headers)
releases_response.raise_for_status()
releases_page = releases_response.json()
if not releases_page:
break
for release in releases_page:
for asset in release.get('assets', []):
asset_name = asset['name']
if not (asset_name.endswith('.7z') or asset_name.endswith('.7z.exe')):
continue
m = re.search(
rf'{filename_prefix}-(\d+\.\d+(?:\.\d+)?(?:\.\d+)?(?:-1)?(?:-?(?:RC|beta|alpha|dev)\d*)?)(?:-\d+)?(?=-20\d{{2}}\.\d{{1,2}}\.\d{{1,2}}|\.7z|$)',
asset_name, re.IGNORECASE
)
if not m and module_name.lower() == 'git':
m = re.search(r'PortableGit-(\d+\.\d+(?:\.\d+)*)(?=-(?:\d+-)?bit|\.7z)', asset_name, re.IGNORECASE)
if m:
ver = m.group(1)
if ver not in version_url_map:
version_url_map[ver] = asset['browser_download_url']
if len(releases_page) < 100:
break
page += 1
print(f"Found {len(version_url_map)} versions across all releases")
repaired_entries = 0
for key, url in list(properties.items()):
try:
response = requests.head(url, timeout=10, allow_redirects=True)
if response.status_code == 404 and key in version_url_map:
new_url = version_url_map[key]
if new_url != url:
print(f"Repairing stale URL: {key}")
print(f" Old: {url}")
print(f" New: {new_url}")
properties[key] = new_url
repaired_entries += 1
except requests.exceptions.RequestException:
pass
if repaired_entries > 0:
print(f"\nRepaired {repaired_entries} stale URLs")
else:
print(f"\nNo stale URLs needed repair")
# Add/update the versions from this release. On an 'edited' release
# this rechecks the file URL: the stored pair is updated whenever the URL changed.
for asset in assets:
ver = asset['version']
url = asset['url']
# Create property key (just version number)
key = ver
if key in properties:
if properties[key] == url:
print(f"Skipped (URL unchanged): {key} = {url}")
else:
print(f"Updated URL: {key}")
print(f" Old: {properties[key]}")
print(f" New: {url}")
properties[key] = url
else:
properties[key] = url
print(f"Added: {key} = {url}")
# Reconcile files removed from THIS release: drop any stored entry
# that still points at this release's download path but whose version
# is no longer among the release's current assets.
asset_versions = {a['version'] for a in assets}
removed_files = []
for key, url in list(properties.items()):
if any(tag and f"/releases/download/{tag}/" in url for tag in match_tags):
if key not in asset_versions:
print(f"Removing (file removed from release): {key} = {url}")
del properties[key]
removed_files.append(key)
if removed_files:
print(f"Removed {len(removed_files)} entries whose files were removed from the release")
# Sort properties by version (semver)
def extract_version(key):
# Parse version directly from key
try:
return version.parse(key)
except:
return version.parse("0.0.0")
sorted_properties = OrderedDict(
sorted(properties.items(), key=lambda x: extract_version(x[0]), reverse=True)
)
# Write back to file
with open(properties_file, 'w', encoding='utf-8') as f:
# Write header
for line in header_lines:
f.write(line)
# Write sorted properties with spaces around equals sign
for key, value in sorted_properties.items():
f.write(f"{key} = {value}\n")
print(f"\nSuccessfully updated {properties_file}")
print(f"Total versions: {len(sorted_properties)}")
EOF
- name: Check for changes
id: check_changes
if: steps.resolve.outputs.skip != 'true'
run: |
if git diff --quiet releases.properties 2>/dev/null; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "No changes to releases.properties"
else
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "Changes detected in releases.properties"
fi
- name: Commit and push changes
# Commit on every event that produced changes. Pushes to the resolved
# branch (the version PR branch) so branch tests see the new entry, and
# mirrors to main so main stays consistent for the final release.
if: >
steps.resolve.outputs.skip != 'true' &&
steps.check_changes.outputs.has_changes == 'true'
env:
RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }}
BRANCH: ${{ steps.resolve.outputs.branch }}
GH_TOKEN: ${{ secrets.GH_PAT }}
run: |
git config user.name "Bearsampp Bot"
git config user.email "bot@bearsampp.com"
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
git add releases.properties
git commit -m "Update releases.properties
Auto-generated from release ${RELEASE_TAG}"
git push origin HEAD:${BRANCH}
# Also update main branch directly without a PR
if [[ "${BRANCH}" != "main" ]]; then
COMMIT_SHA=$(git rev-parse HEAD)
git fetch origin main
git checkout main
if git cherry-pick --no-edit $COMMIT_SHA; then
git push origin main
else
echo "::warning::Cherry-pick to main failed, skipping main sync"
git cherry-pick --abort 2>/dev/null || true
fi
git checkout ${BRANCH} || true
fi
dispatch:
runs-on: ubuntu-26.04
needs: [update-properties]
# Quickpick dispatcher. Fires on EVERY non-PR trigger, including release
# deletion (a purged releases.properties must recombine so the removed
# version disappears from the combined release) and triggers where
# releases.properties did NOT change (commit/push skipped but the job still
# succeeds) — quickpick combine is idempotent and must re-run so the
# combined release reflects current data. Only a hard failure of the
# properties job suppresses the dispatch.
if: |
always() &&
github.event_name != 'pull_request' &&
!endsWith(github.repository, '.settings') &&
needs.update-properties.result != 'failure'
steps:
- name: Invoke workflow in another repo (Quickpick Dispatcher)
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.Gh_PAT }}
repository: Bearsampp/Bearsampp
event-type: module_release_created
client-payload: '{"workflow": "combine_releases.yml"}'