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:
58
.github/scripts/update_mod_versions.py
vendored
58
.github/scripts/update_mod_versions.py
vendored
@@ -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)
|
||||
|
||||
|
||||
3
.github/workflows/check-mod.yml
vendored
3
.github/workflows/check-mod.yml
vendored
@@ -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'
|
||||
|
||||
11
README.md
11
README.md
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user