diff --git a/.github/workflows/workflow-policy.yml b/.github/workflows/workflow-policy.yml index 903dd17..9087d02 100644 --- a/.github/workflows/workflow-policy.yml +++ b/.github/workflows/workflow-policy.yml @@ -8,6 +8,7 @@ on: paths: - '.github/workflows/**' - 'workflow-templates/**' + - 'actions/**' - 'scripts/check_workflow_policy.py' - 'tests/test_workflow_policy.py' push: @@ -16,6 +17,7 @@ on: paths: - '.github/workflows/**' - 'workflow-templates/**' + - 'actions/**' - 'scripts/check_workflow_policy.py' - 'tests/test_workflow_policy.py' diff --git a/actions/nextcloud-appstore-publish/action.yml b/actions/nextcloud-appstore-publish/action.yml new file mode 100644 index 0000000..e2ebb5a --- /dev/null +++ b/actions/nextcloud-appstore-publish/action.yml @@ -0,0 +1,307 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Build and publish Nextcloud app release +description: Build, package, sign, attach and publish a tagged Nextcloud app release. + +inputs: + app-name: + description: Nextcloud app id and repository directory name. + required: true + release-tag: + description: Existing Git tag to build and publish. + required: true + github-token: + description: Token used to validate and update the GitHub release. + required: true + app-private-key: + description: Nextcloud app private key. + required: true + appstore-token: + description: Nextcloud App Store token. + required: true + checkout-submodules: + description: Checkout application git submodules. + required: false + default: 'false' + make-signs-app: + description: Let the Makefile sign the app instead of the generic post-package signing step. + required: false + default: 'false' + require-setup-signatures: + description: Require setup integrity metadata in Makefile-built packages. + required: false + default: 'false' + manual-recovery: + description: Use packaging tooling from the workflow ref while keeping tagged application source immutable. + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Check actor permission + uses: skjnldsv/check-actor-permission@69e92a3c4711150929bca9fcf34448c5bf5526e7 # v3.0 + with: + require: write + token: ${{ inputs.github-token }} + + - name: Validate boolean inputs + shell: bash + env: + CHECKOUT_SUBMODULES: ${{ inputs.checkout-submodules }} + MAKE_SIGNS_APP: ${{ inputs.make-signs-app }} + REQUIRE_SETUP_SIGNATURES: ${{ inputs.require-setup-signatures }} + MANUAL_RECOVERY: ${{ inputs.manual-recovery }} + run: | + set -euo pipefail + for value in "${CHECKOUT_SUBMODULES}" "${MAKE_SIGNS_APP}" "${REQUIRE_SETUP_SIGNATURES}" "${MANUAL_RECOVERY}"; do + case "${value}" in + true|false) ;; + *) + echo "::error::Boolean inputs must be 'true' or 'false'" + exit 2 + ;; + esac + done + + - name: Checkout application + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: ${{ inputs.checkout-submodules }} + fetch-tags: true + fetch-depth: 0 + ref: ${{ inputs.release-tag }} + path: ${{ inputs.app-name }} + + - name: Validate release identity + id: release_identity + uses: LibreCodeCoop/release-tool/actions/release-identity@385ca7732db12e5c79590bb21be8da3608194595 + with: + tag: ${{ inputs.release-tag }} + working-directory: ${{ inputs.app-name }} + require-tag-exists: 'true' + + - name: Get appinfo data + id: appinfo + uses: skjnldsv/xpath-action@f5b036e9d973f42c86324833fd00be90665fbf77 # v1.0.0 + with: + filename: ${{ inputs.app-name }}/appinfo/info.xml + expression: "//info//dependencies//nextcloud/@min-version" + + - name: Read package engines + id: versions + continue-on-error: true + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + with: + path: ${{ inputs.app-name }} + fallbackNode: '^24' + + - name: Set up node + 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: Resolve PHP version + id: php_versions + uses: nextcloud-libraries/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + with: + filename: ${{ inputs.app-name }}/appinfo/info.xml + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ steps.php_versions.outputs.php-min }} + coverage: none + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + + - name: Check composer.json + id: check_composer + uses: andstor/file-existence-action@558493d6c74bf472d87c84eab196434afc2fa029 # v3.1.0 + with: + files: "${{ inputs.app-name }}/composer.json" + + - name: Install composer dependencies + if: steps.check_composer.outputs.files_exists == 'true' + uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # v4.0.0 + with: + composer-options: '--no-dev' + working-directory: ${{ inputs.app-name }} + ignore-cache: 'yes' + + - name: Build application + if: ${{ steps.versions.outputs.nodeVersion }} + shell: bash + env: + APP_NAME: ${{ inputs.app-name }} + CYPRESS_INSTALL_BINARY: 0 + run: | + set -euo pipefail + cd "${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: ${{ inputs.app-name }}/krankerl.toml + + - name: Install Krankerl + if: steps.krankerl.outputs.files_exists == 'true' + shell: bash + run: | + set -euo pipefail + wget --quiet 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 with Krankerl + if: steps.krankerl.outputs.files_exists == 'true' + shell: bash + env: + APP_NAME: ${{ inputs.app-name }} + run: | + set -euo pipefail + cd "${APP_NAME}" + krankerl package + + - name: Resolve Nextcloud server download + id: server_url + if: steps.krankerl.outputs.files_exists != 'true' + shell: bash + env: + NC_VERSION: ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} + run: | + set -euo pipefail + download_url="$(curl --fail --silent --show-error "https://updates.nextcloud.com/updater_server/latest?channel=beta&version=${NC_VERSION}" | jq -r '.downloads.zip[0]')" + printf 'download_url=%s\n' "${download_url}" >> "${GITHUB_OUTPUT}" + + - name: Download Nextcloud server + id: server_download + if: steps.krankerl.outputs.files_exists != 'true' && steps.server_url.outputs.download_url != 'null' + continue-on-error: true + shell: bash + env: + DOWNLOAD_URL: ${{ steps.server_url.outputs.download_url }} + run: | + set -euo pipefail + wget "${DOWNLOAD_URL}" -O nextcloud.zip + unzip -q nextcloud.zip + + - name: Checkout Nextcloud server fallback + if: steps.krankerl.outputs.files_exists != 'true' && steps.server_download.outcome != 'success' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + path: nextcloud + + - name: Validate manual recovery source + if: inputs.manual-recovery == 'true' && steps.krankerl.outputs.files_exists != 'true' + shell: bash + env: + APP_NAME: ${{ inputs.app-name }} + RELEASE_TAG: ${{ inputs.release-tag }} + run: | + set -euo pipefail + case "${GITHUB_REF_NAME}" in + stable*) ;; + *) + echo "::error::Manual release recovery must be dispatched from a stable branch" + exit 1 + ;; + esac + cd "${APP_NAME}" + git fetch --quiet origin "${GITHUB_SHA}" + tag_sha="$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" + if ! git merge-base --is-ancestor "${tag_sha}" "${GITHUB_SHA}"; then + echo "::error::${GITHUB_REF_NAME} does not contain ${RELEASE_TAG}; refusing cross-line recovery" + exit 1 + fi + git show "${GITHUB_SHA}:Makefile" > Makefile + + - name: Package with Makefile + if: steps.krankerl.outputs.files_exists != 'true' + shell: bash + env: + APP_NAME: ${{ inputs.app-name }} + APP_PRIVATE_KEY: ${{ inputs.app-private-key }} + REQUIRE_SETUP_SIGNATURES: ${{ inputs.require-setup-signatures }} + MAKE_SIGNS_APP: ${{ inputs.make-signs-app }} + run: | + set -euo pipefail + cd "${APP_NAME}" + if [[ "${MAKE_SIGNS_APP}" == "true" ]]; then + mkdir -p build/tools/certificates + printf '%s' "${APP_PRIVATE_KEY}" > "build/tools/certificates/${APP_NAME}.key" + fi + make appstore + if make -qp 2>/dev/null | grep -q '^verify-appstore-package:'; then + REQUIRE_SETUP_SIGNATURES="${REQUIRE_SETUP_SIGNATURES}" make verify-appstore-package + fi + + - name: Sign generic Makefile package + if: steps.krankerl.outputs.files_exists != 'true' && inputs.make-signs-app != 'true' + shell: bash + env: + APP_NAME: ${{ inputs.app-name }} + APP_PRIVATE_KEY: ${{ inputs.app-private-key }} + run: | + set -euo pipefail + cd "${APP_NAME}/build/artifacts" + tar -xvf "${APP_NAME}.tar.gz" + cd ../../.. + printf '%s' "${APP_PRIVATE_KEY}" > "${APP_NAME}.key" + wget --quiet "https://github.com/nextcloud/app-certificate-requests/raw/master/${APP_NAME}/${APP_NAME}.crt" + php nextcloud/occ integrity:sign-app --privateKey="../${APP_NAME}.key" --certificate="../${APP_NAME}.crt" --path="../${APP_NAME}/build/artifacts/${APP_NAME}" + cd "${APP_NAME}/build/artifacts" + tar -zcvf "${APP_NAME}.tar.gz" "${APP_NAME}" + + - name: Set up PHP 8.3 for release-tool + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: '8.3' + coverage: none + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + + - name: Validate release artifact + uses: LibreCodeCoop/release-tool/actions/artifact-validate@385ca7732db12e5c79590bb21be8da3608194595 + with: + artifact: ${{ inputs.app-name }}/build/artifacts/${{ inputs.app-name }}.tar.gz + app-name: ${{ inputs.app-name }} + version: ${{ steps.release_identity.outputs.version }} + + - name: Attach tarball to GitHub release + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + APP_NAME: ${{ inputs.app-name }} + RELEASE_TAG: ${{ inputs.release-tag }} + run: | + set -euo pipefail + source_asset="${APP_NAME}/build/artifacts/${APP_NAME}.tar.gz" + asset_name="${APP_NAME}-${RELEASE_TAG}.tar.gz" + publish_asset="${RUNNER_TEMP}/${asset_name}" + cp "${source_asset}" "${publish_asset}" + gh release upload "${RELEASE_TAG}" "${publish_asset}" --clobber --repo "${GITHUB_REPOSITORY}" + + - name: Upload app to Nextcloud App Store + uses: nextcloud-libraries/nextcloud-appstore-push-action@a011fe619bcf6e77ddebc96f9908e1af4071b9c1 # v1.0.3 + with: + app_name: ${{ inputs.app-name }} + appstore_token: ${{ inputs.appstore-token }} + download_url: https://github.com/${{ github.repository }}/releases/download/${{ inputs.release-tag }}/${{ inputs.app-name }}-${{ inputs.release-tag }}.tar.gz + app_private_key: ${{ inputs.app-private-key }} + + - name: Verify App Store publication + uses: LibreCodeCoop/release-tool/actions/appstore-publication-wait@385ca7732db12e5c79590bb21be8da3608194595 + with: + app-name: ${{ inputs.app-name }} + version: ${{ steps.release_identity.outputs.version }} + platform: ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} diff --git a/scripts/check_workflow_policy.py b/scripts/check_workflow_policy.py index 6f54727..982bfe2 100644 --- a/scripts/check_workflow_policy.py +++ b/scripts/check_workflow_policy.py @@ -69,7 +69,7 @@ def main() -> int: "roots", nargs="*", type=Path, - default=[Path("workflow-templates"), Path(".github/workflows")], + default=[Path("workflow-templates"), Path(".github/workflows"), Path("actions")], ) args = parser.parse_args() diff --git a/tests/test_appstore_publication_contract.py b/tests/test_appstore_publication_contract.py new file mode 100644 index 0000000..8524918 --- /dev/null +++ b/tests/test_appstore_publication_contract.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ACTION = ROOT / "actions/nextcloud-appstore-publish/action.yml" + + +class AppStorePublicationContractTest(unittest.TestCase): + def test_external_actions_are_immutable(self) -> None: + content = ACTION.read_text(encoding="utf-8") + revisions = re.findall(r"^\s*uses:\s*([^@\s]+)@([^\s#]+)", content, re.MULTILINE) + self.assertTrue(revisions) + for action, revision in revisions: + if action.startswith("./"): + continue + self.assertRegex( + revision, + r"^[0-9a-f]{40}$", + msg=f"{action} is not pinned to an immutable SHA: {revision}", + ) + + def test_uses_does_not_interpolate_revisions(self) -> None: + content = ACTION.read_text(encoding="utf-8") + for line in content.splitlines(): + if "uses:" in line: + self.assertNotIn("${{", line) + + def test_manual_recovery_is_release_line_safe(self) -> None: + content = ACTION.read_text(encoding="utf-8") + self.assertIn('case "${GITHUB_REF_NAME}" in', content) + self.assertIn("stable*)", content) + self.assertIn('git merge-base --is-ancestor "${tag_sha}" "${GITHUB_SHA}"', content) + self.assertIn('git show "${GITHUB_SHA}:Makefile" > Makefile', content) + + def test_release_source_always_comes_from_tag(self) -> None: + content = ACTION.read_text(encoding="utf-8") + self.assertIn("ref: ${{ inputs.release-tag }}", content) + self.assertIn("require-tag-exists: 'true'", content) + + def test_expression_ids_are_safe(self) -> None: + content = ACTION.read_text(encoding="utf-8") + ids = re.findall(r"^\s*id:\s*([^\s]+)", content, re.MULTILINE) + for step_id in ids: + self.assertRegex(step_id, r"^[A-Za-z_][A-Za-z0-9_]*$") + + def test_does_not_write_untrusted_values_to_github_env(self) -> None: + content = ACTION.read_text(encoding="utf-8") + self.assertNotIn("GITHUB_ENV", content) + + def test_release_upload_uses_runner_cli(self) -> None: + content = ACTION.read_text(encoding="utf-8") + self.assertNotIn("svenstaro/upload-release-action", content) + self.assertIn('gh release upload "${RELEASE_TAG}"', content) + + def test_asset_name_matches_appstore_download_url(self) -> None: + content = ACTION.read_text(encoding="utf-8") + self.assertIn('asset_name="${APP_NAME}-${RELEASE_TAG}.tar.gz"', content) + self.assertIn( + "releases/download/${{ inputs.release-tag }}/${{ inputs.app-name }}-${{ inputs.release-tag }}.tar.gz", + content, + ) + + +if __name__ == "__main__": + unittest.main()