1
0

feat: add proper version detection for repos with multiple immutable URLs for different mods (#290)

Reopening after messing up #288.

## Motivation

The current version detection logic assumes that mods using
`automatic-version-check` either:
1. Use GitHub's automatic latest release URL
(`releases/latest/download/mod.zip`)
2. Link directly to repository HEAD (`archive/refs/heads/main.zip`)

However, some mod authors, like me, use **permanent release tag URLs**
like `releases/download/mod-name__latest/mod.zip` to provide stable
download links while still updating the underlying release. This pattern
is particularly common in **monorepos** where multiple mods share a
single repository.

## Problem

When a mod uses a permanent tag URL, the current script queries the
repository's overall latest release, which fundamentally diverge from
how releases in a monorepo work. This causes issues where:

- **All mods get the same version**: Every mod gets versioned as
whatever release was created most recently across the entire repository
- **False update notifications**: BMM detects "updates" when unrelated
mods are released
- **Broken update detection**: Actual updates to specific mods may not
be detected

## Solution

This PR adds support for permanent release tag URLs with minimal changes
by:

1. **Detecting the pattern**: URLs matching
`releases/download/{tag}/{asset}` or `releases/tag/{tag}` (excluding
`releases/latest/`)
2. **Querying specific tags**: Instead of `/releases/latest`, query
`/releases/tags/{specific-tag}`
3. **Using asset timestamps**: Generate versions based on the most
recent asset's `created_at` timestamp, this avoids generating updates
for simple renames in the asset.
4. **Maintaining compatibility**: All existing URL patterns continue to
work unchanged

### Key Design Decisions

- **Asset-agnostic**: Uses the most recently created asset instead of
searching for specific filenames, keeping it simple and efficient
- **Timestamp-based versioning**: Format `YYYYMMDD_HHMMSS` provides
human-readable, sequential versions

## Benefits

- **Enables monorepo workflows**: Authors can maintain multiple mods in
one repository with reliable update detection by updating specific tags
assets
- **Broader ecosystem support**: Works with any permanent tag naming
convention
- **Zero breaking changes**: Existing single-mod repositories continue
working identically

## Example

**Before**: All mods in `/balatro-mods` get version
`rebalanced-stakes__v1.2.8` (latest across entire repo)

**After**: 
- `qol-bundle` gets version `20240115_103042` (when `qol-bundle__latest`
tag has new asset)
- `rebalanced-stakes` gets version `20240116_140521` (when
`rebalanced-stakes__latest` tag has new asset)

This enables reliable update detection for each mod independently.

## Considerations
The `update_mod_versions` script currently uses the `created_at`
timestamp of the most recent asset to generate versions. This approach
is intentionally flexible and works with various ways of updating fixed
release tags, but it may be worth considering more opinionated
approaches in the future. For example, we could require semantic
versioning in release titles or extract changelogs from release bodies,
which would open up possibilities for additional features.

This implementation can also be extended to latest-release URLs with
little effort, since they just redirect to a specific release tag in the
API. Any future metadata extraction features could apply to both URL
patterns.

For now, this PR focuses on providing immediate compatibility with
minimal friction in the existing environment, while establishing a
foundation that can be built upon later.
This commit is contained in:
Breezebuilder
2025-07-08 12:59:54 +10:00
committed by GitHub
7 changed files with 146 additions and 13 deletions

View File

@@ -27,14 +27,22 @@ def extract_repo_info(repo_url):
return None, None
VersionSource = Enum("VersionSource", [
("RELEASE_TAG", "release"),
("LATEST_TAG", "release"),
("HEAD", "commit"),
("SPECIFIC_TAG", "specific_tag"),
])
def get_version_string(source: Enum, owner, repo, start_timestamp, n = 1):
def get_version_string(source: Enum, owner, repo, start_timestamp, n = 1, tag_data=None):
"""Get the version string from a given GitHub repo."""
if source is VersionSource.RELEASE_TAG:
if source is VersionSource.LATEST_TAG:
url = f'https://api.github.com/repos/{owner}/{repo}/releases/latest'
elif source is VersionSource.SPECIFIC_TAG:
if not tag_data:
print(f"ERROR: SPECIFIC_TAG source requires tag_name")
return None
tag_name = tag_data['name']
url = f'https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag_name}'
else:
if not source is VersionSource.HEAD:
print(f"UNIMPLEMENTED(VersionSource): `{source}`,\nfalling back to `HEAD`")
@@ -58,10 +66,33 @@ def get_version_string(source: Enum, owner, repo, start_timestamp, n = 1):
if response.status_code == 200:
data = response.json()
if source is VersionSource.RELEASE_TAG:
if source is VersionSource.LATEST_TAG:
# Return name of latest tag
return data.get('tag_name')
elif source is VersionSource.SPECIFIC_TAG:
assets = data.get('assets', [])
if not assets:
print(f"⚠️ No assets found in release {tag_name}")
return None
latest_created_at = ""
latest_asset = None
for asset in assets:
created_at = asset.get('created_at', '')
if created_at > latest_created_at:
latest_created_at = created_at
latest_asset = asset['name']
# Convert 2099-12-31T01:02:03Z to 20991231_010203
parts = latest_created_at.replace('Z', '').split('T')
date_part = parts[0].replace('-', '') # 20991231
time_part = parts[1].replace(':', '') # 010203
version = f"{date_part}_{time_part}" # 20991231_010203
tag_data['file'] = latest_asset
return version
if data and len(data) > 0:
# Return shortened commit hash (first 7 characters)
return data[0]['sha'][:7]
@@ -98,7 +129,7 @@ def get_version_string(source: Enum, owner, repo, start_timestamp, n = 1):
print(f"Waiting {wait_time} seconds until next attempt...")
time.sleep(wait_time)
n += 1
return get_version_string(source, owner, repo, start_timestamp, n) # Retry
return get_version_string(source, owner, repo, start_timestamp, n, tag_data=tag_data) # Retry
else:
print(f"Next attempt in {wait_time} seconds, but Action run time would exceed 1800 seconds - Stopping...")
sys.exit(1)
@@ -165,10 +196,22 @@ def process_mod(start_timestamp, name, meta_file):
print("Download URL links to HEAD, checking latest commit...")
source = VersionSource.HEAD
new_version = get_version_string(VersionSource.HEAD, owner, repo, start_timestamp)
elif (meta.get('fixed-release-tag-updates') == True) and "/releases/download/" in download_url:
source = VersionSource.SPECIFIC_TAG
tag_data = {}
# Pattern: /releases/download/{tag}/{file} - tag is second-to-last
print("Download URL links to specific release asset, checking that asset's tag...")
tag_name = download_url.split('/')[-2]
print(f"Checking release tag: {tag_name}")
tag_data['name'] = tag_name
new_version = get_version_string(
source, owner, repo, start_timestamp, tag_data=tag_data
)
else:
print("Checking releases for latest version tag...")
source = VersionSource.RELEASE_TAG
source = VersionSource.LATEST_TAG
new_version = get_version_string(source, owner, repo, start_timestamp)
if not new_version:
@@ -192,6 +235,8 @@ def process_mod(start_timestamp, name, meta_file):
meta['version'] = new_version
if "/archive/refs/tags/" in download_url:
meta['downloadURL'] = f"{repo_url}/archive/refs/tags/{meta['version']}.zip"
elif source == VersionSource.SPECIFIC_TAG:
meta['downloadURL'] = f"{repo_url}/releases/download/{tag_data['name']}/{tag_data['file']}"
with open(meta_file, 'w', encoding='utf-8') as f:
# Preserve formatting with indentation
@@ -241,4 +286,3 @@ if __name__ == "__main__":
# Exit with status code 0 even if no updates were made
sys.exit(0)

View File

@@ -121,10 +121,11 @@ jobs:
- name: Validate meta.json Against Schema
if: always() && steps.find-changed-mods.outputs.changed_mods_found == 'true'
uses: dsanders11/json-schema-validate-action@v1.2.0
uses: dsanders11/json-schema-validate-action@v1.4.0
with:
schema: "./schema/meta.schema.json"
files: ${{ steps.find-changed-mods.outputs.meta_json_files }}
custom-errors: true
- name: Validate Download URLs
if: always() && steps.find-changed-mods.outputs.changed_mods_found == 'true'

View File

@@ -52,8 +52,15 @@ This file stores essential metadata in JSON format. **Make sure you adhere to th
- **version**: The version number of the mod files available at `downloadURL`.
- *folderName*: (*Optional*) The name for the mod's install folder. This must be **unique**, and cannot contain characters `<` `>` `:` `"` `/` `\` `|` `?` `*`
- *automatic-version-check*: (*Optional* but **recommended**) Set to `true` to let the Index automatically update the `version` field.
- Updates happen once every hour, by checking either your mod's latest Release **or** latest commit, depending on the `downloadURL`.
- Enable this option **only** if your `downloadURL` points to an automatically updating source, using a link to [releases/latest](https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases) (recommended), or a link to the [latest commit (HEAD)](https://docs.github.com/en/repositories/working-with-files/using-files/downloading-source-code-archives#source-code-archive-urls).
- Updates happen once every hour, by checking either your mod's latest Release, latest commit, or specific release tag, depending on the `downloadURL`.
- Enable this option **only** if your `downloadURL` points to an automatically updating source:
- **Latest release** (recommended): Using a link to [releases/latest](https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases)
- **Latest commit**: Using a link to the [latest commit (HEAD)](https://docs.github.com/en/repositories/working-with-files/using-files/downloading-source-code-archives#source-code-archive-urls)
- *fixed-release-tag-updates*: (*Optional*) Set to `true` if your mod uses a fixed release tag and still wants to auto-update when modifying the underlying files. This can be useful for repositories with multiple mods, allowing you to have a release tag dedicated for each mod where you upload new versions. Note that:
- Requires `automatic-version-check` to also be set to `true`.
- The `downloadURL` must point to a specific release asset using a link such as `https://github.com/author/repo/releases/download/my-release-tag/mod.zip`.
### 3. thumbnail.jpg (Optional)
If included, this image will appear alongside your mod in the index. Maximum and recommended size is 1920x1080 pixels.

View File

@@ -10,5 +10,6 @@
"downloadURL": "https://github.com/gfreitash/balatro-mods/releases/download/black-seal__latest/black-seal.zip",
"folderName": "black-seal",
"automatic-version-check": true,
"version": "black-seal__v3.2.9"
"fixed-release-tag-updates": true,
"version": "20250624_015936"
}

View File

@@ -10,5 +10,6 @@
"downloadURL": "https://github.com/gfreitash/balatro-mods/releases/download/qol-bundle__latest/qol-bundle.zip",
"folderName": "qol-bundle",
"automatic-version-check": true,
"version": "black-seal__v3.2.9"
"fixed-release-tag-updates": true,
"version": "20250624_015935"
}

View File

@@ -11,5 +11,6 @@
"downloadURL": "https://github.com/gfreitash/balatro-mods/releases/download/rebalanced-stakes__latest/rebalanced-stakes.zip",
"folderName": "rebalanced-stakes",
"automatic-version-check": true,
"version": "black-seal__v3.2.9"
"fixed-release-tag-updates": true,
"version": "20250618_035311"
}

