diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 05dc293..1eb81a2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,54 +1,107 @@ name: Build Docs on: - release: - types: [published] - workflow_dispatch: + pull_request: + paths: ['src/**', 'docs/**', 'tools/*docs.php', 'phpdoc.dist.xml', '.github/workflows/docs.yml'] push: + branches: [main] + paths: ['src/**', 'docs/**', 'tools/*docs.php', 'phpdoc.dist.xml', '.github/workflows/docs.yml'] + workflow_dispatch: permissions: - contents: write + contents: read + +concurrency: + group: documentation-${{ github.ref }} + cancel-in-progress: false jobs: - docs: - runs-on: ubuntu-latest - # A published release, a manual dispatch, or a push whose head commit message - # contains "build docs" - the convention DiscordPHP uses, so the site is not - # rebuilt and redeployed on every commit. - if: >- - github.event_name == 'release' - || github.event_name == 'workflow_dispatch' - || (github.event_name == 'push' && contains(github.event.head_commit.message, 'build docs')) + build: + runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - - name: Checkout - uses: actions/checkout@v4 - + - name: Checkout source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@db91e1a0e48e84637d325a7ee4d2677e146ceec4 # 2.36.0 with: php-version: '8.4' extensions: iconv, mbstring - tools: composer - - # The DiscordPHP family's build of phpDocumentor, carrying its patches for - # `?T|null` types. Not on Packagist, hence the explicit repository. - - name: Install phpDocumentor + coverage: none + tools: composer:2.9.8 + - name: Install local PlantUML and Graphviz for the class diagram + run: | + sudo apt-get update -qq + sudo apt-get install --yes --no-install-recommends graphviz plantuml + - name: Checkout pinned phpDocumentor toolchain + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: discord-php/phpdoc-tool + ref: 40e5911a592e5fc4407096cebd5fb4aaf2e9df6f + path: phpdoc-tool + persist-credentials: false + - name: Install locked toolchain and reviewed nullable-type patches run: >- - composer create-project discord-php/phpdoc-tool:^1.0 phpdoc-tool - --no-interaction --no-progress - --repository='{"type":"vcs","url":"https://github.com/discord-php/phpdoc-tool"}' - - - name: Build the reference - run: php tools/build-docs.php - - - name: Disable Jekyll - # Without this, GitHub Pages hides every path beginning with an underscore. - run: touch build/.nojekyll + composer install --working-dir=phpdoc-tool --no-dev --no-interaction + --no-scripts --prefer-dist --no-progress + - name: Generate and validate public output + env: + PHPDOC_PLANTUML: plantuml + PHPDOC_PLANTUML_BIN: /usr/bin/plantuml + run: | + php tools/build-docs.php + php tools/check-docs.php + - name: Archive only generated public files + run: tar --directory build -cf "$RUNNER_TEMP/artifact.tar" . + - name: Retain Pages artifact for one day + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: github-pages + path: ${{ runner.temp }}/artifact.tar + retention-days: 1 + if-no-files-found: error - - name: Publish to gh-pages - uses: JamesIves/github-pages-deploy-action@v4 + publish: + needs: build + if: >- + github.event_name != 'pull_request' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-24.04 + timeout-minutes: 8 + permissions: + contents: write # GitHub cannot branch-scope this; only generated gh-pages is written. + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout repository metadata for gh-pages publisher + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Download this run's generated site + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: github-pages + path: pages-artifact + - name: Restore the validated site + run: | + mkdir site + tar -xf pages-artifact/artifact.tar --directory site + - name: Preserve generated gh-pages copy + uses: JamesIves/github-pages-deploy-action@fa24774553152dd7873cd16ebd8d959b010c5445 # v4 with: token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages - folder: build + folder: site clean: true + clean-exclude: CNAME + # GITHUB_TOKEN pushes do not themselves trigger a branch Pages build. + # Explicitly deploy the same artifact instead of assuming gh-pages is live. + - name: Publish the exact Pages artifact + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/README.md b/README.md index b96f0d9..6d2b60c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ reachable from PHP. ## Requirements +[Documentation and guides](https://valgorithms.github.io/YoutubePHP/) ยท +[GitHub Actions uploader setup](https://valgorithms.github.io/YoutubePHP/guides/github-actions-uploader.html) + - PHP 8.4 or newer - ext-json, ext-mbstring diff --git a/docs/github-actions-uploader.md b/docs/github-actions-uploader.md index 1703d6d..e3e0c02 100644 --- a/docs/github-actions-uploader.md +++ b/docs/github-actions-uploader.md @@ -7,7 +7,7 @@ schedule. A PR producing a video must never receive the channel credentials. YouTubePHP supplies the API client, OAuth refresh and multipart media calls. It does **not** supply Google consent, GitHub environment configuration, artifact provenance, human content approval or a durable upload journal. Those are the -repository integration's responsibility. The [small PHP example](../examples/ReviewedUpload.php) +repository integration's responsibility. The [small PHP example](https://github.com/Valgorithms/YoutubePHP/blob/main/examples/ReviewedUpload.php) is tested with a fake transport; it deliberately has no executable CLI or default journal. The [Civ13 example](civ13-uploader-example.md) links a complete, separately tested integration, including its controller and setup helper. **Copying the two diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..2a324ed --- /dev/null +++ b/docs/index.md @@ -0,0 +1,15 @@ +# YouTubePHP guides + +YouTubePHP is an asynchronous ReactPHP client for the YouTube Data API v3. +The generated class reference documents the API surface. These guides explain +how to connect it to a reviewed application. + +- [GitHub Actions uploader setup](github-actions-uploader.md): Google consent, + protected credentials, workflow templates, library calls and required controller + safeguards for a manually approved video upload. +- [Civ13 uploader case study](civ13-uploader-example.md): the exact tested source, + successful run, durable receipt and fixes behind a working deployment. +- [Building and publishing these docs](publishing.md): the pinned phpDocumentor + toolchain, local validation and bounded GitHub Pages deployment. + +Source and issues: [Valgorithms/YoutubePHP](https://github.com/Valgorithms/YoutubePHP). diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100644 index 0000000..12dad9d --- /dev/null +++ b/docs/publishing.md @@ -0,0 +1,57 @@ +# Building and publishing the documentation + +`phpdoc.dist.xml` builds the API reference from `src/` and Markdown guides from +`docs/`. Only the generated `build/` directory is published. No credentials, +client JSON, source dependencies or private evidence trees belong in that output. + +The existing DiscordPHP-family phpDocumentor toolchain is pinned in the workflow +to `40e5911a592e5fc4407096cebd5fb4aaf2e9df6f`, with its own committed Composer lock +and reviewed nullable-type patches. Its Composer patch plugin is intentional; +the uploader consumer's no-plugin installation policy is a different project. +Graphviz `dot` and local PlantUML must be installed for the generated class diagram. +Set `PHPDOC_PLANTUML=plantuml` and `PHPDOC_PLANTUML_BIN` to the local PlantUML +executable (CI uses `/usr/bin/plantuml`). The Composer distribution does not bundle +the binary assumed by its default renderer. No remote diagram service is used. +The configuration +also explicitly sets `graphs.enabled=true`; phpDocumentor disables graph rendering +by default even though its default template emits a diagram-page link. + +For a local build, install that toolchain in `phpdoc-tool/` as its own project, +or point `PHPDOC` at an existing trusted toolchain's `vendor/bin/phpdoc`. Then run: + +```bash +php tools/build-docs.php +php tools/check-docs.php +php -S 127.0.0.1:8000 -t build +``` + +The checker verifies required API/guide pages, all local href/src targets with +HTML base semantics, output size (32 MiB), regular files, and absence of common +private-file/diagnostic/token patterns. These are bounded smoke checks, not a +claim that an arbitrary secret is detectable. Review source content before merging. +The docs builder keeps tool diagnostics out of public HTML buffers. + +The Build Docs workflow validates PRs without deploying. Default-branch docs/source +pushes and manual default-branch dispatches generate and validate the site, retain +one artifact for one day, then publish that same artifact. Builds use read-only +repository permissions; only the default-branch publish job receives Contents +write (for `gh-pages`), Pages write and the OIDC token used by `deploy-pages`. +Jobs use standard public Ubuntu runners, timeouts, no schedule or paid service. + +The `gh-pages` branch contains generated files. `CNAME`, if present, is preserved; +do not add unrelated content to a generated branch. No custom domain is configured +by this change. GitHub-token pushes alone do not trigger branch-based Pages builds, +so the workflow also uses GitHub's Pages artifact deployment explicitly. Configure +the repository's Pages publishing source as **GitHub Actions**. The Pages settings +and `github-pages` environment are separate from any YouTube upload environment; +this workflow never accesses Google credentials or changes upload gates. + +Before calling the site live, verify the deployment's source revision, successful +publish job, configured Pages URL and anonymous responses for the landing page, +API reference, uploader guide and case study. Source generation alone is not a +live deployment. The default address is +`https://valgorithms.github.io/YoutubePHP/`. + +References: [phpDocumentor configuration](https://docs.phpdoc.org/guide/references/configuration.html), +[custom Pages workflows](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages), +[Pages publishing source](https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site). diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml index b29bc49..2eb8338 100644 --- a/phpdoc.dist.xml +++ b/phpdoc.dist.xml @@ -15,7 +15,14 @@ src - reference + . + + + docs + + guides + + diff --git a/tests/DocsOutputTest.php b/tests/DocsOutputTest.php new file mode 100644 index 0000000..ce31099 --- /dev/null +++ b/tests/DocsOutputTest.php @@ -0,0 +1,84 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE file. + */ + +namespace YouTube\Tests; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +require_once dirname(__DIR__) . '/tools/check-docs.php'; + +final class DocsOutputTest extends TestCase +{ + private string $root; + + protected function setUp(): void + { + $path = sys_get_temp_dir() . '/youtubephp-docs-' . bin2hex(random_bytes(8)); + mkdir($path); + $this->root = realpath($path); + mkdir($this->root . '/guides'); + mkdir($this->root . '/images'); + foreach (['index.html', 'guides/page.html', 'images/icon.png'] as $file) { + file_put_contents($this->root . '/' . $file, 'offline fixture'); + } + } + + protected function tearDown(): void + { + foreach (['index.html', 'guides/page.html', 'images/icon.png'] as $file) { + unlink($this->root . '/' . $file); + } + rmdir($this->root . '/guides'); + rmdir($this->root . '/images'); + rmdir($this->root); + } + + public function testPhpDocumentorBaseResolvesAssetsAndRootNavigation(): void + { + docsCheckLinks( + $this->root, + $this->root . '/guides/page.html', + 'Home', + ); + $this->assertSame($this->root, docsLocalTarget($this->root, $this->root . '/guides', '../')); + } + + public function testWithoutBaseLinksRemainRelativeToDocument(): void + { + docsCheckLinks( + $this->root, + $this->root . '/guides/page.html', + 'Here', + ); + $this->assertSame( + $this->root . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'icon.png', + docsLocalTarget($this->root, $this->root . '/guides', '../images/icon.png'), + ); + } + + public static function invalidCases(): array + { + return [ + [''], + [''], + [''], + ['escape'], + ]; + } + + #[DataProvider('invalidCases')] + public function testBrokenAssetsAndEscapingBasesStillFail(string $markup): void + { + $this->expectException(\RuntimeException::class); + docsCheckLinks($this->root, $this->root . '/guides/page.html', '' . $markup . ''); + } +} diff --git a/tools/build-docs.php b/tools/build-docs.php index b2e5790..ad42990 100644 --- a/tools/build-docs.php +++ b/tools/build-docs.php @@ -65,7 +65,14 @@ exit(1); } -$arguments = array_map('escapeshellarg', [PHP_BINARY, $binary, '--config', $root . '/phpdoc.dist.xml', '--no-interaction']); +// Template output buffers must not include local diagnostic paths/stack traces. +// The pinned league/uri tool emits PHP 8.5 deprecations; other errors stay visible. +$arguments = array_map('escapeshellarg', [ + PHP_BINARY, + '-d', 'display_errors=stderr', + '-d', 'error_reporting=' . (E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED), + $binary, '--config', $root . '/phpdoc.dist.xml', '--no-interaction', +]); fwrite(STDERR, "Building the reference...\n"); passthru(implode(' ', $arguments), $status); diff --git a/tools/check-docs.php b/tools/check-docs.php new file mode 100644 index 0000000..daa4f70 --- /dev/null +++ b/tools/check-docs.php @@ -0,0 +1,115 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE file. + */ + +/** Resolve a local URL against a directory without permitting escape from the site. */ +function docsLocalTarget(string $root, string $directory, string $href): ?string +{ + $href = html_entity_decode($href, ENT_QUOTES | ENT_HTML5); + if (preg_match('~^(?:[a-z][a-z0-9+.-]*:|//|#)~i', $href)) { + return null; + } + $path = rawurldecode(explode('?', explode('#', $href, 2)[0], 2)[0]); + if ($path === '') { + return null; + } + // Pages is published under /YoutubePHP/, not the origin root. + if (str_starts_with($path, '/') || str_contains($path, '\\') || str_contains($path, "\0")) { + throw new RuntimeException('Unsupported root/escaped local URL'); + } + $target = realpath($directory . '/' . $path); + if ($target === false || ($target !== $root && ! str_starts_with($target, $root . DIRECTORY_SEPARATOR))) { + throw new RuntimeException('Broken/local-escaping link: ' . $path); + } + + return $target; +} + +/** Respect the first HTML base element; it must itself resolve within this site. */ +function docsCheckLinks(string $root, string $file, string $content): void +{ + $document = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + try { + if (! $document->loadHTML($content)) { + throw new RuntimeException('Invalid generated HTML'); + } + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + $directory = dirname($file); + $base = $document->getElementsByTagName('base')->item(0); + if ($base !== null && $base->hasAttribute('href')) { + $href = html_entity_decode($base->getAttribute('href'), ENT_QUOTES | ENT_HTML5); + if (preg_match('~^(?:[a-z][a-z0-9+.-]*:|//)~i', $href)) { + throw new RuntimeException('External HTML base is not allowed'); + } + $target = docsLocalTarget($root, $directory, $href); + if ($target !== null) { + $path = explode('?', explode('#', $href, 2)[0], 2)[0]; + $directory = str_ends_with($path, '/') ? $target : dirname($target); + } + } + foreach ($document->getElementsByTagName('*') as $node) { + if ($node->nodeName === 'base') { + continue; // Resolved against the document, not against itself. + } + foreach (['href', 'src'] as $attribute) { + if ($node->hasAttribute($attribute)) { + docsLocalTarget($root, $directory, $node->getAttribute($attribute)); + } + } + } +} + +if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') !== __FILE__) { + return; // Tests can exercise URL semantics without reading a real build. +} + +/** Bounded smoke check of the actual public build, never the repository tree. */ +$root = realpath(dirname(__DIR__) . '/build'); +if ($root === false) { + throw new RuntimeException('Build output missing'); +} +$required = ['index.html', 'classes/YouTube-YouTube.html', 'graphs/classes.svg', 'guides/index.html', + 'guides/github-actions-uploader.html', 'guides/civ13-uploader-example.html']; +foreach ($required as $path) { + if (! is_file($root . '/' . $path)) { + throw new RuntimeException('Required generated page missing: ' . $path); + } +} +$bytes = 0; +$html = 0; +$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)); +foreach ($iterator as $file) { + if ($file->isLink() || ! $file->isFile()) { + throw new RuntimeException('Only regular generated files may be published'); + } + $relative = str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1)); + if (preg_match('~(^|/)(vendor|\.git|\.env|composer\.(json|lock)|client[^/]*\.json)(/|$)~i', $relative)) { + throw new RuntimeException('Private or dependency file in public output'); + } + $bytes += $file->getSize(); + if ($bytes > 32 * 1024 * 1024) { + throw new RuntimeException('Generated site exceeds 32 MiB cap'); + } + $content = file_get_contents($file->getPathname()); + if (preg_match('~PHP Stack trace|PHP (?:Deprecated|Warning|Fatal error):|[A-Z]:[/\\\\]Users[/\\\\]|/home/runner/work/|\bya29\.[A-Za-z0-9_-]{20,}|\b1//[A-Za-z0-9_-]{30,}~', $content)) { + throw new RuntimeException('Diagnostic, local path or token-shaped content in public HTML'); + } + if ($file->getExtension() !== 'html') { + continue; + } + ++$html; + docsCheckLinks($root, $file->getPathname(), $content); +} +file_put_contents($root . '/.nojekyll', ''); +printf("Public docs checked: %d HTML pages, %d bytes; required API and uploader guides present.\n", $html, $bytes);