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 is tested with a fake transport; it deliberately has no executable CLI or default journal. The Civ13 example 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.
| 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:
Google project: <GOOGLE_PROJECT_ID>
Owning Google account:<OWNER_ACCOUNT> (keep private)
Channel: <CHANNEL_HANDLE> / <CHANNEL_ID>
Repository: <OWNER>/<REPOSITORY>
Default branch: <DEFAULT_BRANCH>
Protected environment:<ENVIRONMENT>, e.g. youtube-upload
Required reviewer: <OWNER_GITHUB_LOGIN>
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.
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.
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 validateChoose 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:
composer install --working-dir=tools/youtube --no-dev --no-interaction \
--no-scripts --no-plugins --prefer-dist --no-progressUse 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).
In the approved project in Google Cloud Console:
-
Enable YouTube Data API v3. An API key or service account does not replace the channel owner's OAuth grant.
-
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.
-
Add exactly these complete scope URLs in Data Access:
https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonlyyoutube.uploadis the minimum insertion scope. The separateyoutube.readonlyscope allowschannels.list(mine=true)and ownervideos.liststatus/processing readback; upload alone is absent from those methods' allowed scope lists in API discovery. Do not silently substitute the much broaderyoutube,force-sslor partner scopes. Reject a missing or expanded returned grant rather than guessing. -
Create a Desktop app OAuth client. For an assistant-operated automated client, follow Google's AI-powered client declaration 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.
-
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.
Use Google's installed-app authorization-code flow,
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
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.
- Generate a cryptographically random verifier (43–128 URL-safe characters),
BASE64URL(SHA256(verifier))challenge and independent unpredictable state. Bind a temporary IPv4 loopback listener to127.0.0.1on a free port. - The human opens
https://accounts.google.com/o/oauth2/v2/authwith the approvedclient_id, exact loopbackredirect_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. - 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.
- POST form data to
https://oauth2.googleapis.com/token:grant_type=authorization_code,client_id,client_secret,code, the sameredirect_uri, andcode_verifier. Perform this in the private helper, not a command that prints token JSON. - 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
.envfile, 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. 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.
In the consumer repository's Settings → Environments, create <ENVIRONMENT>:
-
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_TOKENas 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:
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.
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:
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:
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.
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.
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:
- 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=falseis not content approval. - 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.
- 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.
- 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.
- 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:writeis 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. - Commit
reservedbeforevideos.insert. If reservation persistence is failed/uncertain, send no media. Persist returned ID asuploadedbefore readback; only markverifiedafter 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. - 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:
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.
In this library checkout, the example has offline tests using ScriptedDriver,
not Google/network access. After installing the development dependencies:
php -l examples/ReviewedUpload.php
vendor/bin/phpunit --filter=ReviewedUploadTestIn 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:
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=<ID>.
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.
Official documentation checked 2026-10-20: 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.