From f16f05f428c50ba36ff043927c0f9a7770120819 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 22:51:31 -0300 Subject: [PATCH] feat: import pinned Nextcloud appstore workflow reference Signed-off-by: Vitor Mattos --- LICENSES/MIT.txt | 15 ++ scripts/sync_upstream.py | 22 +- tests/test_sync_upstream.py | 22 ++ upstream/sources.json | 9 +- .../nextcloud/appstore-build-publish.yml | 202 ++++++++++++++++++ 5 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 LICENSES/MIT.txt mode change 100644 => 100755 scripts/sync_upstream.py create mode 100644 upstream/vendor/nextcloud/appstore-build-publish.yml diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..7762a5f --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,15 @@ +Copyright + +Permission is hereby granted, free of charge, +to any person obtaining a copy of this software and associated documentation files (the “Software”), +to deal in the Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/scripts/sync_upstream.py b/scripts/sync_upstream.py old mode 100644 new mode 100755 index 87575cd..3ace132 --- a/scripts/sync_upstream.py +++ b/scripts/sync_upstream.py @@ -9,6 +9,7 @@ import json from dataclasses import dataclass from pathlib import Path +from urllib.parse import urlparse from urllib.request import Request, urlopen @@ -43,8 +44,7 @@ def load_sources(manifest_path: Path) -> list[Source]: if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest): raise ValueError(f"sources[{index}].sha256 must be 64 lowercase hex characters") - if "/refs/heads/" in url or url.endswith(("/main", "/master")): - raise ValueError(f"sources[{index}].url must be pinned to an immutable commit") + _validate_immutable_url(url, f"sources[{index}].url") sources.append( Source( @@ -91,6 +91,24 @@ def check(sources: list[Source], root: Path) -> None: raise ValueError("generated templates are out of date: " + ", ".join(drift)) +def _validate_immutable_url(url: str, path: str) -> None: + parsed = urlparse(url) + if parsed.scheme != "https": + raise ValueError(f"{path} must use https") + + if parsed.hostname == "raw.githubusercontent.com": + parts = [part for part in parsed.path.split("/") if part] + if len(parts) < 4: + raise ValueError(f"{path} is not a valid raw GitHub file URL") + revision = parts[2] + if len(revision) != 40 or any( + char not in "0123456789abcdefABCDEF" for char in revision + ): + raise ValueError(f"{path} must pin a 40-character Git commit SHA") + elif "/refs/heads/" in parsed.path: + raise ValueError(f"{path} must not reference a mutable branch") + + def _safe_destination(root: Path, destination: Path) -> Path: if destination.is_absolute() or ".." in destination.parts: raise ValueError(f"unsafe destination: {destination}") diff --git a/tests/test_sync_upstream.py b/tests/test_sync_upstream.py index 3baee13..cc47bc4 100644 --- a/tests/test_sync_upstream.py +++ b/tests/test_sync_upstream.py @@ -57,6 +57,28 @@ def test_rejects_invalid_hash(self) -> None: with self.assertRaisesRegex(ValueError, "sha256"): load_sources(manifest) + def test_rejects_mutable_raw_github_revision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = Path(directory) / "sources.json" + manifest.write_text( + json.dumps( + { + "sources": [ + { + "name": "workflow", + "url": "https://raw.githubusercontent.com/example/project/master/workflow.yml", + "sha256": "a" * 64, + "destination": "templates/workflow.yml", + } + ] + } + ), + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "40-character Git commit SHA"): + load_sources(manifest) + def test_sync_writes_verified_content(self) -> None: content = b"name: Example\n" source = Source( diff --git a/upstream/sources.json b/upstream/sources.json index de86b70..db95083 100644 --- a/upstream/sources.json +++ b/upstream/sources.json @@ -1,3 +1,10 @@ { - "sources": [] + "sources": [ + { + "name": "nextcloud-appstore-build-publish", + "url": "https://raw.githubusercontent.com/nextcloud/.github/cf6248d5ef28a328cde764daf80bc5fae4705ade/workflow-templates/appstore-build-publish.yml", + "sha256": "4710d78c576abd1c875d799770f67262988119404d58d379656f5932872fcba8", + "destination": "upstream/vendor/nextcloud/appstore-build-publish.yml" + } + ] } diff --git a/upstream/vendor/nextcloud/appstore-build-publish.yml b/upstream/vendor/nextcloud/appstore-build-publish.yml new file mode 100644 index 0000000..699f6d6 --- /dev/null +++ b/upstream/vendor/nextcloud/appstore-build-publish.yml @@ -0,0 +1,202 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization +# +# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: Build and publish app release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build_and_publish: + runs-on: ubuntu-latest + + # Only allowed to be run on nextcloud-releases repositories + if: ${{ github.repository_owner == 'nextcloud-releases' }} + + steps: + - name: Check actor permission + uses: skjnldsv/check-actor-permission@69e92a3c4711150929bca9fcf34448c5bf5526e7 # v3.0 + with: + require: write + + - name: Set app env + run: | + # Split and keep last + echo "APP_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV + echo "APP_VERSION=${GITHUB_REF##*/}" >> $GITHUB_ENV + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: ${{ env.APP_NAME }} + + - name: Get app version number + id: app-version + uses: skjnldsv/xpath-action@f5b036e9d973f42c86324833fd00be90665fbf77 # v1.0.0 + with: + filename: ${{ env.APP_NAME }}/appinfo/info.xml + expression: "//info//version/text()" + + - name: Validate app version against tag + run: | + [ "${{ env.APP_VERSION }}" = "v${{ fromJSON(steps.app-version.outputs.result).version }}" ] + + - name: Get appinfo data + id: appinfo + uses: skjnldsv/xpath-action@f5b036e9d973f42c86324833fd00be90665fbf77 # v1.0.0 + with: + filename: ${{ env.APP_NAME }}/appinfo/info.xml + expression: "//info//dependencies//nextcloud/@min-version" + + - name: Read package.json node and npm engines version + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + # Continue if no package.json + continue-on-error: true + with: + path: ${{ env.APP_NAME }} + fallbackNode: '^24' + fallbackNpm: '^11.3' + + - name: Set up node ${{ steps.versions.outputs.nodeVersion }} + # Skip if no package.json + if: ${{ steps.versions.outputs.nodeVersion }} + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + package-manager-cache: false + + - name: Set up npm ${{ steps.versions.outputs.npmVersion }} + # Skip if no package.json + if: ${{ steps.versions.outputs.npmVersion }} + run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' + + - name: Get php version + id: php-versions + uses: nextcloud-libraries/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + with: + filename: ${{ env.APP_NAME }}/appinfo/info.xml + + - name: Set up php ${{ steps.php-versions.outputs.php-min }} + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ steps.php-versions.outputs.php-min }} + coverage: none + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check composer.json + id: check_composer + uses: andstor/file-existence-action@558493d6c74bf472d87c84eab196434afc2fa029 # v3.1.0 + with: + files: "${{ env.APP_NAME }}/composer.json" + + - name: Install composer dependencies + if: steps.check_composer.outputs.files_exists == 'true' + uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + composer-options: '--no-dev' + working-directory: ${{ env.APP_NAME }} + ignore-cache: 'yes' + + - name: Build ${{ env.APP_NAME }} + # Skip if no package.json + if: ${{ steps.versions.outputs.nodeVersion }} + env: + CYPRESS_INSTALL_BINARY: 0 + run: | + cd ${{ env.APP_NAME }} + npm ci + npm run build --if-present + + - name: Check Krankerl config + id: krankerl + uses: andstor/file-existence-action@558493d6c74bf472d87c84eab196434afc2fa029 # v3.1.0 + with: + files: ${{ env.APP_NAME }}/krankerl.toml + + - name: Install Krankerl + if: steps.krankerl.outputs.files_exists == 'true' + run: | + wget https://github.com/ChristophWurst/krankerl/releases/download/v0.14.0/krankerl_0.14.0_amd64.deb + sudo dpkg -i krankerl_0.14.0_amd64.deb + + - name: Package ${{ env.APP_NAME }} ${{ env.APP_VERSION }} with krankerl + if: steps.krankerl.outputs.files_exists == 'true' + run: | + cd ${{ env.APP_NAME }} + krankerl package + + - name: Package ${{ env.APP_NAME }} ${{ env.APP_VERSION }} with makefile + if: steps.krankerl.outputs.files_exists != 'true' + run: | + cd ${{ env.APP_NAME }} + make appstore + + - name: Check server download link for ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} + run: | + NCVERSION='${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }}' + DOWNLOAD_URL=$(curl -s "https://updates.nextcloud.com/updater_server/latest?channel=beta&version=$NCVERSION" | jq -r '.downloads.zip[0]') + echo "DOWNLOAD_URL=$DOWNLOAD_URL" >> $GITHUB_ENV + + - name: Download server ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} + continue-on-error: true + id: server-download + if: ${{ env.DOWNLOAD_URL != 'null' }} + run: | + echo "Downloading release tarball from $DOWNLOAD_URL" + wget $DOWNLOAD_URL -O nextcloud.zip + unzip nextcloud.zip + + - name: Checkout server master fallback + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: ${{ steps.server-download.outcome != 'success' }} + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + path: nextcloud + + + - name: Sign app + run: | + # Extracting release + cd ${{ env.APP_NAME }}/build/artifacts + tar -xvf ${{ env.APP_NAME }}.tar.gz + cd ../../../ + # Setting up keys + echo '${{ secrets.APP_PRIVATE_KEY }}' > ${{ env.APP_NAME }}.key + wget --quiet "https://github.com/nextcloud/app-certificate-requests/raw/master/${{ env.APP_NAME }}/${{ env.APP_NAME }}.crt" + # Signing + php nextcloud/occ integrity:sign-app --privateKey=../${{ env.APP_NAME }}.key --certificate=../${{ env.APP_NAME }}.crt --path=../${{ env.APP_NAME }}/build/artifacts/${{ env.APP_NAME }} + # Rebuilding archive + cd ${{ env.APP_NAME }}/build/artifacts + tar -zcvf ${{ env.APP_NAME }}.tar.gz ${{ env.APP_NAME }} + + - name: Attach tarball to github release + uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # 2.11.5 + id: attach_to_release + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ${{ env.APP_NAME }}/build/artifacts/${{ env.APP_NAME }}.tar.gz + asset_name: ${{ env.APP_NAME }}-${{ env.APP_VERSION }}.tar.gz + tag: ${{ github.ref }} + overwrite: true + + - name: Upload app to Nextcloud appstore + uses: nextcloud-libraries/nextcloud-appstore-push-action@a011fe619bcf6e77ddebc96f9908e1af4071b9c1 # v1.0.3 + with: + app_name: ${{ env.APP_NAME }} + appstore_token: ${{ secrets.APPSTORE_TOKEN }} + download_url: ${{ steps.attach_to_release.outputs.browser_download_url }} + app_private_key: ${{ secrets.APP_PRIVATE_KEY }}