diff --git a/.github/workflows/check-claude-version.yml b/.github/workflows/check-claude-version.yml index cd48331..bb97844 100644 --- a/.github/workflows/check-claude-version.yml +++ b/.github/workflows/check-claude-version.yml @@ -18,87 +18,151 @@ jobs: fetch-depth: 0 token: ${{ secrets.GH_PAT }} - - name: Set up environment + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y p7zip-full wget + pip install playwright requests + playwright install chromium - - name: Download and check Claude version - id: check_version + - name: Resolve download URLs + id: resolve_urls run: | - CLAUDE_DOWNLOAD_URL="https://storage.googleapis.com/osprey-downloads-c02f6a0d-347c-492b-a752-3e0651722e97/nest-win-x64/Claude-Setup-x64.exe" + echo "Resolving Claude Desktop download URLs..." - WORK_DIR=$(mktemp -d) - cd "$WORK_DIR" + # Run the resolver script - stdout has KEY=VALUE pairs, stderr has status messages + python scripts/resolve-download-url.py all --format both > resolved_urls.txt || true - if ! wget -q -O Claude-Setup-x64.exe "$CLAUDE_DOWNLOAD_URL"; then - echo "Failed to download Claude Desktop installer" + echo "Resolved URLs:" + cat resolved_urls.txt + + # Parse the output + AMD64_URL=$(grep "^AMD64_URL=" resolved_urls.txt | cut -d= -f2-) + ARM64_URL=$(grep "^ARM64_URL=" resolved_urls.txt | cut -d= -f2-) + AMD64_VERSION=$(grep "^AMD64_VERSION=" resolved_urls.txt | cut -d= -f2-) + ARM64_VERSION=$(grep "^ARM64_VERSION=" resolved_urls.txt | cut -d= -f2-) + + echo "AMD64 URL: $AMD64_URL" + echo "ARM64 URL: $ARM64_URL" + echo "AMD64 Version: $AMD64_VERSION" + echo "ARM64 Version: $ARM64_VERSION" + + # Use AMD64 version as the canonical version (they should match) + CLAUDE_VERSION="${AMD64_VERSION:-$ARM64_VERSION}" + + if [ -z "$AMD64_URL" ]; then + echo "::error::Failed to resolve AMD64 download URL" exit 1 fi - EXTRACT_DIR="$WORK_DIR/extract" - mkdir -p "$EXTRACT_DIR" - - if ! 7z x -y Claude-Setup-x64.exe -o"$EXTRACT_DIR" > /dev/null 2>&1; then - echo "Failed to extract installer" - exit 1 - fi - - cd "$EXTRACT_DIR" - NUPKG_FILE=$(find . -maxdepth 1 -name "AnthropicClaude-*.nupkg" | head -1) - - if [ -z "$NUPKG_FILE" ]; then - echo "Could not find AnthropicClaude nupkg file" - exit 1 - fi - - # Extract version from nupkg filename - CLAUDE_VERSION=$(echo "$NUPKG_FILE" | grep -oP 'AnthropicClaude-\K[0-9]+\.[0-9]+\.[0-9]+(?=-full|-arm64-full)') - - if [ -z "$CLAUDE_VERSION" ]; then - echo "Could not extract version from nupkg filename" - exit 1 - fi - - echo "Detected Claude Desktop version: $CLAUDE_VERSION" + echo "amd64_url=$AMD64_URL" >> $GITHUB_OUTPUT + echo "arm64_url=$ARM64_URL" >> $GITHUB_OUTPUT echo "claude_version=$CLAUDE_VERSION" >> $GITHUB_OUTPUT - # Clean up - cd / - rm -rf "$WORK_DIR" + - name: Get current URLs from build.sh + id: current_urls + run: | + # Extract current URLs from build.sh + CURRENT_AMD64_URL=$(grep -E 'HOST_ARCH.*=.*"amd64"' -A3 build.sh | grep 'CLAUDE_DOWNLOAD_URL=' | head -1 | sed 's/.*CLAUDE_DOWNLOAD_URL="\([^"]*\)".*/\1/') + CURRENT_ARM64_URL=$(grep -E 'HOST_ARCH.*=.*"arm64"' -A3 build.sh | grep 'CLAUDE_DOWNLOAD_URL=' | head -1 | sed 's/.*CLAUDE_DOWNLOAD_URL="\([^"]*\)".*/\1/') + + echo "Current AMD64 URL: $CURRENT_AMD64_URL" + echo "Current ARM64 URL: $CURRENT_ARM64_URL" + + echo "current_amd64_url=$CURRENT_AMD64_URL" >> $GITHUB_OUTPUT + echo "current_arm64_url=$CURRENT_ARM64_URL" >> $GITHUB_OUTPUT - name: Check if update needed id: check_update env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - CLAUDE_VERSION="${{ steps.check_version.outputs.claude_version }}" - REPO_VERSION="${{ vars.REPO_VERSION }}" + NEW_AMD64_URL="${{ steps.resolve_urls.outputs.amd64_url }}" + NEW_ARM64_URL="${{ steps.resolve_urls.outputs.arm64_url }}" + CURRENT_AMD64_URL="${{ steps.current_urls.outputs.current_amd64_url }}" + CURRENT_ARM64_URL="${{ steps.current_urls.outputs.current_arm64_url }}" + CLAUDE_VERSION="${{ steps.resolve_urls.outputs.claude_version }}" STORED_CLAUDE_VERSION="${{ vars.CLAUDE_DESKTOP_VERSION }}" + REPO_VERSION="${{ vars.REPO_VERSION }}" echo "Current stored Claude version: $STORED_CLAUDE_VERSION" echo "Detected Claude version: $CLAUDE_VERSION" echo "Repository version: $REPO_VERSION" - if [ "$CLAUDE_VERSION" != "$STORED_CLAUDE_VERSION" ]; then - echo "New Claude Desktop version detected!" + UPDATE_NEEDED=false + + # Check if AMD64 URL changed + if [ "$NEW_AMD64_URL" != "$CURRENT_AMD64_URL" ]; then + echo "AMD64 URL has changed" + UPDATE_NEEDED=true + fi + + # Check if ARM64 URL changed (only if we got a new one) + if [ -n "$NEW_ARM64_URL" ] && [ "$NEW_ARM64_URL" != "$CURRENT_ARM64_URL" ]; then + echo "ARM64 URL has changed" + UPDATE_NEEDED=true + fi + + # Check if version changed + if [ -n "$CLAUDE_VERSION" ] && [ "$CLAUDE_VERSION" != "$STORED_CLAUDE_VERSION" ]; then + echo "Claude version has changed" + UPDATE_NEEDED=true + fi + + if [ "$UPDATE_NEEDED" = "true" ]; then + echo "Update needed!" echo "update_needed=true" >> $GITHUB_OUTPUT - + # Construct the new tag NEW_TAG="v${REPO_VERSION}+claude${CLAUDE_VERSION}" echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT echo "New tag will be: $NEW_TAG" else - echo "Claude Desktop version unchanged" + echo "No updates needed" echo "update_needed=false" >> $GITHUB_OUTPUT fi - - name: Update version variable and create tag + - name: Update build.sh with new URLs + if: steps.check_update.outputs.update_needed == 'true' + run: | + NEW_AMD64_URL="${{ steps.resolve_urls.outputs.amd64_url }}" + NEW_ARM64_URL="${{ steps.resolve_urls.outputs.arm64_url }}" + CURRENT_AMD64_URL="${{ steps.current_urls.outputs.current_amd64_url }}" + CURRENT_ARM64_URL="${{ steps.current_urls.outputs.current_arm64_url }}" + + echo "Updating build.sh with new URLs..." + + # Update AMD64 URL + if [ -n "$NEW_AMD64_URL" ] && [ "$NEW_AMD64_URL" != "$CURRENT_AMD64_URL" ]; then + echo "Updating AMD64 URL..." + # Escape special characters for sed + ESCAPED_CURRENT=$(printf '%s\n' "$CURRENT_AMD64_URL" | sed 's/[[\.*^$()+?{|]/\\&/g') + ESCAPED_NEW=$(printf '%s\n' "$NEW_AMD64_URL" | sed 's/[&/\]/\\&/g') + sed -i "s|$ESCAPED_CURRENT|$ESCAPED_NEW|g" build.sh + fi + + # Update ARM64 URL (if we have a new one) + if [ -n "$NEW_ARM64_URL" ] && [ "$NEW_ARM64_URL" != "$CURRENT_ARM64_URL" ]; then + echo "Updating ARM64 URL..." + ESCAPED_CURRENT=$(printf '%s\n' "$CURRENT_ARM64_URL" | sed 's/[[\.*^$()+?{|]/\\&/g') + ESCAPED_NEW=$(printf '%s\n' "$NEW_ARM64_URL" | sed 's/[&/\]/\\&/g') + sed -i "s|$ESCAPED_CURRENT|$ESCAPED_NEW|g" build.sh + fi + + echo "Updated build.sh:" + grep "CLAUDE_DOWNLOAD_URL=" build.sh + + - name: Commit and push changes if: steps.check_update.outputs.update_needed == 'true' env: GH_TOKEN: ${{ secrets.GH_PAT }} run: | - CLAUDE_VERSION="${{ steps.check_version.outputs.claude_version }}" + CLAUDE_VERSION="${{ steps.resolve_urls.outputs.claude_version }}" NEW_TAG="${{ steps.check_update.outputs.new_tag }}" # Check if we have a PAT @@ -107,14 +171,29 @@ jobs: exit 1 fi - gh variable set CLAUDE_DESKTOP_VERSION --body "$CLAUDE_VERSION" - - echo "Creating and pushing new tag: $NEW_TAG" - # Configure git git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + # Check if there are changes to commit + if git diff --quiet build.sh; then + echo "No changes to build.sh" + else + git add build.sh + git commit -m "Update Claude Desktop download URLs to version $CLAUDE_VERSION + +Updated download URLs resolved from official redirect endpoints. + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" + git push + echo "Changes committed and pushed" + fi + + # Update the version variable + gh variable set CLAUDE_DESKTOP_VERSION --body "$CLAUDE_VERSION" + + echo "Creating and pushing new tag: $NEW_TAG" + # Create annotated tag git tag -a "$NEW_TAG" -m "Update to Claude Desktop $CLAUDE_VERSION" @@ -129,7 +208,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | NEW_TAG="${{ steps.check_update.outputs.new_tag }}" - CLAUDE_VERSION="${{ steps.check_version.outputs.claude_version }}" + CLAUDE_VERSION="${{ steps.resolve_urls.outputs.claude_version }}" # Create release notes using heredoc RELEASE_NOTES=$(cat < str | None: + """ + Resolve the actual download URL for the given architecture. + + Args: + arch: Architecture to resolve ('amd64' or 'arm64') + timeout: Timeout in milliseconds + + Returns: + The resolved download URL, or None if resolution failed + """ + if arch not in REDIRECT_URLS: + print(f"Error: Unknown architecture '{arch}'", file=sys.stderr) + return None + + redirect_url = REDIRECT_URLS[arch] + resolved_url = None + + def handle_request(request): + nonlocal resolved_url + url = request.url + # Look for the final Google Cloud Storage URL + if "storage.googleapis.com" in url and url.endswith(".exe"): + resolved_url = url + + def handle_download(download): + nonlocal resolved_url + resolved_url = download.url + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context( + user_agent=USER_AGENT, + viewport={"width": 1920, "height": 1080}, + accept_downloads=True, + ) + + # Apply stealth settings + context.add_init_script(""" + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + Object.defineProperty(navigator, 'platform', { get: () => 'Linux x86_64' }); + Object.defineProperty(navigator, 'vendor', { get: () => 'Google Inc.' }); + """) + + page = context.new_page() + page.on("request", handle_request) + page.on("download", handle_download) + + try: + # Navigate to the redirect URL - this will trigger a download + page.goto(redirect_url, timeout=timeout, wait_until="commit") + # Give it a moment to capture the download URL + page.wait_for_timeout(2000) + except PlaywrightTimeout: + # Timeout is expected - we just need to capture the redirect + pass + except Exception as e: + # "Download is starting" error is expected and means we captured the URL + if "Download is starting" not in str(e): + print(f"Error navigating to {redirect_url}: {e}", file=sys.stderr) + finally: + browser.close() + + return resolved_url + + +def extract_version_from_url(url: str) -> str | None: + """ + Extract version number from a download URL. + + The URL typically contains a path like /1.0.1234/ with the version. + """ + match = re.search(r"/(\d+\.\d+\.\d+)/", url) + if match: + return match.group(1) + return None + + +def verify_url_exists(url: str, timeout: int = 10) -> bool: + """ + Verify that a URL exists by making a HEAD request. + + Returns True if the URL returns a 200 status code. + """ + try: + response = requests.head(url, timeout=timeout, allow_redirects=True) + return response.status_code == 200 + except requests.RequestException: + return False + + +def derive_arm64_url_from_amd64(amd64_url: str) -> str | None: + """ + Derive the ARM64 download URL from an AMD64 URL by pattern substitution. + + Handles both old and new URL patterns: + Old: https://storage.googleapis.com/.../nest-win-x64/Claude-Setup-x64.exe + New: https://downloads.claude.ai/releases/win32/x64/1.0.xxx/Claude-xxx.exe + """ + if not amd64_url: + return None + + arm64_url = amd64_url + + # New URL pattern: downloads.claude.ai/releases/win32/x64/ -> /arm64/ + arm64_url = arm64_url.replace("/win32/x64/", "/win32/arm64/") + + # Old URL pattern: storage.googleapis.com + arm64_url = arm64_url.replace("nest-win-x64", "nest-win-arm64") + arm64_url = arm64_url.replace("Claude-Setup-x64.exe", "Claude-Setup-arm64.exe") + arm64_url = arm64_url.replace("-x64.exe", "-arm64.exe") + + # Only return if we actually made changes + if arm64_url != amd64_url: + return arm64_url + return None + + +def main(): + parser = argparse.ArgumentParser( + description="Resolve Claude Desktop download URLs" + ) + parser.add_argument( + "arch", + choices=["amd64", "arm64", "all"], + help="Architecture to resolve (amd64, arm64, or all)", + ) + parser.add_argument( + "--timeout", + type=int, + default=30000, + help="Timeout in milliseconds (default: 30000)", + ) + parser.add_argument( + "--format", + choices=["url", "version", "both"], + default="url", + help="Output format (default: url)", + ) + + args = parser.parse_args() + + architectures = ["amd64", "arm64"] if args.arch == "all" else [args.arch] + + results = {} + for arch in architectures: + print(f"Resolving {arch} download URL...", file=sys.stderr) + url = resolve_download_url(arch, args.timeout) + + if url: + version = extract_version_from_url(url) + results[arch] = {"url": url, "version": version, "derived": False} + print(f" Resolved: {url}", file=sys.stderr) + if version: + print(f" Version: {version}", file=sys.stderr) + else: + print(f" Failed to resolve {arch} URL via redirect", file=sys.stderr) + results[arch] = None + + # If ARM64 failed but AMD64 succeeded, try to derive ARM64 URL + if args.arch == "all" and results.get("arm64") is None and results.get("amd64"): + print("Attempting to derive ARM64 URL from AMD64 URL...", file=sys.stderr) + derived_url = derive_arm64_url_from_amd64(results["amd64"]["url"]) + if derived_url: + print(f" Derived URL: {derived_url}", file=sys.stderr) + print(" Verifying URL exists...", file=sys.stderr) + if verify_url_exists(derived_url): + version = extract_version_from_url(derived_url) + results["arm64"] = {"url": derived_url, "version": version, "derived": True} + print(f" ✓ Verified ARM64 URL exists", file=sys.stderr) + else: + print(" ✗ Derived URL does not exist (404)", file=sys.stderr) + else: + print(" Could not derive ARM64 URL pattern", file=sys.stderr) + + # Output results based on format + for arch, result in results.items(): + if result is None: + continue + + prefix = f"{arch.upper()}_" if len(architectures) > 1 else "" + + if args.format == "url": + print(f"{prefix}URL={result['url']}") + elif args.format == "version": + if result["version"]: + print(f"{prefix}VERSION={result['version']}") + elif args.format == "both": + print(f"{prefix}URL={result['url']}") + if result["version"]: + print(f"{prefix}VERSION={result['version']}") + + # Exit with error if any resolution failed + if any(r is None for r in results.values()): + sys.exit(1) + + +if __name__ == "__main__": + main()