Browse Source

CI: Create Sparkle appcast and deltas on tag

derrod 3 years ago
parent
commit
cb475718bd
3 changed files with 336 additions and 0 deletions
  1. 115 0
      .github/workflows/main.yml
  2. 96 0
      CI/macos/appcast_convert.py
  3. 125 0
      CI/macos/appcast_download.py

+ 115 - 0
.github/workflows/main.yml

@@ -444,6 +444,9 @@ jobs:
     env:
       HAVE_CODESIGN_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY != '' && secrets.MACOS_SIGNING_CERT != '' }}
       BUILD_FOR_DISTRIBUTION: 'ON'
+      HAVE_SPARKLE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY != '' }}
+    outputs:
+      run_sparkle: ${{ steps.sparkle_check.outputs.run_sparkle }}
     if: ${{ startsWith(github.ref, 'refs/tags/') && github.event_name != 'pull_request' }}
     strategy:
       matrix:
@@ -461,6 +464,12 @@ jobs:
         run: |
           echo "commitHash=$(git rev-parse --short=9 HEAD)" >> $GITHUB_OUTPUT
 
+      - name: 'Determine if Sparkle should run'
+        if: env.HAVE_CODESIGN_IDENTITY == 'true'
+        id: sparkle_check
+        run: |
+          echo 'run_sparkle=${{ env.HAVE_SPARKLE_KEY }}' >> $GITHUB_OUTPUT
+
       - name: 'Download artifact'
         if: env.HAVE_CODESIGN_IDENTITY == 'true'
         uses: actions/download-artifact@v3
@@ -492,3 +501,109 @@ jobs:
         with:
           name: 'obs-studio-macos-${{ matrix.arch }}-notarized'
           path: '${{ github.workspace }}/${{ env.FILE_NAME }}'