View File

@@ -49,6 +49,9 @@
},
"automatic-version-check": {
"type": "boolean"
},
"fixed-release-tag-updates": {
"type": "boolean"
}
},
"required": [
@@ -60,5 +63,80 @@
"repo",
"downloadURL",
"version"
],
"allOf": [
{
"$comment": "This rule prevents accidental freezing of updates",
"not": {
"allOf": [
{
"$comment": "'automatic-version-check' is true",
"properties": {
"automatic-version-check": {
"const": true
}
},
"required": [
"automatic-version-check"
]
},
{
"$comment": "'downloadURL' points to a specific release asset",
"properties": {
"downloadURL": {
"pattern": "^https?://github\\.com/[^/]+/[^/]+/releases/download/[^/]+/.+$"
}
},
"required": [
"downloadURL"
]
},
{
"$comment": "'fixed-release-tag-updates' is NOT true (i.e., it's false, or it's completely missing)",
"not": {
"properties": {
"fixed-release-tag-updates": {
"const": true
}
},
"required": [
"fixed-release-tag-updates"
]
}
}
]
},
"errorMessage": "When 'downloadURL' points to a specific GitHub release asset AND 'automatic-version-check' is true, 'fixed-release-tag-updates' must also be true. This prevents accidental update freezing."
},
{
"$comment": "This rule checks the value of 'fixed-release-tag-updates' and guarantees consistency",
"if": {
"properties": {
"fixed-release-tag-updates": {
"const": true
}
},
"required": [
"fixed-release-tag-updates"
]
},
"then": {
"$comment": "Conditions when 'fixed-release-tag-updates' is true",
"properties": {
"automatic-version-check": {
"const": true,
"errorMessage": "'automatic-version-check' must be true when 'fixed-release-tag-updates' is true."
},
"downloadURL": {
"pattern": "^https?://github\\.com/[^/]+/[^/]+/releases/download/[^/]+/.+$",
"errorMessage": "When 'fixed-release-tag-updates' is true, 'downloadURL' must point to a specific GitHub specific release asset (e.g., '/releases/download/v1.0.0/asset.zip'), NOT a branch head or latest release URL."
}
},
"required": [
"automatic-version-check"
],
"errorMessage": "When 'fixed-release-tag-updates' 'automatic-version-check' must be true, and 'downloadURL' must point to a specific release asset."
}
}
]
}