oauth = $oauth; } /** * @return array|\WP_Error The authenticated user. */ public function get_authenticated_user() { return $this->request( 'GET', '/user' ); } /** * @param string $owner * @param string $repo * * @return array|\WP_Error Repo details, including * ['default_branch']. */ public function get_repo( $owner, $repo ) { return $this->request( 'GET', "/repos/{$owner}/{$repo}" ); } /** * @param string $owner * @param string $repo * @param string $branch * * @return string|\WP_Error Latest commit SHA on $branch. */ public function get_latest_commit_sha( $owner, $repo, $branch ) { $commit = $this->request( 'GET', "/repos/{$owner}/{$repo}/commits/{$branch}" ); if ( is_wp_error( $commit ) ) { return $commit; } if ( empty( $commit['sha'] ) ) { return new \WP_Error( 'esipher_github_no_commit', __( 'GitHub returned no commit for that branch.', 'esipher' ) ); } return $commit['sha']; } /** * Ensure the Esipher build workflow exists at * .github/workflows/build.yml in $owner/$repo, creating or * updating it only if its content actually differs — a no-op * PUT to the Contents API still creates a commit, so this avoids * littering the user's repo with an empty commit every time they * reconnect or re-check an already up-to-date project. * * @param string $owner * @param string $repo * @param string $branch * @param string $workflow_yaml Raw workflow file contents. * * @return true|\WP_Error */ public function ensure_workflow_file( $owner, $repo, $branch, $workflow_yaml ) { $path = '.github/workflows/build.yml'; $existing = $this->request( 'GET', "/repos/{$owner}/{$repo}/contents/{$path}", array( 'ref' => $branch ) ); $existing_sha = null; $already_ready = false; if ( ! is_wp_error( $existing ) && isset( $existing['sha'], $existing['content'] ) ) { $existing_sha = $existing['sha']; $decoded = base64_decode( str_replace( "\n", '', $existing['content'] ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding the Contents API's own base64 envelope, not deobfuscating anything. $already_ready = ( false !== $decoded && trim( $decoded ) === trim( $workflow_yaml ) ); } if ( $already_ready ) { return true; } $body = array( 'message' => $existing_sha ? 'Update Esipher build workflow' : 'Add Esipher build workflow (auto-builds every push for Esipher WordPress sync)', 'content' => base64_encode( $workflow_yaml ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- the Contents API requires base64-encoded file content, not obfuscation. 'branch' => $branch, ); if ( $existing_sha ) { $body['sha'] = $existing_sha; } $result = $this->request( 'PUT', "/repos/{$owner}/{$repo}/contents/{$path}", null, $body ); return is_wp_error( $result ) ? $result : true; } /** * Create a workflow_run webhook on $owner/$repo pointed at this * site's own webhook receiver. * * @param string $owner * @param string $repo * @param string $callback_url This site's REST webhook endpoint. * @param string $secret Shared secret GitHub will sign * deliveries with (X-Hub-Signature-256). * * @return int|\WP_Error The new webhook's numeric ID. */ public function create_webhook( $owner, $repo, $callback_url, $secret ) { $body = array( 'name' => 'web', 'active' => true, 'events' => array( 'workflow_run' ), 'config' => array( 'url' => $callback_url, 'content_type' => 'json', 'secret' => $secret, 'insecure_ssl' => '0', ), ); $result = $this->request( 'POST', "/repos/{$owner}/{$repo}/hooks", null, $body ); if ( is_wp_error( $result ) ) { return $result; } if ( empty( $result['id'] ) ) { return new \WP_Error( 'esipher_github_webhook_create', __( 'GitHub did not return a webhook ID.', 'esipher' ) ); } return (int) $result['id']; } /** * @param string $owner * @param string $repo * @param int $webhook_id * * @return true|\WP_Error */ public function delete_webhook( $owner, $repo, $webhook_id ) { $result = $this->request( 'DELETE', "/repos/{$owner}/{$repo}/hooks/{$webhook_id}" ); // A 404 here just means the webhook was already gone (e.g. the // user deleted the repo, or removed it manually on GitHub's // side) — not a reason to block disconnecting locally. if ( is_wp_error( $result ) && 404 !== (int) $result->get_error_code() ) { return $result; } return true; } /** * Manually trigger a workflow — the admin "Sync now" / "Rebuild" * buttons, so a user doesn't have to wait for their next push just * to test the setup or pull in a change that predates it. * * @param string $owner * @param string $repo * @param string $branch * @param string $workflow_file Workflow file name. Defaults to the * theme build; the app build passes * 'build-app.yml'. * @param array|null $inputs workflow_dispatch inputs, if the * workflow declares any. * * @return true|\WP_Error */ public function dispatch_workflow( $owner, $repo, $branch, $workflow_file = 'build.yml', $inputs = null ) { $body = array( 'ref' => $branch ); if ( ! empty( $inputs ) ) { $body['inputs'] = $inputs; } $result = $this->request( 'POST', "/repos/{$owner}/{$repo}/actions/workflows/{$workflow_file}/dispatches", null, $body ); return is_wp_error( $result ) ? $result : true; } /** * @param string $owner * @param string $repo * @param int $run_id * * @return array|\WP_Error Run details, including * ['status'], ['conclusion']. */ public function get_run( $owner, $repo, $run_id ) { return $this->request( 'GET', "/repos/{$owner}/{$repo}/actions/runs/{$run_id}" ); } /** * @param string $owner * @param string $repo * @param int $run_id * * @return array|\WP_Error * Map of job name => {status, conclusion}. */ public function get_run_jobs( $owner, $repo, $run_id ) { $result = $this->request( 'GET', "/repos/{$owner}/{$repo}/actions/runs/{$run_id}/jobs", array( 'per_page' => 50 ) ); if ( is_wp_error( $result ) ) { return $result; } $jobs = array(); foreach ( (array) ( $result['jobs'] ?? array() ) as $job ) { if ( ! empty( $job['name'] ) ) { $jobs[ (string) $job['name'] ] = array( 'status' => (string) ( $job['status'] ?? '' ), 'conclusion' => (string) ( $job['conclusion'] ?? '' ), ); } } return $jobs; } /** * @param string $owner * @param string $repo * @param string $branch * @param string|null $workflow_file Restrict to one workflow file's * runs (e.g. 'build-app.yml'); null * for any workflow. * * @return array|\WP_Error Most recent run on $branch, * any status. */ public function get_latest_run( $owner, $repo, $branch, $workflow_file = null ) { $path = $workflow_file ? "/repos/{$owner}/{$repo}/actions/workflows/{$workflow_file}/runs" : "/repos/{$owner}/{$repo}/actions/runs"; $result = $this->request( 'GET', $path, array( 'branch' => $branch, 'per_page' => 1, ) ); if ( is_wp_error( $result ) ) { return $result; } if ( empty( $result['workflow_runs'][0] ) ) { return new \WP_Error( 'esipher_github_no_runs', __( 'No workflow runs found yet for this branch.', 'esipher' ) ); } return $result['workflow_runs'][0]; } /** * @param string $owner * @param string $repo * @param int $run_id * * @param string $owner * @param string $repo * @param int $run_id * @param string $artifact_name Which artifact to return. Defaults to * the theme build; the app build passes * 'esipher-app'. * * @return array|\WP_Error The matching artifact (id, * name, size, etc.), or a * \WP_Error if that run has no * such artifact. */ public function find_build_artifact( $owner, $repo, $run_id, $artifact_name = 'esipher-build' ) { $result = $this->request( 'GET', "/repos/{$owner}/{$repo}/actions/runs/{$run_id}/artifacts" ); if ( is_wp_error( $result ) ) { return $result; } foreach ( (array) ( $result['artifacts'] ?? array() ) as $artifact ) { if ( isset( $artifact['name'] ) && $artifact_name === $artifact['name'] ) { return $artifact; } } return new \WP_Error( 'esipher_github_no_artifact', sprintf( /* translators: %s: artifact name. */ __( 'That run finished but produced no "%s" artifact.', 'esipher' ), $artifact_name ) ); } /** * Download an artifact's zip to a local temp file. * * The artifacts/{id}/zip endpoint replies with a 302 to a * short-lived, pre-signed storage URL (GitHub-hosted runners put * these on Azure Blob Storage). That signed URL carries its own * credentials in the query string and rejects an Authorization * header outright with 401 — and both of WordPress's HTTP transports * re-send a caller-supplied Authorization header across a redirect * hop, cross-host or not. So resolve the redirect ourselves and * fetch the storage URL with no Authorization header attached. * * @param string $owner * @param string $repo * @param int $artifact_id * * @return string|\WP_Error Path to the downloaded zip. */ public function download_artifact( $owner, $repo, $artifact_id ) { $url = self::API_BASE . "/repos/{$owner}/{$repo}/actions/artifacts/{$artifact_id}/zip"; $redirect = wp_remote_get( $url, array( 'headers' => $this->headers(), 'timeout' => 30, 'redirection' => 0, ) ); if ( is_wp_error( $redirect ) ) { return $redirect; } $redirect_code = (int) wp_remote_retrieve_response_code( $redirect ); $download_url = wp_remote_retrieve_header( $redirect, 'location' ); if ( $redirect_code < 300 || $redirect_code >= 400 || '' === $download_url ) { return new \WP_Error( 'esipher_github_download_failed', sprintf( /* translators: %d: HTTP status code. */ __( 'GitHub did not return a download URL for the build artifact (HTTP %d).', 'esipher' ), $redirect_code ) ); } $filename = trailingslashit( get_temp_dir() ) . 'esipher-github-artifact-' . uniqid( '', true ) . '.zip'; $response = wp_remote_get( $download_url, array( // Deliberately no Authorization header — $download_url is // already signed, and sending one makes storage 401. 'headers' => array( 'User-Agent' => 'Esipher-WordPress-Plugin' ), 'timeout' => 60, 'redirection' => 5, 'stream' => true, 'filename' => $filename, ) ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) { if ( is_file( $filename ) ) { wp_delete_file( $filename ); } return new \WP_Error( 'esipher_github_download_failed', sprintf( /* translators: %d: HTTP status code. */ __( 'Downloading the build artifact failed (HTTP %d).', 'esipher' ), $code ) ); } if ( ! is_file( $filename ) ) { return new \WP_Error( 'esipher_github_download_missing', __( 'The artifact download did not produce a file.', 'esipher' ) ); } return $filename; } /** * Commit a whole set of files to $branch in one commit via the Git * Data API (blobs -> tree -> commit -> ref) — one commit for the * lot, rather than the Contents API's one-commit-per-file. * * A brand-new empty repo (or a missing branch) is bootstrapped * first with a throwaway seed commit via the Contents API, which * this same commit then removes, so the finished repo holds only * the project files (in two commits on a fresh repo, one otherwise). * * @param string $owner * @param string $repo * @param string $branch * @param array $files * Repo-relative path => file. `content` is raw * text unless `is_base64`, in which case it's * already base64 of the raw bytes. * @param string $message Commit message. * * @return true|\WP_Error */ public function commit_files( $owner, $repo, $branch, array $files, $message ) { if ( empty( $files ) ) { return new \WP_Error( 'esipher_github_commit_empty', __( 'No files to commit.', 'esipher' ) ); } $ref = $this->request( 'GET', "/repos/{$owner}/{$repo}/git/ref/heads/{$branch}" ); $base_commit_sha = null; $base_tree_sha = null; $seeded_path = null; if ( is_wp_error( $ref ) ) { // No such branch (404), or the repo has no commits at all // (409 "Git Repository is empty"). The Git Data API below // can't create a blob in a repo with zero objects, so make // the first commit through the Contents API — which can — // and build on top of that. $has_commits = ! $this->repo_is_empty( $owner, $repo ); $seed = $this->seed_first_commit( $owner, $repo, $branch, $has_commits ); if ( is_wp_error( $seed ) ) { return $seed; } $base_commit_sha = $seed['commit_sha']; $base_tree_sha = $seed['tree_sha']; $seeded_path = $seed['path']; $branch = $seed['branch']; // GitHub may have used its own default branch name. } elseif ( ! empty( $ref['object']['sha'] ) ) { $base_commit_sha = $ref['object']['sha']; $base_commit = $this->request( 'GET', "/repos/{$owner}/{$repo}/git/commits/{$base_commit_sha}" ); if ( is_wp_error( $base_commit ) ) { return $base_commit; } $base_tree_sha = $base_commit['tree']['sha'] ?? null; } $tree = array(); // Drop the throwaway seed file in this same commit, so the // finished repo contains only the real project. if ( null !== $seeded_path && ! isset( $files[ $seeded_path ] ) ) { $tree[] = array( 'path' => $seeded_path, 'mode' => '100644', 'type' => 'blob', 'sha' => null, ); } foreach ( $files as $path => $file ) { $raw = ! empty( $file['is_base64'] ) ? base64_decode( $file['content'], true ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- caller handed us base64 of real bytes; turning it back for the blob upload. : (string) $file['content']; if ( false === $raw ) { return new \WP_Error( 'esipher_github_commit_badfile', sprintf( '%s: unreadable content.', $path ) ); } $blob = $this->request( 'POST', "/repos/{$owner}/{$repo}/git/blobs", null, array( 'content' => base64_encode( $raw ), 'encoding' => 'base64' ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- the blobs API's own transport encoding. ); if ( is_wp_error( $blob ) ) { return $blob; } $tree[] = array( 'path' => ltrim( (string) $path, '/' ), 'mode' => '100644', 'type' => 'blob', 'sha' => $blob['sha'] ?? '', ); } $tree_body = array( 'tree' => $tree ); if ( $base_tree_sha ) { $tree_body['base_tree'] = $base_tree_sha; } $new_tree = $this->request( 'POST', "/repos/{$owner}/{$repo}/git/trees", null, $tree_body ); if ( is_wp_error( $new_tree ) ) { return $new_tree; } $commit_body = array( 'message' => $message, 'tree' => $new_tree['sha'] ?? '', ); if ( $base_commit_sha ) { $commit_body['parents'] = array( $base_commit_sha ); } $new_commit = $this->request( 'POST', "/repos/{$owner}/{$repo}/git/commits", null, $commit_body ); if ( is_wp_error( $new_commit ) ) { return $new_commit; } $new_commit_sha = $new_commit['sha'] ?? ''; // A base commit always exists by now (real or seeded), so the // branch ref is only ever moved, never created. $updated = $this->request( 'PATCH', "/repos/{$owner}/{$repo}/git/refs/heads/{$branch}", null, array( 'sha' => $new_commit_sha, 'force' => true ) ); return is_wp_error( $updated ) ? $updated : true; } /** * @param string $owner * @param string $repo * * @return bool True when the repo has no commits on any branch. */ private function repo_is_empty( $owner, $repo ) { $branches = $this->request( 'GET', "/repos/{$owner}/{$repo}/branches", array( 'per_page' => 1 ) ); // A truly empty repo returns []. An API error here (rate limit, // permissions) — assume not empty and let the caller's own // error handling deal with whatever comes next. return is_array( $branches ) && empty( $branches ); } /** * Give $branch a first commit to build on: * * - Empty repo (no commits anywhere): create README.md via the * Contents API with NO branch param — GitHub then creates the * repo's default branch. Passing a branch that doesn't exist yet * is what makes this path fail with "Git Repository is empty". * - Repo has commits but not $branch: branch the default branch's * tip into refs/heads/$branch via the Git Data API. * * @param string $owner * @param string $repo * @param string $branch * @param bool $has_commits Whether the repo already has any commit. * * @return array{commit_sha:string,tree_sha:string,path:?string,branch:string}|\WP_Error */ private function seed_first_commit( $owner, $repo, $branch, $has_commits ) { if ( $has_commits ) { $repo_info = $this->request( 'GET', "/repos/{$owner}/{$repo}" ); $default = is_wp_error( $repo_info ) ? 'main' : ( $repo_info['default_branch'] ?? 'main' ); $default_ref = $this->request( 'GET', "/repos/{$owner}/{$repo}/git/ref/heads/{$default}" ); if ( is_wp_error( $default_ref ) || empty( $default_ref['object']['sha'] ) ) { return new \WP_Error( 'esipher_github_seed_failed', __( 'Could not find a commit to branch from in the app repo.', 'esipher' ) ); } $tip = $default_ref['object']['sha']; $created = $this->request( 'POST', "/repos/{$owner}/{$repo}/git/refs", null, array( 'ref' => "refs/heads/{$branch}", 'sha' => $tip ) ); if ( is_wp_error( $created ) ) { return $created; } $commit = $this->request( 'GET', "/repos/{$owner}/{$repo}/git/commits/{$tip}" ); return array( 'commit_sha' => $tip, 'tree_sha' => is_wp_error( $commit ) ? '' : ( $commit['tree']['sha'] ?? '' ), 'path' => null, 'branch' => $branch, ); } $result = $this->request( 'PUT', "/repos/{$owner}/{$repo}/contents/README.md", null, array( 'message' => 'Initialize repository (Esipher)', 'content' => base64_encode( "# {$repo}\n\nInitialized by the Esipher WordPress plugin.\n" ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- the Contents API requires base64-encoded content. ) ); if ( is_wp_error( $result ) ) { return $result; } $commit_sha = $result['commit']['sha'] ?? ''; $tree_sha = $result['commit']['tree']['sha'] ?? ''; if ( '' === $commit_sha || '' === $tree_sha ) { return new \WP_Error( 'esipher_github_seed_failed', __( 'Could not initialize the empty app repo.', 'esipher' ) ); } // Whatever branch GitHub just created (its default) is the one // to keep building on. $repo_info = $this->request( 'GET', "/repos/{$owner}/{$repo}" ); $new_branch = is_wp_error( $repo_info ) ? $branch : ( $repo_info['default_branch'] ?: $branch ); return array( 'commit_sha' => $commit_sha, 'tree_sha' => $tree_sha, 'path' => 'README.md', 'branch' => $new_branch, ); } /** * @param string $owner * @param string $repo * * @return array{key:string,key_id:string}|\WP_Error The repo's * Actions secrets public key (libsodium, base64) and its id. */ public function get_actions_public_key( $owner, $repo ) { $result = $this->request( 'GET', "/repos/{$owner}/{$repo}/actions/secrets/public-key" ); if ( is_wp_error( $result ) ) { return $result; } if ( empty( $result['key'] ) || empty( $result['key_id'] ) ) { return new \WP_Error( 'esipher_github_secret_key', __( 'GitHub did not return a key for encrypting repository secrets.', 'esipher' ) ); } return array( 'key' => (string) $result['key'], 'key_id' => (string) $result['key_id'] ); } /** * Create/update one Actions secret. The value is sealed to the * repo's public key with libsodium before it leaves this server — * GitHub never sees it in the clear. * * @param string $owner * @param string $repo * @param string $name Secret name (A-Z, 0-9, _). * @param string $plaintext Secret value. * * @return true|\WP_Error */ public function put_actions_secret( $owner, $repo, $name, $plaintext ) { if ( ! function_exists( 'sodium_crypto_box_seal' ) ) { return new \WP_Error( 'esipher_github_no_sodium', __( 'This server\'s PHP is missing libsodium, needed to send an encrypted secret to GitHub.', 'esipher' ) ); } $key = $this->get_actions_public_key( $owner, $repo ); if ( is_wp_error( $key ) ) { return $key; } $public_key = base64_decode( $key['key'], true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding GitHub's own base64 public key. if ( false === $public_key || '' === $public_key ) { return new \WP_Error( 'esipher_github_secret_key', __( 'GitHub\'s repository-secret key could not be read.', 'esipher' ) ); } $sealed = sodium_crypto_box_seal( $plaintext, $public_key ); $result = $this->request( 'PUT', "/repos/{$owner}/{$repo}/actions/secrets/{$name}", null, array( 'encrypted_value' => base64_encode( $sealed ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- the secrets API expects base64 ciphertext. 'key_id' => $key['key_id'], ) ); return is_wp_error( $result ) ? $result : true; } /** * Create or update a repository Actions *variable* (plain text, not * a secret) — used to gate a job with `if: vars.NAME == '...'`, * which `secrets` and `hashFiles()` can't do at the job level. * * @param string $owner * @param string $repo * @param string $name Variable name (A-Z, 0-9, _). * @param string $value Variable value. * * @return true|\WP_Error */ public function put_actions_variable( $owner, $repo, $name, $value ) { $updated = $this->request( 'PATCH', "/repos/{$owner}/{$repo}/actions/variables/{$name}", null, array( 'name' => $name, 'value' => (string) $value ) ); if ( ! is_wp_error( $updated ) ) { return true; } if ( 404 !== (int) $updated->get_error_code() ) { return $updated; } $created = $this->request( 'POST', "/repos/{$owner}/{$repo}/actions/variables", null, array( 'name' => $name, 'value' => (string) $value ) ); return is_wp_error( $created ) ? $created : true; } /** * @param string $method HTTP method. * @param string $path API path, e.g. "/repos/owner/repo". * @param array|null $query Query-string params. * @param array|null $body JSON request body. * * @return array|\WP_Error Decoded JSON body on * success, or an error whose * get_error_code() is the * HTTP status when GitHub * itself responded. */ private function request( $method, $path, $query = null, $body = null ) { $url = self::API_BASE . $path; if ( ! empty( $query ) ) { $url = add_query_arg( $query, $url ); } $args = array( 'method' => $method, 'headers' => $this->headers(), 'timeout' => 30, ); if ( null !== $body ) { $args['body'] = wp_json_encode( $body ); } $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); $decoded_body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( $code < 200 || $code >= 300 ) { $message = is_array( $decoded_body ) && ! empty( $decoded_body['message'] ) ? $decoded_body['message'] /* translators: %d: HTTP status code. */ : sprintf( __( 'GitHub API request failed (HTTP %d).', 'esipher' ), $code ); return new \WP_Error( $code, $message ); } return is_array( $decoded_body ) ? $decoded_body : array(); } /** * @return array */ private function headers() { return array( 'Authorization' => 'Bearer ' . $this->oauth->token(), 'Accept' => 'application/vnd.github+json', 'User-Agent' => 'Esipher-WordPress-Plugin', ); } }