diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..05a30eb --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +# Fires when a v* tag is pushed — including tags created through Gitea's +# "New Release" UI. Builds the distributable plugin zip and attaches it to +# the release for that tag (creating the release if only a bare tag was +# pushed). The attached zip is what UpdateChecker serves to WordPress +# sites as the update package. + +on: + push: + tags: + - 'v*' + +jobs: + release: + name: Build and Publish Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer:v2 + + # A tag that disagrees with the plugin header would make sites see a + # phantom update forever (or never see a real one), so fail fast. + - name: Verify tag matches plugin version + id: meta + run: | + tag_version="${GITHUB_REF_NAME#v}" + header_version="$(sed -nE 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*([^[:space:]]+).*/\1/p' unsupervised-schedular.php | head -1)" + if [ "$tag_version" != "$header_version" ]; then + echo "Tag ${GITHUB_REF_NAME} does not match plugin header Version: ${header_version}" >&2 + exit 1 + fi + echo "version=${header_version}" >> "$GITHUB_OUTPUT" + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run tests + run: composer test + + - name: Build plugin zip + run: composer build + + - name: Publish release with zip asset + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" + version="${{ steps.meta.outputs.version }}" + zip="dist/unsupervised-schedular-${version}.zip" + + # Pre-release versions (1.2.3-rc.1) are flagged so Gitea's + # /releases/latest endpoint — and therefore the update checker — + # skips them. + prerelease=false + case "$version" in *-*) prerelease=true ;; esac + + # Reuse the release if the tag was created via Gitea's release UI. + release_id="$(curl -sS -H "Authorization: token ${TOKEN}" \ + "${api}/releases/tags/${GITHUB_REF_NAME}" | jq -r '.id // empty' || true)" + + if [ -z "$release_id" ]; then + release_id="$(curl -fsS -X POST "${api}/releases" \ + -H "Authorization: token ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"prerelease\":${prerelease}}" \ + | jq -r '.id')" + fi + + echo "Attaching ${zip} to release ${release_id}" + curl -fsS -X POST \ + "${api}/releases/${release_id}/assets?name=unsupervised-schedular-${version}.zip" \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${zip}" > /dev/null diff --git a/docs/features/plugin-self-update.md b/docs/features/plugin-self-update.md new file mode 100644 index 0000000..fb76ab5 --- /dev/null +++ b/docs/features/plugin-self-update.md @@ -0,0 +1,73 @@ +# Feature: Plugin Self-Update from Gitea Releases + +## Overview +WordPress sites running this plugin receive updates directly from the Gitea +repository's releases — no wordpress.org listing and no manual zip uploads. +Publishing a release is the whole deploy: bump the version, merge to `main`, +tag `vX.Y.Z` in Gitea. Every site sees the update on its next check and can +install it with one click, or unattended if the site admin enables +auto-updates for the plugin. + +## How It Works + +### Release side (`.gitea/workflows/release.yml`) +Pushing a `v*` tag (including tags created through Gitea's "New Release" UI) +triggers the release workflow, which: + +1. Fails if the tag does not match the `Version:` plugin header — a mismatch + would make sites see a phantom update forever, or never see a real one. +2. Runs the test suite. +3. Builds the distributable zip via `composer build` (`bin/build-zip.sh`): + a single top-level `unsupervised-schedular/` folder with a production + (no-dev) Composer autoloader. +4. Creates the release for the tag (or reuses one created via the UI) and + attaches the zip as a release asset. Versions containing a hyphen + (e.g. `1.2.3-rc.1`) are flagged as pre-releases. + +The attached asset — not Gitea's auto-generated source archive — is the +update package. Source archives have the wrong top-level folder name and no +`vendor/` directory, so WordPress could not install them. + +### Site side (`src/Update/UpdateChecker.php`) +The plugin header declares: + +``` +Update URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler +``` + +Since WP 5.8 that header both blocks wordpress.org from ever serving an +update for a same-slug plugin and makes core fire the +`update_plugins_git.unsupervised.ca` filter during update checks. +`UpdateChecker` (registered in `Plugin::boot()`) answers that filter: + +1. Fetches `GET /api/v1/repos/Unsupervised/unsupervised-scheduler/releases/latest` + (anonymous — the repo is public). The `/latest` endpoint excludes drafts + and pre-releases, so `-rc` builds are never offered to sites. +2. Caches the result (including failures) in the + `us_schedular_latest_release` transient for 6 hours. +3. Strips the leading `v` from the tag and compares against `USC_VERSION` + with `version_compare`; PHP orders `1.0.0-rc.2 < 1.0.0` correctly. +4. When newer, returns the release's first `.zip` asset as the update + package. Core takes over from there: Plugins-screen notice, one-click + update, and WP-Cron auto-updates if enabled. + +Any API failure, malformed response, or asset-less release degrades to +"no update available" — never an error surfaced to the site. + +## Cutting a Release +1. Bump the version in `unsupervised-schedular.php` (both the `Version:` + header and the `USC_VERSION` constant) and merge to `main`. +2. Tag the merge commit `vX.Y.Z` — via Gitea's New Release UI or + `git tag vX.Y.Z && git push origin vX.Y.Z`. +3. The release workflow attaches the zip; sites pick the update up on their + next check (twice daily via cron, or immediately from + Dashboard → Updates → Check again). + +## Classes + +| Class | Responsibility | +|---|---| +| `Update\UpdateChecker` | Answers core's `update_plugins_{hostname}` filter from the Gitea releases API | + +## Tests +- `tests/Unit/Update/UpdateCheckerTest.php` diff --git a/src/Plugin.php b/src/Plugin.php index d883c96..f32b7eb 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -29,6 +29,7 @@ use Unsupervised\Schedular\Policy\PolicyVersionRepository; use Unsupervised\Schedular\Registration\AnswerRepository; use Unsupervised\Schedular\Registration\QuestionRepository; use Unsupervised\Schedular\Registration\RegistrationGate; +use Unsupervised\Schedular\Update\UpdateChecker; class Plugin { @@ -74,6 +75,7 @@ class Plugin { $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer ); $groupClassPage = new GroupClassPage(); + ( new UpdateChecker() )->register(); ( new RoleManager() )->register(); ( new RegistrationLoginGate() )->register(); ( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register(); diff --git a/src/Update/UpdateChecker.php b/src/Update/UpdateChecker.php new file mode 100644 index 0000000..0e61430 --- /dev/null +++ b/src/Update/UpdateChecker.php @@ -0,0 +1,146 @@ +latestRelease(); + + if ( '' === $release['version'] || '' === $release['package'] ) { + return $update; + } + + if ( version_compare( $release['version'], USC_VERSION, '<=' ) ) { + return $update; + } + + return [ + 'slug' => 'unsupervised-schedular', + 'version' => $release['version'], + 'url' => self::REPO_URL, + 'package' => $release['package'], + ]; + } + + /** + * The latest published release, from the transient cache when fresh. + * + * @return array{version: string, package: string} Empty strings when no + * usable release exists. + */ + private function latestRelease(): array { + $cached = get_transient( self::TRANSIENT ); + if ( is_array( $cached ) ) { + return [ + 'version' => Val::string( $cached['version'] ?? '' ), + 'package' => Val::string( $cached['package'] ?? '' ), + ]; + } + + $release = $this->fetchLatestRelease(); + set_transient( self::TRANSIENT, $release, self::CACHE_TTL ); + + return $release; + } + + /** + * Ask the Gitea API for the latest published release's version and zip asset. + * + * @return array{version: string, package: string} + */ + private function fetchLatestRelease(): array { + $none = [ + 'version' => '', + 'package' => '', + ]; + + $response = wp_remote_get( + self::API_URL, + [ + 'timeout' => 10, + 'headers' => [ 'Accept' => 'application/json' ], + ] + ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return $none; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! is_array( $body ) ) { + return $none; + } + + // Release tags are named v1.2.3; the plugin header carries the bare version. + $version = preg_replace( '/^v/i', '', Val::string( $body['tag_name'] ?? '' ) ) ?? ''; + + // The release workflow attaches the built plugin zip (top-level + // unsupervised-schedular/ folder, production autoloader) as an asset. + // Gitea's auto-generated source archives are not usable packages. + $package = ''; + $assets = $body['assets'] ?? null; + if ( is_array( $assets ) ) { + foreach ( $assets as $asset ) { + if ( ! is_array( $asset ) ) { + continue; + } + if ( str_ends_with( strtolower( Val::string( $asset['name'] ?? '' ) ), '.zip' ) ) { + $package = Val::string( $asset['browser_download_url'] ?? '' ); + break; + } + } + } + + if ( '' === $version || '' === $package ) { + return $none; + } + + return [ + 'version' => $version, + 'package' => $package, + ]; + } +} diff --git a/tests/Unit/Update/UpdateCheckerTest.php b/tests/Unit/Update/UpdateCheckerTest.php new file mode 100644 index 0000000..8bf0981 --- /dev/null +++ b/tests/Unit/Update/UpdateCheckerTest.php @@ -0,0 +1,177 @@ +justReturn(['response' => ['code' => $code]]); + Functions\when('is_wp_error')->justReturn(false); + Functions\when('wp_remote_retrieve_response_code')->justReturn($code); + Functions\when('wp_remote_retrieve_body')->justReturn(json_encode($body)); + } + + private function release(string $tag, array $assets): array + { + return ['tag_name' => $tag, 'assets' => $assets]; + } + + private function zipAsset(string $name = 'unsupervised-schedular-9.9.9.zip'): array + { + return ['name' => $name, 'browser_download_url' => self::PACKAGE_URL]; + } + + public function testRegisterHooksHostnameFilter(): void + { + Filters\expectAdded('update_plugins_git.unsupervised.ca')->once(); + + (new UpdateChecker())->register(); + } + + public function testOffersUpdateWhenReleaseIsNewer(): void + { + Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); + Functions\when('get_transient')->justReturn(false); + // USC_VERSION is 1.0.0 in the test bootstrap. + $this->stubApiResponse(200, $this->release('v9.9.9', [$this->zipAsset()])); + Functions\expect('set_transient')->once()->with( + UpdateChecker::TRANSIENT, + ['version' => '9.9.9', 'package' => self::PACKAGE_URL], + \Mockery::type('int') + ); + + $result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE); + + self::assertSame( + [ + 'slug' => 'unsupervised-schedular', + 'version' => '9.9.9', + 'url' => UpdateChecker::REPO_URL, + 'package' => self::PACKAGE_URL, + ], + $result + ); + } + + public function testReturnsUpdateUnchangedForOtherPlugins(): void + { + Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); + Functions\expect('wp_remote_get')->never(); + + $result = (new UpdateChecker())->provideUpdate(false, [], 'other-plugin/other-plugin.php'); + + self::assertFalse($result); + } + + public function testNoUpdateWhenReleaseIsNotNewer(): void + { + Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); + Functions\when('get_transient')->justReturn(false); + Functions\when('set_transient')->justReturn(true); + $this->stubApiResponse(200, $this->release('v1.0.0', [$this->zipAsset('unsupervised-schedular-1.0.0.zip')])); + + $result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE); + + self::assertFalse($result); + } + + public function testUsesCachedReleaseWithoutHittingApi(): void + { + Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); + Functions\when('get_transient')->justReturn(['version' => '2.0.0', 'package' => self::PACKAGE_URL]); + Functions\expect('wp_remote_get')->never(); + Functions\expect('set_transient')->never(); + + $result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE); + + self::assertIsArray($result); + self::assertSame('2.0.0', $result['version']); + } + + public function testApiFailureIsCachedAndReturnsUpdateUnchanged(): void + { + Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); + Functions\when('get_transient')->justReturn(false); + Functions\when('wp_remote_get')->justReturn('irrelevant'); + Functions\when('is_wp_error')->justReturn(true); + // A failed lookup is cached too, so a down Gitea is not re-polled + // on every admin page load. + Functions\expect('set_transient')->once()->with( + UpdateChecker::TRANSIENT, + ['version' => '', 'package' => ''], + \Mockery::type('int') + ); + + $result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE); + + self::assertFalse($result); + } + + public function testNon200ResponseReturnsUpdateUnchanged(): void + { + Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); + Functions\when('get_transient')->justReturn(false); + Functions\when('set_transient')->justReturn(true); + $this->stubApiResponse(404, ['message' => 'Not Found']); + + $result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE); + + self::assertFalse($result); + } + + public function testPicksFirstZipAssetAndSkipsOthers(): void + { + Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); + Functions\when('get_transient')->justReturn(false); + Functions\when('set_transient')->justReturn(true); + $this->stubApiResponse(200, $this->release('v9.9.9', [ + ['name' => 'release-notes.pdf', 'browser_download_url' => 'https://example.test/notes.pdf'], + $this->zipAsset(), + ])); + + $result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE); + + self::assertIsArray($result); + self::assertSame(self::PACKAGE_URL, $result['package']); + } + + public function testReleaseWithoutZipAssetOffersNoUpdate(): void + { + Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); + Functions\when('get_transient')->justReturn(false); + Functions\when('set_transient')->justReturn(true); + $this->stubApiResponse(200, $this->release('v9.9.9', [ + ['name' => 'release-notes.pdf', 'browser_download_url' => 'https://example.test/notes.pdf'], + ])); + + $result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE); + + self::assertFalse($result); + } + + public function testMalformedApiBodyOffersNoUpdate(): void + { + Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); + Functions\when('get_transient')->justReturn(false); + Functions\when('set_transient')->justReturn(true); + Functions\when('wp_remote_get')->justReturn(['response' => ['code' => 200]]); + Functions\when('is_wp_error')->justReturn(false); + Functions\when('wp_remote_retrieve_response_code')->justReturn(200); + Functions\when('wp_remote_retrieve_body')->justReturn('not json'); + + $result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE); + + self::assertFalse($result); + } +} diff --git a/uninstall.php b/uninstall.php index 636bcbb..76cfba1 100644 --- a/uninstall.php +++ b/uninstall.php @@ -16,3 +16,4 @@ remove_role('us_instructor'); remove_role('us_student'); delete_option('us_schedular_version'); +delete_transient('us_schedular_latest_release'); diff --git a/unsupervised-schedular.php b/unsupervised-schedular.php index 3b6b932..a14febd 100644 --- a/unsupervised-schedular.php +++ b/unsupervised-schedular.php @@ -9,6 +9,7 @@ * Author: Unsupervised * License: GPL-2.0-or-later * Text Domain: unsupervised-schedular + * Update URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler */ declare(strict_types=1);