-
Notifications
You must be signed in to change notification settings - Fork 11.8k
Fix #4345: deliver bundled extension updates - catalog-synced bumps, CI guard, staleness detection, local-package installs #4351
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
CrazyBaran
wants to merge
10
commits into
github:main
Choose a base branch
from
CrazyBaran:fix/4345-bundled-extension-version-bumps
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,140
−35
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
60dc320
fix(extensions): bump drifted bundled extension versions (agent-conte…
dfa1fa6
ci(extensions): guard bundled extension changes behind a version bump
1683f27
feat(extensions): detect content-stale bundled extensions in `extensi…
7204848
fix(extensions): install bundled extension updates from the local pac…
cbe4b31
refactor(extensions): align #4345 series with constitution typing and…
08246e5
docs(extensions): document bundled update behavior and the version-bu…
18a12b9
fix(extensions): address Copilot review round 1 on #4351
002443a
fix(extensions): block bundled updates whenever the local copy lags t…
edb30a4
fix(bundles): move agent-context pins to 1.1.0 and stop hardcoding th…
16c5642
docs(extensions): distinguish version-driven offers from advisory sta…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| #!/usr/bin/env python3 | ||
| """Fail a PR that changes bundled extension content without a version bump. | ||
|
|
||
| Update offers from `specify extension update` are version-driven: an | ||
| extension is offered (and installed) only when the semver in | ||
| `extensions/catalog.json` exceeds the installed copy's registered | ||
| version. A content change shipped without a version bump is therefore | ||
| never delivered automatically (#4345). The command's content-hash check | ||
| can detect such unbumped drift on bundled extensions, but only as an | ||
| advisory stale-content warning pointing at a manual `--force` reinstall — | ||
| a bump is what makes a change actually reach existing installs, and this | ||
| guard is what makes the bump non-optional. | ||
|
|
||
| This check enforces two invariants on the extensions listed in | ||
| `extensions/catalog.json`: | ||
|
|
||
| 1. Any change to a file under `extensions/<id>/` must increase the | ||
| `version:` in that extension's `extension.yml` (PEP 440 comparison, | ||
| the same semantics `extension update` uses). | ||
| 2. The `version` in `extensions/catalog.json` must equal the manifest's | ||
| `extension.version` (the catalog is what update checks compare | ||
| against, and the update preflight rejects a manifest whose version | ||
| differs from the catalog's). | ||
|
|
||
| Usage: | ||
| check_extension_version_bump.py BASE_REF [HEAD_REF] | ||
|
|
||
| BASE_REF is a git ref/SHA for the PR base (must be fetchable with | ||
| `git show`). HEAD_REF defaults to the working tree's HEAD. Exits 0 when | ||
| all invariants hold, 1 otherwise, printing one line per violation. | ||
|
|
||
| Extensions under `extensions/` that are not in the catalog (the | ||
| `selftest` fixture and the `template` scaffold) are exempt: no update | ||
| flow is driven by their versions. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import subprocess | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| import yaml | ||
| from packaging.version import InvalidVersion, Version | ||
|
|
||
| EXTENSIONS_ROOT = "extensions" | ||
| CATALOG_PATH = f"{EXTENSIONS_ROOT}/catalog.json" | ||
|
|
||
|
|
||
| def _git(*args: str) -> str: | ||
| return subprocess.run( | ||
| ["git", *args], check=True, capture_output=True, text=True | ||
| ).stdout | ||
|
|
||
|
|
||
| def _show(ref: str, path: str) -> str | None: | ||
| """Return the file's content at *ref*, or None when absent there.""" | ||
| result = subprocess.run( | ||
| ["git", "show", f"{ref}:{path}"], capture_output=True, text=True | ||
| ) | ||
| return result.stdout if result.returncode == 0 else None | ||
|
|
||
|
|
||
| def _manifest_version(manifest_text: str, origin: str) -> str: | ||
| data = yaml.safe_load(manifest_text) | ||
| if not isinstance(data, dict) or not isinstance(data.get("extension"), dict): | ||
| raise ValueError(f"{origin}: manifest is not a mapping with an 'extension' block") | ||
| version = data["extension"].get("version") | ||
| if not isinstance(version, str) or not version.strip(): | ||
| raise ValueError(f"{origin}: extension.version is missing or not a string") | ||
| return version.strip() | ||
|
|
||
|
|
||
| def main(argv: list[str]) -> int: | ||
| if len(argv) < 2 or len(argv) > 3: | ||
| print(__doc__, file=sys.stderr) | ||
| return 2 | ||
| base_ref = argv[1] | ||
| head_ref = argv[2] if len(argv) == 3 else "HEAD" | ||
|
|
||
| catalog_text = _show(head_ref, CATALOG_PATH) | ||
| if catalog_text is None: | ||
| print(f"::error::{CATALOG_PATH} is missing at {head_ref}") | ||
| return 1 | ||
| catalog = json.loads(catalog_text) | ||
| catalog_entries = catalog.get("extensions", {}) | ||
|
|
||
| errors: list[str] = [] | ||
|
|
||
| # -- Invariant 1: content change requires a version bump --------------- | ||
| changed = _git( | ||
| "diff", "--name-only", "--no-renames", base_ref, head_ref, "--", EXTENSIONS_ROOT | ||
| ).splitlines() | ||
| changed_ids = { | ||
| parts[1] | ||
| for line in changed | ||
| if len(parts := Path(line.strip()).parts) >= 3 and parts[0] == EXTENSIONS_ROOT | ||
| } | ||
|
|
||
| for ext_id in sorted(changed_ids): | ||
| if ext_id not in catalog_entries: | ||
| continue # not driven by `extension update` (selftest, template) | ||
| manifest_path = f"{EXTENSIONS_ROOT}/{ext_id}/extension.yml" | ||
| head_manifest = _show(head_ref, manifest_path) | ||
| if head_manifest is None: | ||
| continue # extension removed in this PR | ||
| base_manifest = _show(base_ref, manifest_path) | ||
| if base_manifest is None: | ||
| continue # new extension; any initial version is fine | ||
| try: | ||
| base_version = _manifest_version(base_manifest, f"{base_ref}:{manifest_path}") | ||
| head_version = _manifest_version(head_manifest, f"{head_ref}:{manifest_path}") | ||
| except ValueError as exc: | ||
| errors.append(str(exc)) | ||
| continue | ||
|
|
||
| # Compare with the same PEP 440 semantics the extension update and | ||
| # install code use (packaging.version), so prereleases and other | ||
| # accepted forms cannot bypass the guard (e.g. 2.0.0 -> 1.0.0rc1 is | ||
| # a downgrade). Unparseable versions fail closed. | ||
| try: | ||
| base_parsed = Version(base_version) | ||
| head_parsed = Version(head_version) | ||
| except InvalidVersion as exc: | ||
| errors.append( | ||
| f"{manifest_path}: could not compare versions " | ||
| f"{base_version!r} -> {head_version!r}: {exc}" | ||
| ) | ||
| continue | ||
| if head_parsed <= base_parsed: | ||
| errors.append( | ||
| f"{manifest_path}: files under {EXTENSIONS_ROOT}/{ext_id}/ changed but " | ||
| f"extension.version did not increase ({base_version} -> {head_version}). " | ||
| f"Installed copies only receive changes when the version is bumped." | ||
| ) | ||
|
|
||
| # -- Invariant 2: catalog.json version matches the manifest ------------ | ||
| for ext_id, entry in sorted(catalog_entries.items()): | ||
| manifest_path = f"{EXTENSIONS_ROOT}/{ext_id}/extension.yml" | ||
| head_manifest = _show(head_ref, manifest_path) | ||
| if head_manifest is None: | ||
| continue # catalog-only entry (e.g. hosted elsewhere) | ||
| try: | ||
| manifest_version = _manifest_version(head_manifest, f"{head_ref}:{manifest_path}") | ||
| except ValueError as exc: | ||
| errors.append(str(exc)) | ||
| continue | ||
| catalog_version = entry.get("version") | ||
| if catalog_version != manifest_version: | ||
| errors.append( | ||
| f"{CATALOG_PATH}: entry '{ext_id}' has version {catalog_version!r} but " | ||
| f"{manifest_path} declares {manifest_version!r}. `extension update` " | ||
| f"compares against the catalog, so the two must move together." | ||
| ) | ||
|
|
||
| for error in errors: | ||
| print(f"::error::{error}") | ||
| if not errors: | ||
| print("Extension version guard: all invariants hold.") | ||
| return 1 if errors else 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main(sys.argv)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| name: Extension Version Guard | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| # Bundled extensions only reach existing installs through a version bump: | ||
| # `specify extension update` compares the semver in extensions/catalog.json | ||
| # against the installed copy and reports "Up to date" whenever they match. | ||
| # Content changes shipped without a bump go silently stale on every | ||
| # project that already installed the extension (#4345). This guard turns | ||
| # "please remember to bump" into a merge requirement. | ||
| on: | ||
| pull_request: | ||
| paths: | ||
| - "extensions/**" | ||
|
|
||
| jobs: | ||
| version-bump: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| fetch-depth: 1 | ||
|
|
||
| - name: Set up Python | ||
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | ||
| with: | ||
| python-version: "3.14" | ||
|
|
||
| - name: Install check dependencies | ||
| run: python -m pip install --quiet pyyaml packaging | ||
|
|
||
| # For pull_request events the checkout is the merge of the PR head | ||
| # into the base tip, so diffing base.sha against HEAD yields exactly | ||
| # the PR's changes (same fetch pattern as lint.yml). | ||
| - name: Check bundled extension version bumps | ||
| env: | ||
| PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} | ||
| run: | | ||
| set -euo pipefail | ||
| git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/checks/pr-base" | ||
| python .github/scripts/check_extension_version_bump.py refs/checks/pr-base |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.