diff --git a/README.md b/README.md index 88c3bc6..b96f0d9 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,16 @@ $youtube = new YouTube([ ## Signing in +For a reviewed, manual GitHub Actions uploader, follow +[Uploading videos from GitHub Actions](docs/github-actions-uploader.md). +It covers Desktop OAuth with the narrow upload/readback scopes, protected +environment secrets, reusable workflows, durable receipts and uncertain outcomes. +The [Civ13 case study](docs/civ13-uploader-example.md) records the working integration. +The device-flow example below is for long-running bots; it is not the uploader setup. + A bot signs in once, as the account whose channel it works for, with Google's device flow: it shows a code, and someone enters it at `google.com/device`. Google then issues a refresh token, -which does not expire and is not rotated, so the bot mints its hour-long access tokens from it +which can expire or be revoked, so the bot mints its hour-long access tokens from it from then on. What you need from the [Google Cloud console](https://console.cloud.google.com): diff --git a/docs/civ13-uploader-example.md b/docs/civ13-uploader-example.md new file mode 100644 index 0000000..ec4cdc1 --- /dev/null +++ b/docs/civ13-uploader-example.md @@ -0,0 +1,125 @@ +# Civ13: a verified uploader integration + +This is a specific deployment record, separate from the [generic setup guide](github-actions-uploader.md). +It is evidence for the named revision, not authorization to upload another clip +or reuse its Google grant. No credential values are included here. + +## Verified result + +On 2026-10-20, [run 39447608392](https://github.com/Civ13/Civ13/actions/runs/39447608392) +uploaded [Civ13 development test: cable power disconnect and reconnect (PR #3031)](https://www.youtube.com/watch?v=37MeTKVBs7I) +to **ValZarTech / @valzartech8995**, immutable channel +`UCFRx_n3Mh3FfvL9yOUfG-yw`. Owner API readback confirmed that exact ID/channel, +`privacyStatus=public`, `uploadStatus=processed` and `processingStatus=succeeded` +before the durable receipt became `verified`. Independent unauthenticated oEmbed +confirmed title/handle; that public readback alone cannot establish visibility. + +The owner explicitly replaced an earlier unlisted-only request with **public** for +this new clip. `notifySubscribers=false` was explicit in the insert request and +covered by the framework multipart test; it is not a readable video-status field. +The environment retains the historical name `youtube-unlisted`; the name is not +the visibility policy. No older video visibility was changed. + +All three gates were read back disabled at both repository and environment scope +after completion (2026-10-20T15:23:29.835014Z): enabled=false, stop-use=true, +zero-cost-confirmed=false. No recurring upload/capture schedule was installed. + +## Review the complete integration, not isolated files + +Trusted source commit: `8710dc0264f927b3d19e49dfea50c0b09f0d9675`. +All links below are immutable: + +- [Full integration/setup guide](https://github.com/Civ13/Civ13/blob/8710dc0264f927b3d19e49dfea50c0b09f0d9675/docs/youtube-upload.md) +- [Manual caller](https://github.com/Civ13/Civ13/blob/8710dc0264f927b3d19e49dfea50c0b09f0d9675/.github/workflows/youtube-upload.yml), + [trusted worker](https://github.com/Civ13/Civ13/blob/8710dc0264f927b3d19e49dfea50c0b09f0d9675/.github/workflows/youtube-upload-worker.yml), + [credential-free historical importer](https://github.com/Civ13/Civ13/blob/8710dc0264f927b3d19e49dfea50c0b09f0d9675/.github/workflows/youtube-prepare-import.yml) +- [Controller and durable journal](https://github.com/Civ13/Civ13/blob/8710dc0264f927b3d19e49dfea50c0b09f0d9675/tools/youtube/upload.py), + [PHP adapter](https://github.com/Civ13/Civ13/blob/8710dc0264f927b3d19e49dfea50c0b09f0d9675/tools/youtube/adapter.php), + [local PKCE/secret-placement helper](https://github.com/Civ13/Civ13/blob/8710dc0264f927b3d19e49dfea50c0b09f0d9675/tools/youtube/authorize.py) +- [Offline tests](https://github.com/Civ13/Civ13/tree/8710dc0264f927b3d19e49dfea50c0b09f0d9675/tests/youtube_upload), + [exact approved import material](https://github.com/Civ13/Civ13/blob/8710dc0264f927b3d19e49dfea50c0b09f0d9675/tools/youtube/imports/pr3031.json) + +The adapter pinned YouTubePHP `7ce9b24eb26a32af7ffcd4936d8083c06a428fb0` and a +reviewed Composer lock. This deployment passed 74 Python tests, 53 PHP assertions, +four workflow lints, independent review with no must-fix finding, and CodeQL with +zero findings. Those checks are distinct from the successful live owner readback. + +These files contain **Civ13-specific policy**, not portable defaults: repository +IDs, owner reviewer, Google project, channel, branch, content/visibility rules, +historical import and receipt-key namespace. Read the source license before +copying; adapt them together in a reviewed consumer PR with matching tests. + +## What made the run safe and repeatable + +The human personally performed local Desktop PKCE consent for the approved +project/channel and confirmed encrypted storage of exactly three protected +environment secrets. No Google login was performed in Actions. The grant used +only `youtube.upload` plus separately justified `youtube.readonly`. The environment +required the owner reviewer, allowed that owner's self-review, disabled admin +bypass and allowed only `master`. The setup helper created no environment and +refused secret overwrite; gate variables stayed disabled throughout setup. + +Untrusted gameplay/test producers and credential-free inspection were separated +from trusted upload code. Both jobs used the reviewed default-branch dispatch +commit. The secret-bearing job never checked out or executed PR scripts, treated +artifact metadata as code, or decoded video. Actions and dependency references +were pinned; secrets entered only the final trusted adapter step. + +The approved source was [PR3031](https://github.com/Civ13/Civ13/pull/3031), tested game +commit `861a04010f2819493d5c37d0adf542eea9b78988`, original successful CI run +`38127733413`. The video was owner-attested native DreamSeeker capture in an +isolated, controlled **pregame** scene, not a CI recording or multiplayer/production +acceptance run. The reviewed copy masked the Game ID only, retaining 26 native +frame presentation times, no audio/interpolation; its ending was 0.44 seconds +longer. Approved MP4: 1,308,770 bytes, 41 seconds, +SHA256 `7a0605e3e4c799beaf76fbd5b9b02ddf27a80fb9e5c4df28853248b742851456`. + +A private local/Library file was not assumed accessible to a GitHub runner. +The owner-approved exact masked bytes were made available through an approved +public GitHub attachment, then a separate trusted importer packaged them without +capturing/transforming the game. The original tested game revision and later +packaging revision/run remained distinct. Successful final sequence: + +1. [Importer 39447256895](https://github.com/Civ13/Civ13/actions/runs/39447256895) + produced artifact `12276292330`, 1,309,110 bytes, + SHA256 `90c67ba7f95ef656566a1884c2c69c3db2e01dd51b49a629c6ff72e519919157`. +2. [Inspection 39447373692](https://github.com/Civ13/Civ13/actions/runs/39447373692) + validated provenance/content consistency without channel credentials. +3. Owner-approved [upload 39447608392](https://github.com/Civ13/Civ13/actions/runs/39447608392) + persisted reservation, returned ID, then verified receipt. A bounded monitor + reset/read back both gate scopes after terminal success. + +The data-only `youtube-upload-receipts` branch used Contents API compare-and-swap, +with a source key incorporating repository/game commit/media SHA and a separate +global channel+media SHA scan. A destination-wide lock, two reservations per +rolling 24 hours, 32 MiB media cap, one-day artifact retention and bounded ledger +prevented unbounded work/storage. Per-process transport policy prevented library +POST retries; durable state prevented duplicates across reruns and one-off tools. + +An unrelated older one-off clip remained `uncertain` and blocked. Success for this +different approved hash did not clear it. Missing IDs/possible incomplete drafts +require owner reconciliation, not a new API insert or credential/account switch. + +## Two failures worth reproducing in integration tests + +**Historical PR provenance — [PR3247](https://github.com/Civ13/Civ13/pull/3247).** +The original successful run reported `pull_requests=[]` despite belonging to the +merged PR's exact game revision. The fix was limited to this approved historical +import: require commit-to-PR API association, retained exact PR head SHA, both +repository IDs, original successful CI and owner attestation/comment hash. Generic +empty or conflicting associations still fail. Do not waive provenance globally +or call the later packaging run a recorder/test run. + +**Empty reusable-workflow credentials — [PR3248](https://github.com/Civ13/Civ13/pull/3248).** +Run `39443514193` stopped with a sanitized framework error although the three secret +names existed. Missing named caller bindings resolved to empty credentials. +The framework rejected empty client secret **locally before OAuth/YouTube HTTP**; +transport evidence and unchanged journal positively reconciled no transmission. +That evidence justified a corrected bounded test without rotating credentials. +The fix added named caller mappings, required callee declarations and nonempty +credential preflight before PHP. The callee's protected environment still supplies +the actual environment-scoped values. Secret-name presence never proves propagation. + +Other generic errors do not imply this same safe failure point. Read the durable +receipt and transport evidence first. Once an ID exists, verify that ID; do not +insert a second video. An uncertain media outcome remains blocked until resolved. diff --git a/docs/github-actions-uploader.md b/docs/github-actions-uploader.md new file mode 100644 index 0000000..1703d6d --- /dev/null +++ b/docs/github-actions-uploader.md @@ -0,0 +1,496 @@ +# Uploading reviewed videos from GitHub Actions + +This guide uses YouTubePHP for a **manual, owner-approved upload** from trusted +default-branch code. Start with one small clip, an inspection-only run and no +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) +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 +workflow templates below alone does not create a functioning uploader.** + +## Choose a starting point + +| Route | What to copy or implement | +| --- | --- | +| Small standalone integration | The library calls below and `ReviewedUpload.php`; implement your own trusted CLI, approval/provenance checks and durable journal as specified below. No Civ13 code is assumed. | +| Adapt the working Civ13 integration | Review its linked controller, PHP adapter, local authorization helper, policies, tests and workflows together. Replace every hard-coded project/repository/channel/owner/default-branch/visibility policy in a PR. Check licenses before copying code. | + +The standalone example is restricted to reviewed, non-synthetic development clips +which are not made for kids. Its category/audience declarations are not suitable +defaults for arbitrary videos. Change them only with accurate owner-reviewed +content declarations and corresponding tests. + +Record these choices before creating persistent access: + +```text +Google project: +Owning Google account: (keep private) +Channel: / +Repository: / +Default branch: +Protected environment:, e.g. youtube-upload +Required reviewer: +Visibility policy: unlisted OR public, explicitly approved +``` + +An unlisted link can be shared by anyone who has it. Public uploads are also +discoverable. Pin the selected privacy policy in trusted code/approval; do not +allow an untrusted artifact to choose it. Do not change older videos' visibility. + +## 1. Prepare and pin the dependency + +In your **consumer repository**, create a separate project directory (these are +Bash commands). Resolve dependencies locally, review the lock, then commit both +files. No grant or upload occurs here. + +```bash +mkdir -p tools/youtube +cd tools/youtube +composer init --name=example/reviewed-youtube-uploader \ + --description='Reviewed manual video uploader' --license=proprietary --no-interaction +composer config repositories.youtubephp vcs https://github.com/Valgorithms/YoutubePHP +composer config minimum-stability dev +composer config prefer-stable true +composer config allow-plugins false +composer config platform.php 8.4.0 +composer require --no-interaction --no-scripts --no-plugins \ + 'vzgcoders/youtubephp:dev-main#7ce9b24eb26a32af7ffcd4936d8083c06a428fb0' +composer validate +``` + +Choose the package name/license for your consumer; `proprietary` above is an +illustrative application license, not a change to YouTubePHP's MIT license. +The exact commit is the version tested in the case study, not a recommendation +to ignore later fixes. Composer warns about the intentional commit-ref constraint +(`validate --strict` therefore exits nonzero); the normal `validate` command still +checks the schema. Review +both the pinned source **and the lock's resolved transitive references**. Do not +use global path overrides when generating a portable consumer lock. A library +checkout ignores its own `composer.lock`; your consumer application should commit +its lock. CI installs it, never runs `composer update`: + +```bash +composer install --working-dir=tools/youtube --no-dev --no-interaction \ + --no-scripts --no-plugins --prefer-dist --no-progress +``` + +Use PHP 8.4+, JSON and mbstring. At this pin, media is read completely into memory +and sent in **one multipart request**; resumable upload is not implemented. Cap +clips before PHP starts; the example uses 32 MiB. Use a bounded process/job timeout +and enough memory for the file and multipart copy (Civ13 uses 256 MiB). + +## 2. Google project, consent and channel + +In the approved project in [Google Cloud Console](https://console.cloud.google.com/): + +1. Enable **YouTube Data API v3**. An API key or service account does not replace + the channel owner's OAuth grant. +2. Configure Google Auth Platform branding/support/contact details. For an initial + owner-only test, choose **External / Testing**, and add the owning Google account + under **Test users**. A pre-existing test-user entry need not be duplicated. +3. Add exactly these complete scope URLs in Data Access: + + ```text + https://www.googleapis.com/auth/youtube.upload + https://www.googleapis.com/auth/youtube.readonly + ``` + + `youtube.upload` is the minimum insertion scope. The separate `youtube.readonly` + scope allows `channels.list(mine=true)` and owner `videos.list` status/processing + readback; upload alone is absent from those methods' allowed scope lists in + [API discovery](https://youtube.googleapis.com/$discovery/rest?version=v3). + Do not silently substitute the much broader `youtube`, `force-ssl` or partner + scopes. Reject a missing or expanded returned grant rather than guessing. +4. Create a **Desktop app** OAuth client. For an assistant-operated automated + client, follow Google's [AI-powered client declaration](https://support.google.com/cloud/answer/15549257#agentic-client) + when offered; it adds no scope. Do not reuse an agent-only client for a future + mixed human/agent application without reviewing Google's requirements. +5. Keep downloaded client JSON in a private local folder outside repositories, + synced/shared folders and chat. Use restrictive file permissions. The local + setup process must not display or log the JSON, token response or client secret. + +Choose the **Brand channel**, if applicable, during the owner's Google sign-in. +An account email, channel display name or remembered handle is not proof of the +destination. Resolve and approve its immutable `UC...` channel ID, then require +the same grant's `channels.list(part:'id', mine:true)` to return exactly that ID +before inserting media. A wrong channel stops; never switch accounts automatically. + +### Desktop authorization code with PKCE, once locally + +Use Google's [installed-app authorization-code flow](https://developers.google.com/youtube/data/guides/auth/installed-apps), +not a headless consent attempt inside Actions. The README's TV/device-flow bot +example is a different setup: Google's [device-flow allowed scopes](https://developers.google.com/identity/protocols/oauth2/limited-input-device#allowedscopes) +do not include the narrow `youtube.upload` scope. + +A reviewed local helper must perform this sequence. YouTubePHP's `OAuth` class at +this pin implements refresh/device grants, **not** an authorization-code/PKCE +helper; implement this step separately or explicitly adapt the case-study helper. + +1. Generate a cryptographically random verifier (43–128 URL-safe characters), + `BASE64URL(SHA256(verifier))` challenge and independent unpredictable state. + Bind a temporary IPv4 loopback listener to `127.0.0.1` on a free port. +2. The human opens `https://accounts.google.com/o/oauth2/v2/auth` with the approved + `client_id`, exact loopback `redirect_uri`, `response_type=code`, the two scopes, + `access_type=offline`, `code_challenge_method=S256`, challenge and state. + Request fresh consent only when needed to obtain an approved refresh grant; + do not continually generate new grants. Keep authorization parameters out of logs. +3. Have the human select the approved account/Brand channel and personally approve + consent. Validate callback path, state with a constant-time comparison, absence + of OAuth errors and a single unexpired callback. Bound the session (e.g. five + minutes). Denial, timeout or mismatched state stops, without automatic retry. +4. POST form data to `https://oauth2.googleapis.com/token`: `grant_type=authorization_code`, + `client_id`, `client_secret`, `code`, **the same** `redirect_uri`, and `code_verifier`. + Perform this in the private helper, not a command that prints token JSON. +5. Check the granted scopes/token type, required refresh token and owner channel. + Do not persist access tokens. Ask the human to confirm the **exact repository, + environment and three secret names** before encrypted placement. No `.env` file, + token file, workflow input, output, artifact or PR comment should carry tokens. + +Keep client/token values out of shell arguments and tracing. A local helper can +pass each value to `gh secret set NAME --repo OWNER/REPOSITORY --env ENVIRONMENT` +on **stdin**, never with `--body VALUE`. The GitHub CLI encrypts secret input. +The human can instead use GitHub's environment-secret UI directly. Do not ask them +to paste secrets into chat. Revoking the Google app grant terminates its access; +merely discarding a local token does not revoke it. + +External/Testing refresh tokens for these YouTube scopes [expire after seven days](https://developers.google.com/identity/protocols/oauth2#expiration). +Refresh tokens can also be revoked/invalidated for other reasons; "never expires" +is not a safe assumption. Continued use requires a separately approved renewal +or Production consent/verification plan. Never silently rotate/overwrite secrets, +broaden scopes or create another client to work around an error. + +## 3. Configure the protected GitHub environment + +In the consumer repository's Settings → Environments, create ``: + +- Require the named human owner as reviewer. Explicitly choose whether the owner + may review their own dispatch; Civ13 allows self-review, while retaining the + required approval step. Confirm your GitHub plan supports the desired protections. +- Disable administrator bypass. Restrict deployment branches to **only** the + actual default branch, using an explicit branch rule. Protect that branch. +- Store exactly `YOUTUBE_CLIENT_ID`, `YOUTUBE_CLIENT_SECRET`, `YOUTUBE_REFRESH_TOKEN` + as **environment secrets**. Do not move them to repository/organization scope + to fix propagation. Inspection and PR jobs must not receive them. +- Keep repository and environment variables explicitly disabled: + + ```text + YOUTUBE_UPLOAD_ENABLED=false + YOUTUBE_UPLOAD_STOP_USE=true + YOUTUBE_ZERO_COST_CONFIRMED=false + ``` + +Do not enable uploads during credential setup. Store/rotation approval is separate +from clip approval or source PR approval. A three-secret write is not atomic: +GitHub cannot return old values for rollback. An interrupted/uncertain placement +needs owner reconciliation and uploads must remain disabled. Never knowingly +overwrite an existing grant as an incidental setup retry. + +### Reusable workflow interface + +These are **consumer repository templates**, not active workflows in YouTubePHP. +Replace `youtube-upload` with the approved environment. Your default branch is +read dynamically. Copy the tested PHP example into your consumer and implement +the trusted `tools/youtube/repository-uploader.php` controller contract in the next +section **before** enabling mutation. That file is intentionally not supplied as +an unsafe journal-free CLI. The templates fail if it is absent; they do not call +any Civ13 script implicitly. + +Caller, `.github/workflows/youtube-upload.yml`: + +```yaml +name: Reviewed YouTube upload +on: + workflow_dispatch: + inputs: + execute_upload: + type: boolean + default: false + description: Insert the separately approved clip after inspection +permissions: + contents: read + actions: read + issues: read + pull-requests: read +jobs: + upload: + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + uses: ./.github/workflows/youtube-upload-worker.yml + with: + execute_upload: ${{ inputs.execute_upload }} + secrets: + YOUTUBE_CLIENT_ID: ${{ secrets.YOUTUBE_CLIENT_ID }} + YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }} + YOUTUBE_REFRESH_TOKEN: ${{ secrets.YOUTUBE_REFRESH_TOKEN }} +``` + +Worker, `.github/workflows/youtube-upload-worker.yml`: + +```yaml +name: Trusted YouTube worker +on: + workflow_call: + inputs: + execute_upload: + type: boolean + required: true + secrets: + YOUTUBE_CLIENT_ID: + required: true + YOUTUBE_CLIENT_SECRET: + required: true + YOUTUBE_REFRESH_TOKEN: + required: true +permissions: + contents: read + actions: read + issues: read + pull-requests: read +concurrency: + group: youtube-destination-upload + cancel-in-progress: false +jobs: + inspect: + if: >- + github.event_name == 'workflow_dispatch' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: shivammathur/setup-php@db91e1a0e48e84637d325a7ee4d2677e146ceec4 # 2.36.0 + with: + php-version: '8.4' + extensions: mbstring + coverage: none + tools: composer:2.9.8 + - run: php tools/youtube/repository-uploader.php inspect + env: + GH_TOKEN: ${{ github.token }} + upload: + needs: inspect + if: >- + inputs.execute_upload && vars.YOUTUBE_UPLOAD_ENABLED == 'true' && + vars.YOUTUBE_UPLOAD_STOP_USE == 'false' && vars.YOUTUBE_ZERO_COST_CONFIRMED == 'true' + environment: youtube-upload + runs-on: ubuntu-24.04 + timeout-minutes: 8 + # If your durable journal uses GitHub Contents, grant contents:write HERE + # AND in the caller. A callee cannot elevate a read-only caller token. + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: shivammathur/setup-php@db91e1a0e48e84637d325a7ee4d2677e146ceec4 # 2.36.0 + with: + php-version: '8.4' + extensions: mbstring + coverage: none + tools: composer:2.9.8 + - run: >- + composer install --working-dir=tools/youtube --no-dev --no-interaction + --no-scripts --no-plugins --prefer-dist --no-progress + - name: Upload using trusted controller and protected environment + run: php -d display_errors=0 -d memory_limit=256M tools/youtube/repository-uploader.php upload + env: + GH_TOKEN: ${{ github.token }} + YOUTUBE_CLIENT_ID: ${{ secrets.YOUTUBE_CLIENT_ID }} + YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }} + YOUTUBE_REFRESH_TOKEN: ${{ secrets.YOUTUBE_REFRESH_TOKEN }} + YOUTUBE_UPLOAD_ENABLED: ${{ vars.YOUTUBE_UPLOAD_ENABLED }} + YOUTUBE_UPLOAD_STOP_USE: ${{ vars.YOUTUBE_UPLOAD_STOP_USE }} + YOUTUBE_ZERO_COST_CONFIRMED: ${{ vars.YOUTUBE_ZERO_COST_CONFIRMED }} +``` + +Use both **named caller bindings** and required callee declarations. Required keys +do not prove nonempty values; preflight them before PHP/OAuth transport. GitHub +does not pass environment configuration through `workflow_call`: the callee job's +`environment` selects its protected environment secrets, which take precedence +over a passed secret with the same name. The caller mappings above do not expose +those environment values to an unprotected caller. Avoid `secrets: inherit`. +See [GitHub's reusable-workflow secret rules](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows). + +Environment variables are not reliably available to a **job-level** `if`. The +job-level gate above uses repository variables; the controller must also read and +enforce the effective environment values at step execution and recheck both scopes +via GitHub metadata when your policy requires two independent gates. Inspection +receives a GitHub read token, no YouTube credential variables and no protected +environment. Never interpolate manifest text or workflow inputs into a shell command. + +## 4. Implement the trusted controller, then connect the API calls + +`repository-uploader.php` is code **you review and commit** in the consumer. It +must support `inspect` (no YouTube secrets or OAuth) and `upload`, with these contracts: + +1. Read only a reviewed policy/manifest in the trusted default-branch revision. + Validate its bounded schema, exact channel/privacy, media SHA256, title, + description, audience/content declaration, tested commit and named human + approval. Bind approval to those exact fields; a changed file or metadata + invalidates approval. A default `execute_upload=false` is not content approval. +2. Prove the source run/artifact belongs to the expected repository IDs, workflow + ID/path, event/branch, exact tested revision and successful attempt. Check PR + association, artifact ID/name/size/digest/expiry and creation during that attempt. + Never check out or execute producer/PR scripts in the secret-bearing job. + Separate original recording/game revision from a later trusted packaging run. +3. Download bounded bytes using credential-free signed redirects, check archive + and media hashes, reject traversal/symlinks/encrypted/extra entries, and probe + MIME/header/codec/duration/dimensions **without upload secrets**. A hash is + consistency evidence, not gameplay or publication approval. Revalidate trusted + provenance/approval and identical media bytes in the upload job, without decoding + media there. Use a fixed size/content policy, not artifact-provided limits. +4. Enforce disabled gates, default-branch/manual-only eligibility, destination-wide + concurrency, a small daily upload cap, zero-cost budget and nonempty credentials. + Treat auth/rate/quota errors as stop-use. Never print raw exceptions, auth headers, + tokens, signed artifact URLs, media or OAuth response bodies. +5. Load the durable journal and block any existing destination+media SHA record + across manifests, commits and one-off tools. Use a shared persistent store, + not a runner file, cache or expiring artifact. Import known prior attempts first. + For a small GitHub Contents journal, use a separate **data-only** branch and + compare-and-swap the current file SHA. `contents:write` is repository-wide, + so protect the default branch and document this privilege. It is not needed + for inspection or a journal stored through a different approved mechanism. +6. Commit `reserved` **before** `videos.insert`. If reservation persistence is + failed/uncertain, send no media. Persist returned ID as `uploaded` **before** + readback; only mark `verified` after exact channel/privacy and successful + processing. Receipt fields should contain public IDs/hashes/provenance, no auth. + Do not delete dedup history to fit a cap; stop for reviewed maintenance. +7. Bound polling (for example 12 reads, ten seconds apart). If an ID exists, future + attempts are **verify-only**, including after artifact expiry. If insert/ID + persistence is uncertain, stop retries and retain the reservation. An empty + uploads playlist does not prove no incomplete Studio draft exists; API inventory + may not expose every draft. Reconcile with owner evidence, never another insert, + credential/client switch or automated account selection. + +The example's `reserve(channel, sha)` callback must atomically reject an existing +record and confirm a persistent write before returning `true`. Its `saveId(id)` +callback must confirm another persistent write. These are integration hooks, not +in-memory booleans. The example does not itself mark a receipt verified. + +Here is the actual library connection pattern, inside that trusted controller: + +```php +use YouTube\Auth\OAuth; +use YouTube\Examples\ReviewedUpload; +use YouTube\Examples\SingleInsertDriver; +use YouTube\Http\Drivers\React as ReactDriver; +use YouTube\YouTube; + +// Require tools/youtube/vendor/autoload.php and your copied ReviewedUpload.php first. +// $loop is your React event loop; all three strings were preflighted, never logged. +$driver = new SingleInsertDriver(new ReactDriver($loop, [], 120.0)); +$oauth = new OAuth($clientId, $clientSecret, $driver); +$session = $oauth->refreshToken($refreshToken)->then(function (array $grant) use ($driver, $loop) { + $scopes = $grant['scope'] ?? []; + sort($scopes); + if ($scopes !== ReviewedUpload::SCOPES + || strtolower($grant['token_type'] ?? '') !== 'bearer' + || ! is_string($grant['access_token'] ?? null) || $grant['access_token'] === '') { + throw new RuntimeException('Unexpected grant; stop without logging the response'); + } + return new YouTube([ + 'token' => $grant['access_token'], + 'scopes' => ReviewedUpload::SCOPES, + 'driver' => $driver, + 'loop' => $loop, + // No token_store, device_prompt, refresh_token or automatic reauthorization. + ]); +}); +``` + +`OAuth::refreshToken` normalizes the returned scope string into an array. Chain +your promise handling from `$session` and run/stop the event loop with a bounded +deadline. Construct `new ReviewedUpload($youtube)`, call `insert(...)` with the +approved file/metadata and the **durable** callbacks above, then poll +`verified($savedId, $channelId, $privacy)`; `false` means still processing and +`true` permits the controller to persist `verified`. Close the client in cleanup. +Catch errors at the CLI boundary with a fixed sanitized diagnostic, preserving +the journal for reconciliation. Do not call `YouTube::run()` to start a bot loop. + +At the tested pin, normal library behavior retries some POST failures (503/rate +limits), and a client configured with refresh credentials may replay after 401. +`SingleInsertDriver` is an **example integration policy**, not a changed library +default: it allows only four fixed HTTPS endpoints, converts non-2xx/transport +failures into a sanitized non-retryable error and rejects a second media insert. +That per-process counter is insufficient across workflow reruns; the journal is +still mandatory. Never retry an ambiguous media request just because a driver +returned an error. Larger/resumable uploads need a separately reviewed implementation. + +## 5. Validate, run once, and close the gates + +In this library checkout, the example has offline tests using `ScriptedDriver`, +not Google/network access. After installing the development dependencies: + +```bash +php -l examples/ReviewedUpload.php +vendor/bin/phpunit --filter=ReviewedUploadTest +``` + +In a consumer, add tests for the full controller, durable-write ordering/failures, +dedup across reruns, tampered provenance/approval, file caps/paths, scope/channel +mismatch and no media requests on failure. Mocked API tests do not prove a real +grant, channel ownership or publication. Lint the copied YAML with actionlint; +keep setup/actions pinned and review lock changes separately. + +For the first live check, inspect first with `execute_upload=false`. Confirm +standard-runner/public-repository **$0** eligibility and your existing account +budget; private-repository allowances and environment features may differ. Use +short artifact retention (e.g. one day), bounded file size, one clip per dispatch, +job timeouts and a low daily cap. Do not add larger runners, paid services or +unbounded video retention. + +After a fresh exact-clip approval, open the repository **and** environment gates +only for one bounded test: enabled=true, stop-use=false, zero-cost-confirmed=true. +Dispatch the default-branch caller, then have the owner personally approve the +protected job. A separate bounded monitor must reset **both scopes** to disabled +on terminal state, failure, interruption or deadline and **read back** the result. +`always()` inside a job cannot guarantee cleanup after cancellation, outage or +runner loss. If monitoring disconnects, independently verify shutdown before +claiming gates are closed. The templates do not install a watchdog for you. + +Do not call an upload successful merely because the insert returned an ID or the +Actions job completed. Verify that exact ID through owner API readback: + +```text +snippet.channelId == approved immutable channel +status.privacyStatus == approved public/unlisted policy +status.uploadStatus == processed +processingDetails.processingStatus == succeeded +``` + +Then persist the verified receipt and return `https://www.youtube.com/watch?v=`. +A public/embeddable page or oEmbed response alone does not distinguish public from +unlisted. `notifySubscribers=false` is an explicit request parameter, not a status +field you can read back. Failed/mismatched/pending processing is not success and +never authorizes a second insert. + +## Current Google limits and troubleshooting + +Official documentation checked **2026-10-20**: [`videos.insert`](https://developers.google.com/youtube/data/reference/rest/v3/videos/insert) +uses a separate Video Uploads bucket of 100 calls/day, at one unit per call, and +says unverified API projects are not restricted to private viewing. Older guidance +about 1,600 units/private-only uploads should not be copied as a current rule. +Creator account settings, OAuth consent verification and actual project/account +restrictions remain separate: always recheck current official docs and returned +privacy. If Google returns private instead of the approved public/unlisted value, +stop and resolve the applicable restriction with the owner; do not change clients +or claim publication succeeded. + +| Symptom | Check before doing anything else | +| --- | --- | +| API project/client setup appears complete but consent denies access | Exact project/client type, External/Testing test-user account, complete scope URLs and the selected Brand channel. | +| `invalid_grant` after a week | Testing refresh-token expiration or revocation. Stop; plan owner-approved renewal/Production verification, never loop consent. | +| Secret names exist but refresh sees empty values | Explicit caller named bindings, callee required declarations, actual callee environment name and step env. Metadata presence is not value propagation. | +| Historical successful run has empty `pull_requests` | Do not waive provenance globally. Validate exact commit-to-PR relation and original success, under a narrowly reviewed import policy; keep game and packaging commits distinct. | +| Generic upload failure, timeout or lost output | Inspect durable reservation/ID and sanitized transport evidence. Do not assume no bytes were sent. Use known-ID verification or owner reconciliation, not rerun. | +| Visibility mismatch/processing pending | Preserve saved ID; bounded verify-only checks. Never insert a replacement or change old visibility automatically. | +| Rate/quota/budget incident | Close gates and stop. Do not switch credentials/accounts to evade limits. | + +For the precise fixes behind the tested integration, see the [Civ13 case study](civ13-uploader-example.md). diff --git a/examples/ReviewedUpload.php b/examples/ReviewedUpload.php new file mode 100644 index 0000000..65214c4 --- /dev/null +++ b/examples/ReviewedUpload.php @@ -0,0 +1,191 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE file. + */ + +namespace YouTube\Examples; + +use Psr\Http\Message\ResponseInterface; +use React\Http\Message\Response; +use React\Promise\PromiseInterface; + +use function React\Promise\resolve; + +use YouTube\Http\DriverInterface; +use YouTube\Http\Media; +use YouTube\Http\Request; +use YouTube\YouTube; + +/** + * A one-attempt transport policy for the small-video example, not a library default. + * A new process must still check a shared durable receipt before using this driver. + */ +final class SingleInsertDriver implements DriverInterface +{ + private bool $sent = false; + + public function __construct(private readonly DriverInterface $inner) + { + } + + public function runRequest(Request $request, bool $streaming = false): PromiseInterface + { + $routes = [ + 'oauth.token' => ['POST', 'oauth2.googleapis.com', '/token'], + 'channels.list' => ['GET', 'youtube.googleapis.com', '/youtube/v3/channels'], + 'videos.list' => ['GET', 'youtube.googleapis.com', '/youtube/v3/videos'], + 'videos.insert' => ['POST', 'youtube.googleapis.com', '/upload/youtube/v3/videos'], + ]; + $url = parse_url($request->getUrl()); + $route = [$request->getMethod(), $url['host'] ?? '', $url['path'] ?? '']; + if ($streaming || ($url['scheme'] ?? '') !== 'https' || isset($url['user']) || isset($url['pass']) + || isset($url['port']) || ($routes[$request->getEndpoint()] ?? null) !== $route) { + return resolve($this->stopped()); + } + if ($request->getEndpoint() === 'videos.insert') { + if ($this->sent) { + return resolve($this->stopped()); + } + $this->sent = true; // Before handing any bytes to the underlying driver. + } + + return $this->inner->runRequest($request, false)->then( + fn (ResponseInterface $response): ResponseInterface => + $response->getStatusCode() >= 200 && $response->getStatusCode() < 300 + ? $response : $this->stopped(), + fn (\Throwable $error): ResponseInterface => $this->stopped(), + ); + } + + private function stopped(): ResponseInterface + { + // Avoid the library's 401/429/503 retry paths and leaking service/auth bodies. + return new Response( + 400, + ['Content-Type' => 'application/json'], + '{"error":{"code":400,"message":"Stopped; reconcile the durable receipt before retry"}}', + ); + } +} + +/** + * Library calls only. No .env loading, grant, CLI, artifact downloader or journal. + * See docs/github-actions-uploader.md for the mandatory repository-owned controls. + */ +final class ReviewedUpload +{ + public const SCOPES = [ + 'https://www.googleapis.com/auth/youtube.readonly', + 'https://www.googleapis.com/auth/youtube.upload', + ]; + + public function __construct(private readonly YouTube $youtube) + { + } + + /** + * Both callbacks must finish a DURABLE shared-store write before returning true. + * The caller has already verified content/audience approval and file provenance. + * + * @param \Closure(string, string): bool $reserve Channel + media SHA dedup/reservation + * @param \Closure(string): bool $saveId Save ID before ANY status readback + */ + public function insert( + string $path, + string $sha256, + string $channelId, + string $privacy, + string $title, + string $description, + \Closure $reserve, + \Closure $saveId, + ): PromiseInterface { + self::validateTarget($channelId, $privacy); + if (! preg_match('/^[a-f0-9]{64}$/D', $sha256) || ! is_file($path) || is_link($path) + || filesize($path) < 12 || filesize($path) > 32 * 1024 * 1024 + || hash_file('sha256', $path) !== $sha256) { + throw new \InvalidArgumentException('Invalid reviewed media'); + } + foreach ([[$title, 100, false], [$description, 4000, true]] as [$text, $limit, $allowEmpty]) { + if ((! $allowEmpty && $text === '') || strlen($text) > $limit + || ! preg_match('/^[\x20-\x3B\x3D\x3F-\x7E]*$/D', $text)) { + throw new \InvalidArgumentException('Invalid reviewed metadata'); + } + } + // Read once: the bytes sent are exactly the bytes hashed here. + $media = Media::fromFile($path, 'video/mp4'); + if (strlen($media->contents) > 32 * 1024 * 1024 || hash('sha256', $media->contents) !== $sha256 + || substr($media->contents, 4, 4) !== 'ftyp') { + throw new \InvalidArgumentException('Media changed or is not MP4'); + } + + return $this->youtube->channels->list(part: 'id', mine: true)->then( + function ($response) use ($channelId, $sha256, $privacy, $title, $description, $media, $reserve) { + if (count($response->items) !== 1 || $response->items->first()->id !== $channelId) { + throw new \RuntimeException('Wrong owner channel'); + } + if ($reserve($channelId, $sha256) !== true) { + throw new \RuntimeException('Reservation not confirmed; no insert'); + } + + return $this->youtube->videos->insert( + part: 'snippet,status', + body: [ + 'snippet' => ['title' => $title, 'description' => $description, 'categoryId' => '20'], + // This example is ONLY for reviewed, non-synthetic, non-kids development clips. + 'status' => ['privacyStatus' => $privacy, 'selfDeclaredMadeForKids' => false, + 'containsSyntheticMedia' => false], + ], + media: $media, + notifySubscribers: false, + ); + }, + )->then(function ($video) use ($saveId): string { + if (! is_string($video->id) || ! preg_match('/^[A-Za-z0-9_-]{11}$/D', $video->id)) { + throw new \RuntimeException('Missing ID; reservation remains blocked'); + } + if ($saveId($video->id) !== true) { + throw new \RuntimeException('ID persistence unconfirmed; reservation remains blocked'); + } + + return $video->id; + }); + } + + /** One owner readback, suitable for bounded polling of a previously saved ID. */ + public function verified(string $id, string $channelId, string $privacy): PromiseInterface + { + self::validateTarget($channelId, $privacy); + if (! preg_match('/^[A-Za-z0-9_-]{11}$/D', $id)) { + throw new \InvalidArgumentException('Invalid video ID'); + } + + return $this->youtube->videos->list(part: 'snippet,status,processingDetails', id: $id) + ->then(function ($response) use ($id, $channelId, $privacy): bool { + $video = $response->items->first(); + if (count($response->items) !== 1 || $video->id !== $id + || $video->snippet->channelId !== $channelId || $video->status->privacyStatus !== $privacy + || in_array($video->status->uploadStatus, ['failed', 'rejected', 'deleted'], true) + || in_array($video->processingDetails?->processingStatus, ['failed', 'terminated'], true)) { + throw new \RuntimeException('Owner readback mismatch'); + } + + return $video->status->uploadStatus === 'processed' + && $video->processingDetails?->processingStatus === 'succeeded'; + }); + } + + private static function validateTarget(string $channelId, string $privacy): void + { + if (! preg_match('/^UC[A-Za-z0-9_-]{22}$/D', $channelId) + || ! in_array($privacy, ['unlisted', 'public'], true)) { + throw new \InvalidArgumentException('Invalid approved destination or visibility'); + } + } +} diff --git a/tests/ReviewedUploadTest.php b/tests/ReviewedUploadTest.php new file mode 100644 index 0000000..8a88dbc --- /dev/null +++ b/tests/ReviewedUploadTest.php @@ -0,0 +1,249 @@ + + * + * 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; + +use function React\Async\await; + +use React\EventLoop\Loop; +use YouTube\Examples\ReviewedUpload; +use YouTube\Examples\SingleInsertDriver; +use YouTube\Http\Request; +use YouTube\YouTube; + +require_once dirname(__DIR__) . '/examples/ReviewedUpload.php'; + +/** Offline documentation contracts; this synthetic byte string is not gameplay. */ +final class ReviewedUploadTest extends TestCase +{ + private const CHANNEL = 'UC1234567890123456789012'; + + private const ID = 'abcDEF123_-'; + + private string $path; + + private ScriptedDriver $driver; + + private ReviewedUpload $example; + + protected function setUp(): void + { + $this->path = tempnam(sys_get_temp_dir(), 'youtubephp-example-'); + file_put_contents($this->path, "\0\0\0\x18ftypmp42offline-fixture"); + $this->driver = new ScriptedDriver(); + $youtube = new YouTube([ + 'token' => 'mock-access-token', + 'scopes' => ReviewedUpload::SCOPES, + 'driver' => new SingleInsertDriver($this->driver), + 'loop' => Loop::get(), + ]); + $this->example = new ReviewedUpload($youtube); + } + + protected function tearDown(): void + { + unlink($this->path); + } + + private function owner(string $channel = self::CHANNEL): void + { + $this->driver->respond(200, ['items' => [['id' => $channel]]]); + } + + private function insert(string $privacy = 'unlisted', ?\Closure $reserve = null, ?\Closure $saveId = null): mixed + { + return await($this->example->insert( + $this->path, + hash_file('sha256', $this->path), + self::CHANNEL, + $privacy, + 'Reviewed development clip', + 'Observed behavior and known limits.', + $reserve ?? static fn (): bool => true, + $saveId ?? static fn (): bool => true, + )); + } + + public static function privacyCases(): array + { + return [['unlisted'], ['public']]; + } + + #[DataProvider('privacyCases')] + public function testReservesBeforeSendingAndPersistsIdBeforeReadback(string $privacy): void + { + $this->owner(); + $events = []; + $this->driver->handle(function (Request $request) use (&$events, $privacy) { + $this->assertSame(['reserved'], $events); + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('videos.insert', $request->getEndpoint()); + $this->assertContains(['notifySubscribers', 'false'], ScriptedDriver::queryPairs($request)); + $content = $request->getContent(); + $this->assertStringContainsString('"privacyStatus":"' . $privacy . '"', $content); + $this->assertStringContainsString('"selfDeclaredMadeForKids":false', $content); + $this->assertStringContainsString('"containsSyntheticMedia":false', $content); + $this->assertStringContainsString('Content-Type: video/mp4', $content); + $this->assertStringContainsString(file_get_contents($this->path), $content); + $events[] = 'insert'; + + return new \React\Http\Message\Response(200, [], json_encode(['id' => self::ID])); + }); + $id = $this->insert($privacy, function ($channel, $sha) use (&$events): bool { + $this->assertSame(self::CHANNEL, $channel); + $this->assertSame(hash_file('sha256', $this->path), $sha); + $events[] = 'reserved'; + + return true; + }, function ($id) use (&$events): bool { + $this->assertSame(self::ID, $id); + $events[] = 'saved'; + + return true; + }); + $this->assertSame(self::ID, $id); + $this->assertSame(['reserved', 'insert', 'saved'], $events); + $this->assertCount(2, $this->driver->requests); // No implicit verification before persistence. + } + + public function testRejectedReservationSendsNoMedia(): void + { + $this->owner(); + try { + $this->insert(reserve: static fn (): bool => false); + $this->fail('Expected reservation rejection'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('Reservation', $error->getMessage()); + } + $this->assertCount(1, $this->driver->requests); + } + + public function testWrongChannelDoesNotReserveOrInsert(): void + { + $this->owner('UC0000000000000000000000'); + $reserved = false; + try { + $this->insert(reserve: function () use (&$reserved): bool { + $reserved = true; + + return true; + }); + $this->fail('Expected channel rejection'); + } catch (\RuntimeException $error) { + $this->assertSame('Wrong owner channel', $error->getMessage()); + } + $this->assertFalse($reserved); + $this->assertCount(1, $this->driver->requests); + } + + public static function errorCases(): array + { + return [[401], [429], [503], [0]]; + } + + #[DataProvider('errorCases')] + public function testAmbiguousInsertNeverRetriesAndDoesNotPersistInventedId(int $status): void + { + $this->owner(); + if ($status === 0) { + $this->driver->fail(new \RuntimeException('secret service details')); + } else { + $this->driver->error($status, 'rateLimitExceeded', 'secret service details'); + } + $saved = false; + try { + $this->insert(saveId: function () use (&$saved): bool { + $saved = true; + + return true; + }); + $this->fail('Expected sanitized failure'); + } catch (\Throwable $error) { + $this->assertStringNotContainsString('secret service details', $error->getMessage()); + } + $this->assertFalse($saved); + $this->assertCount(2, $this->driver->requests); + } + + public function testUnknownIdAndFailedPersistenceCannotReportSuccess(): void + { + $this->owner(); + $this->driver->respond(200, ['id' => self::ID]); + $this->expectExceptionMessage('ID persistence unconfirmed'); + $this->insert(saveId: static fn (): bool => false); + } + + public function testHashMismatchStopsBeforeAnyRequest(): void + { + try { + $this->example->insert( + $this->path, + str_repeat('0', 64), + self::CHANNEL, + 'unlisted', + 'Title', + '', + static fn (): bool => true, + static fn (): bool => true, + ); + $this->fail('Expected hash rejection'); + } catch (\InvalidArgumentException $error) { + $this->assertSame('Invalid reviewed media', $error->getMessage()); + } + $this->assertCount(0, $this->driver->requests); + } + + public static function readbackCases(): array + { + return [ + ['unlisted', self::CHANNEL, 'processed', 'succeeded', true], + ['unlisted', self::CHANNEL, 'uploaded', 'processing', false], + ['private', self::CHANNEL, 'processed', 'succeeded', null], + ['unlisted', 'UC0000000000000000000000', 'processed', 'succeeded', null], + ['unlisted', self::CHANNEL, 'rejected', 'failed', null], + ]; + } + + #[DataProvider('readbackCases')] + public function testKnownIdVerificationNeverInserts(string $privacy, string $channel, string $upload, string $processing, ?bool $expected): void + { + $this->driver->respond(200, ['items' => [[ + 'id' => self::ID, 'snippet' => ['channelId' => $channel], + 'status' => ['privacyStatus' => $privacy, 'uploadStatus' => $upload], + 'processingDetails' => ['processingStatus' => $processing], + ]]]); + try { + $actual = await($this->example->verified(self::ID, self::CHANNEL, 'unlisted')); + $this->assertNotNull($expected); + $this->assertSame($expected, $actual); + } catch (\RuntimeException $error) { + $this->assertNull($expected); + $this->assertSame('Owner readback mismatch', $error->getMessage()); + } + $this->assertCount(1, $this->driver->requests); + $this->assertSame('videos.list', $this->driver->last()->getEndpoint()); + } + + public function testDriverBlocksSecondInsertAndUnexpectedEndpoint(): void + { + $inner = new ScriptedDriver(); + $inner->respond(200, []); + $driver = new SingleInsertDriver($inner); + $insert = new Request('videos.insert', 'POST', 'https://youtube.googleapis.com/upload/youtube/v3/videos'); + $this->assertSame(200, await($driver->runRequest($insert))->getStatusCode()); + $this->assertSame(400, await($driver->runRequest($insert))->getStatusCode()); + $this->assertSame(400, await($driver->runRequest(new Request('videos.list', 'GET', 'https://attacker.invalid/youtube/v3/videos')))->getStatusCode()); + $this->assertCount(1, $inner->requests); + } +}