+
+  macos_sparkle:
+    name: '04 - macOS Sparkle Updates'
+    runs-on: [macos-12]
+    needs: [macos_release]
+    if: fromJSON(needs.macos_release.outputs.run_sparkle)
+    strategy:
+      matrix:
+        arch: ['x86_64', 'arm64']
+    env:
+      SPARKLE_VERSION: '2.3.2'
+      SPARKLE_HASH: '2b3fe6918ca20a83729aad34f8f693a678b714a17d33b5f13ca2d25edfa7eed3'
+    defaults:
+      run:
+        shell: bash
+    steps:
+      - name: 'Checkout'
+        uses: actions/checkout@v3
+        with:
+          path: 'repo'
+          ref: ${{ github.ref }}
+
+      - name: 'Download artifact'
+        uses: actions/download-artifact@v3
+        with:
+          name: 'obs-studio-macos-${{ matrix.arch }}-notarized'
+          path: 'artifacts'
+
+      - name: 'Install Python requirements'
+        run: pip3 install requests xmltodict
+
+      - name: 'Install Brew requirements'
+        run: brew install coreutils pandoc
+
+      - name: 'Setup Sparkle'
+        run: |
+          curl -L "https://github.com/sparkle-project/Sparkle/releases/download/${{ env.SPARKLE_VERSION }}/Sparkle-${{ env.SPARKLE_VERSION }}.tar.xz" -o Sparkle.tar.xz
+
+          if [[ '${{ env.SPARKLE_HASH }}' != "$(sha256sum Sparkle.tar.xz | cut -d " " -f 1)" ]]; then
+              echo "Sparkle download hash does not match!"
+              exit 1
+          fi
+
+          mkdir sparkle && cd sparkle
+          tar -xf ../Sparkle.tar.xz
+
+      - name: 'Setup folder structure'
+        run: |
+          mkdir builds
+          mkdir -p output/appcasts/stable
+          mkdir -p output/sparkle_deltas/${{ matrix.arch }}
+
+      - name: 'Determine branch and tag'
+        id: branch
+        run: |
+          pushd repo
+          
+          GIT_TAG="$(git describe --tags --abbrev=0)"
+          if [[ ${GIT_TAG} == *'beta'* || ${GIT_TAG} == *'rc'* ]]; then
+            echo "branch=beta" >> $GITHUB_OUTPUT
+            echo "deltas=0" >> $GITHUB_OUTPUT
+          else
+            echo "branch=stable" >> $GITHUB_OUTPUT
+            echo "deltas=1" >> $GITHUB_OUTPUT
+          fi
+          # Write tag description to file
+          git tag -l --format='%(contents)' ${GIT_TAG} >> ../notes.rst
+
+      - name: 'Download existing Appcast and builds'
+        run: python3 repo/CI/macos/appcast_download.py --branch "${{ steps.branch.outputs.branch }}" --max-old-versions ${{ steps.branch.outputs.deltas }}
+
+      - name: 'Prepare release notes'
+        run: |
+          # Insert underline at line 2 to turn first line into heading
+          sed -i '' '2i\'$'\n''###################################################' notes.rst
+          pandoc -f rst -t html notes.rst -o output/appcasts/notes_${{ steps.branch.outputs.branch }}.html
+
+      - name: 'Setup Sparkle key'
+        run: echo -n "${{ secrets.SPARKLE_PRIVATE_KEY }}" >> eddsa_private.key
+
+      - name: 'Generate Appcast'
+        run: |
+          mv artifacts/*.dmg builds/
+          ./sparkle/bin/generate_appcast \
+              --verbose \
+              --ed-key-file ./eddsa_private.key \
+              --download-url-prefix "https://cdn-fastly.obsproject.com/downloads/" \
+              --full-release-notes-url "https://obsproject.com/osx_update/notes_${{ steps.branch.outputs.branch }}.html" \
+              --maximum-versions 0 \
+              --maximum-deltas ${{ steps.branch.outputs.deltas }} \
+              --channel "${{ steps.branch.outputs.branch }}" builds/
+          # Move deltas, if any
+          if compgen -G "builds/*.delta" > /dev/null; then
+            mv builds/*.delta output/sparkle_deltas/${{ matrix.arch }}
+          fi
+          # Move appcasts
+          mv builds/*.xml output/appcasts/
+
+      - name: 'Create 1.x Appcast'
+        run: python3 repo/CI/macos/appcast_convert.py
+
+      - name: 'Upload Appcast and Deltas'
+        uses: actions/upload-artifact@v3
+        with:
+          name: 'macos-sparkle-updates'
+          path: '${{ github.workspace }}/output'

+ 96 - 0
CI/macos/appcast_convert.py

@@ -0,0 +1,96 @@
+import os
+from copy import deepcopy
+
+import xmltodict
+
+
+DELTA_BASE_URL = "https://cdn-fastly.obsproject.com/downloads/sparkle_deltas"
+
+
+def convert_appcast(filename):
+    print("Converting", filename)
+    in_path = os.path.join("output/appcasts", filename)
+    out_path = os.path.join("output/appcasts/stable", filename.replace("_v2", ""))
+    with open(in_path, "rb") as f:
+        xml_data = f.read()
+    if not xml_data:
+        return
+
+    appcast = xmltodict.parse(xml_data, force_list=("item",))
+    out_appcast = deepcopy(appcast)
+
+    # Remove anything but stable channel items.
+    new_list = []
+    for _item in appcast["rss"]["channel"]["item"]:
+        item = deepcopy(_item)
+        branch = item.pop("sparkle:channel", "stable")
+        if branch != "stable":
+            continue
+        # Remove delta information (incompatible with Sparkle 1.x)
+        item.pop("sparkle:deltas", None)
+        new_list.append(item)
+
+    out_appcast["rss"]["channel"]["item"] = new_list
+
+    with open(out_path, "wb") as f:
+        xmltodict.unparse(out_appcast, output=f, pretty=True)
+
+    # Also create legacy appcast from x86 version.
+    if "x86" in filename:
+        out_path = os.path.join("output/appcasts/stable", "updates.xml")
+        with open(out_path, "wb") as f:
+            xmltodict.unparse(out_appcast, output=f, pretty=True)
+
+
+def adjust_appcast(filename):
+    print("Adjusting", filename)
+    file_path = os.path.join("output/appcasts", filename)
+    with open(file_path, "rb") as f:
+        xml_data = f.read()
+    if not xml_data:
+        return
+
+    arch = "arm64" if "arm64" in filename else "x86_64"
+    appcast = xmltodict.parse(xml_data, force_list=("item", "enclosure"))
+
+    out_appcast = deepcopy(appcast)
+    out_appcast["rss"]["channel"]["title"] = "OBS Studio"
+    out_appcast["rss"]["channel"]["link"] = "https://obsproject.com/"
+
+    new_list = []
+    for _item in appcast["rss"]["channel"]["item"]:
+        item = deepcopy(_item)
+        # Fix changelog URL
+        # Sparkle doesn't allow us to specify the URL for a specific update,
+        # so we set the full release notes link instead and then rewrite the
+        # appcast. Yay.
+        if release_notes_link := item.pop("sparkle:fullReleaseNotesLink", None):
+            item["sparkle:releaseNotesLink"] = release_notes_link
+
+        # If deltas exist, update their URLs to match server layout
+        # (generate_appcast doesn't allow this).
+        if deltas := item.get("sparkle:deltas", None):
+            for delta_item in deltas["enclosure"]:
+                delta_filename = delta_item["@url"].rpartition("/")[2]
+                delta_item["@url"] = f"{DELTA_BASE_URL}/{arch}/{delta_filename}"
+
+        new_list.append(item)
+
+    out_appcast["rss"]["channel"]["item"] = new_list
+
+    with open(file_path, "wb") as f:
+        xmltodict.unparse(out_appcast, output=f, pretty=True)
+
+
+if __name__ == "__main__":
+    for ac_file in os.listdir("output/appcasts"):
+        if ".xml" not in ac_file:
+            continue
+        if "v2" not in ac_file:
+            # generate_appcast may download legacy appcast files and update them as well.
+            # Those generated files are not backwards-compatible, so delete whatever v1
+            # files it may have created and recreate them manually.
+            os.remove(os.path.join("output/appcasts", ac_file))
+            continue
+        adjust_appcast(ac_file)
+        convert_appcast(ac_file)

+ 125 - 0
CI/macos/appcast_download.py

@@ -0,0 +1,125 @@
+import os
+import sys
+import plistlib
+import glob
+import subprocess
+import argparse
+
+import requests
+import xmltodict
+
+
+def download_build(url):
+    print(f'Downloading build "{url}"...')
+    filename = url.rpartition("/")[2]
+    r = requests.get(url)
+    if r.status_code == 200:
+        with open(f"artifacts/{filename}", "wb") as f:
+            f.write(r.content)
+    else:
+        print(f"Build download failed, status code: {r.status_code}")
+        sys.exit(1)
+
+
+def read_appcast(url):
+    print(f"Downloading feed {url} ...")
+    r = requests.get(url)
+    if r.status_code != 200:
+        print(f"Appcast download failed, status code: {r.status_code}")
+        sys.exit(1)
+
+    filename = url.rpartition("/")[2]
+    with open(f"builds/{filename}", "wb") as f:
+        f.write(r.content)
+
+    appcast = xmltodict.parse(r.content, force_list=("item",))
+
+    dl = 0
+    for item in appcast["rss"]["channel"]["item"]:
+        channel = item.get("sparkle:channel", "stable")
+        if channel != target_branch:
+            continue
+
+        if dl == max_old_vers:
+            break
+        download_build(item["enclosure"]["@url"])
+        dl += 1
+
+
+def get_appcast_url(artifact_dir):
+    dmgs = glob.glob(artifact_dir + "/*.dmg")
+    if not dmgs:
+        raise ValueError("No artifacts!")
+    elif len(dmgs) > 1:
+        raise ValueError("Too many artifacts!")
+
+    dmg = dmgs[0]
+    print(f"Mounting {dmg} ...")
+    out = subprocess.check_output(
+        [
+            "hdiutil",
+            "attach",
+            "-readonly",
+            "-noverify",
+            "-noautoopen",
+            "-plist",
+            dmg,
+        ]
+    )
+    d = plistlib.loads(out)
+
+    mountpoint = ""
+    for item in d["system-entities"]:
+        if "mount-point" not in item:
+            continue
+        mountpoint = item["mount-point"]
+        break
+
+    url = None
+    plist_files = glob.glob(mountpoint + "/*.app/Contents/Info.plist")
+    if plist_files:
+        plist_file = plist_files[0]
+        print(f"Reading plist {plist_file} ...")
+        plist = plistlib.load(open(plist_file, "rb"))
+        url = plist.get("SUFeedURL", None)
+    else:
+        print("No Plist file found!")
+
+    print(f"Unmounting {mountpoint}")
+    subprocess.check_call(["hdiutil", "detach", mountpoint])
+    return url
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        "--artifacts-dir",
+        dest="artifacts",
+        action="store",
+        default="artifacts",
+        help="Folder containing artifact",
+    )
+    parser.add_argument(
+        "--branch",
+        dest="branch",
+        action="store",
+        default="stable",
+        help="Channel/Branch",
+    )
+    parser.add_argument(
+        "--max-old-versions",
+        dest="max_old_ver",
+        action="store",
+        type=int,
+        default=1,
+        help="Maximum old versions to download",
+    )
+    args = parser.parse_args()
+
+    target_branch = args.branch
+    max_old_vers = args.max_old_ver
+    url = get_appcast_url(args.artifacts)
+    if not url:
+        raise ValueError("Failed to get Sparkle URL from DMG!")
+
+    read_appcast(url)