Skip to content

Commit

Permalink
feat(build): implement OAuth device flow
Browse files Browse the repository at this point in the history
  • Loading branch information
tido64 committed May 29, 2024
1 parent 0179568 commit 80567d6
Show file tree
Hide file tree
Showing 7 changed files with 125 additions and 58 deletions.
12 changes: 6 additions & 6 deletions .github/workflows/rnx-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,21 +83,21 @@ jobs:
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
run: npx --prefix . rnx-build-apple install-certificate
run: /bin/bash $(node --print 'require.resolve("@rnx-kit/build/scripts/build-apple")') install-certificate
- name: Build iOS app
run: npx --prefix . rnx-build-apple build-ios --scheme ${{ github.event.inputs.scheme }} --device-type ${{ github.event.inputs.deviceType }} --archs ${{ github.event.inputs.architecture }}
run: /bin/bash $(node --print 'require.resolve("@rnx-kit/build/scripts/build-apple")') build-ios --scheme ${{ github.event.inputs.scheme }} --device-type ${{ github.event.inputs.deviceType }} --archs ${{ github.event.inputs.architecture }}
working-directory: ${{ github.event.inputs.projectRoot }}/ios
- name: Remove Apple signing certificate and provisioning profile
# Always run this job step, even if previous ones fail. See also
# https://docs.github.com/en/actions/deployment/deploying-xcode-applications/installing-an-apple-certificate-on-macos-runners-for-xcode-development#required-clean-up-on-self-hosted-runners
if: ${{ always() && github.event.inputs.deviceType == 'device' }}
run: npx --prefix . rnx-build-apple uninstall-certificate
run: /bin/bash $(node --print 'require.resolve("@rnx-kit/build/scripts/build-apple")') uninstall-certificate
- name: Prepare build artifact
id: prepare-build-artifact
run: |
if [[ ${{ github.event.inputs.distribution }} == 'local' ]]; then
app=$(find ${XCARCHIVE_FILE}/Products/Applications -maxdepth 1 -name '*.app' -type d | head -1)
npx --prefix . rnx-build-apple archive ios-artifact.tar "${app}"
/bin/bash $(node --print 'require.resolve("@rnx-kit/build/scripts/build-apple")') archive ios-artifact.tar "${app}"
echo 'filename=ios-artifact.tar' >> $GITHUB_OUTPUT
else
xcodebuild -exportArchive -archivePath ${XCARCHIVE_FILE} -exportPath export -exportOptionsPlist ExportOptions.plist 2>&1
Expand Down Expand Up @@ -131,13 +131,13 @@ jobs:
run: pod install --project-directory=macos --verbose
working-directory: ${{ github.event.inputs.projectRoot }}
- name: Build macOS app
run: npx --prefix . rnx-build-apple build-macos --scheme ${{ github.event.inputs.scheme }}
run: /bin/bash $(node --print 'require.resolve("@rnx-kit/build/scripts/build-apple")') build-macos --scheme ${{ github.event.inputs.scheme }}
working-directory: ${{ github.event.inputs.projectRoot }}/macos
- name: Prepare build artifact
run: |
output_path=DerivedData/Build/Products
app=$(find ${output_path} -maxdepth 2 -name '*.app' -type d | head -1)
npx --prefix . rnx-build-apple archive macos-artifact.tar "${app}"
/bin/bash $(node --print 'require.resolve("@rnx-kit/build/scripts/build-apple")') archive macos-artifact.tar "${app}"
working-directory: ${{ github.event.inputs.projectRoot }}/macos
- name: Upload build artifact
uses: actions/upload-artifact@v4
Expand Down
4 changes: 4 additions & 0 deletions incubator/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
"types": "./lib/index.d.ts",
"import": "./lib/index.js"
},
"./scripts/build-apple": {
"require": "./scripts/build-apple.sh"
},
"./package.json": "./package.json"
},
"bin": {
Expand All @@ -40,6 +43,7 @@
"test": "rnx-kit-scripts test"
},
"dependencies": {
"@octokit/auth-oauth-device": "^7.1.1",
"@octokit/core": "^6.0.0",
"@octokit/plugin-rest-endpoint-methods": "^13.0.0",
"@octokit/request-error": "^6.0.0",
Expand Down
95 changes: 55 additions & 40 deletions incubator/build/src/remotes/github.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
import { Octokit } from "@octokit/core";
import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods";
Expand Down Expand Up @@ -34,10 +35,10 @@ const POLL_INTERVAL = 1000;

const workflowRunCache: Record<string, number> = {};

const octokit = once(() => {
const octokit = once((auth = "") => {
const RestClient = Octokit.plugin(restEndpointMethods);
return new RestClient({
auth: getPersonalAccessToken(),
auth,
// Use `node-fetch` only if Node doesn't implement Fetch API:
// https://github.com/octokit/request.js/blob/v8.1.1/src/fetch-wrapper.ts#L28-L31
request: "fetch" in globalThis ? undefined : { fetch },
Expand Down Expand Up @@ -82,18 +83,49 @@ async function downloadArtifact(
return filename;
}

function getPersonalAccessToken(): string | undefined {
if (process.env.GITHUB_TOKEN) {
return process.env.GITHUB_TOKEN;
async function getPersonalAccessToken(logger: Ora): Promise<string> {
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
if (GITHUB_TOKEN) {
return GITHUB_TOKEN;
}

if (!fs.existsSync(USER_CONFIG_FILE)) {
return undefined;
const config: UserConfig = (() => {
if (fs.existsSync(USER_CONFIG_FILE)) {
const content = fs.readFileSync(USER_CONFIG_FILE, { encoding: "utf-8" });
return JSON.parse(content);
}
return {};
})();
const githubToken = config.github?.token;
if (githubToken) {
return githubToken;
}

const content = fs.readFileSync(USER_CONFIG_FILE, { encoding: "utf-8" });
const config: UserConfig = JSON.parse(content);
return config?.github?.token;
const auth = createOAuthDeviceAuth({
clientType: "oauth-app",
clientId: "Ov23litJ0KvI5TF8gZiE", // TODO: Register app under an org
scopes: ["workflow", "public_repo"],
onVerification: (verification) => {
logger.info(
"@rnx-kit/build requires your permission to dispatch builds on GitHub"
);
logger.info(`Open ${verification.verification_uri}`);
logger.info(`Enter code: ${verification.user_code}`);
},
});

const { token } = await auth({ type: "oauth" });
if (config.github) {
config.github.token = token;
} else {
config.github = { token };
}
fs.writeFile(
USER_CONFIG_FILE,
JSON.stringify(config, undefined, 2) + "\n",
() => 0
);
return token;
}

async function getWorkflowRunId(
Expand All @@ -120,9 +152,9 @@ async function getWorkflowRunId(

async function watchWorkflowRun(
runId: WorkflowRunId,
spinner: Ora
logger: Ora
): Promise<string | null> {
spinner.start("Starting build");
logger.start("Starting build");

const max = Math.max;
const now = Date.now;
Expand Down Expand Up @@ -174,13 +206,13 @@ async function watchWorkflowRun(
const elapsed = elapsedTime(started_at, completed_at);
switch (conclusion) {
case "failure":
spinner.fail(`${name} failed (${elapsed})`);
logger.fail(`${name} failed (${elapsed})`);
break;
case "success":
spinner.succeed(`${name} succeeded (${elapsed})`);
logger.succeed(`${name} succeeded (${elapsed})`);
break;
default:
spinner.fail(`${name} ${conclusion} (${elapsed})`);
logger.fail(`${name} ${conclusion} (${elapsed})`);
break;
}
return conclusion || result;
Expand All @@ -199,7 +231,7 @@ async function watchWorkflowRun(
continue;
}

spinner.text = `${currentStep} (${elapsedTime(jobStartedAt)})`;
logger.text = `${currentStep} (${elapsedTime(jobStartedAt)})`;
idleTime = max(100, POLL_INTERVAL - (now() - start));
}

Expand Down Expand Up @@ -255,33 +287,16 @@ export async function install(): Promise<number> {
return 1;
}

if (!getPersonalAccessToken()) {
const exampleConfig: UserConfig = {
github: { token: "github_pat_0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" },
};
const example = JSON.stringify(exampleConfig);
console.error(
"Missing personal access token for GitHub.\n\nPlease create a " +
"fine-grained personal access token, and save it in " +
`'${USER_CONFIG_FILE}' like below:\n\n\t${example}\n\nThe ` +
"token must have `action:write` permission. When creating a new " +
"fine-grained personal access token, under 'Repository permissions', " +
"make sure 'Actions' is set to 'Read and write'.\n\n" +
"For how to create a personal access token, see: " +
"https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
);
return 1;
}

return 0;
}

export async function build(
{ owner, repo, ref }: Context,
inputs: BuildParams,
spinner: Ora
logger: Ora
): Promise<string | null> {
await octokit().rest.actions.createWorkflowDispatch({
const token = await getPersonalAccessToken(logger);
await octokit(token).rest.actions.createWorkflowDispatch({
owner,
repo,
workflow_id: WORKFLOW_ID,
Expand All @@ -300,11 +315,11 @@ export async function build(
});
workflowRunCache[ref] = workflowRunId.run_id;

spinner.succeed(
logger.succeed(
`Build queued: https://github.com/${owner}/${repo}/actions/runs/${workflowRunId.run_id}`
);

const conclusion = await watchWorkflowRun(workflowRunId, spinner);
const conclusion = await watchWorkflowRun(workflowRunId, logger);
delete workflowRunCache[ref];
if (conclusion !== "success") {
return null;
Expand All @@ -314,9 +329,9 @@ export async function build(
return inputs.distribution;
}

spinner.start("Downloading build artifact");
logger.start("Downloading build artifact");
const artifactFile = await downloadArtifact(workflowRunId, inputs);
spinner.succeed(`Build artifact saved to ${artifactFile}`);
logger.succeed(`Build artifact saved to ${artifactFile}`);

return artifactFile;
}
Expand Down
12 changes: 6 additions & 6 deletions incubator/build/workflows/github.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,21 +83,21 @@ jobs:
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
run: npx --prefix . rnx-build-apple install-certificate
run: /bin/bash $(node --print 'require.resolve("@rnx-kit/build/scripts/build-apple")') install-certificate
- name: Build iOS app
run: npx --prefix . rnx-build-apple build-ios --scheme ${{ github.event.inputs.scheme }} --device-type ${{ github.event.inputs.deviceType }} --archs ${{ github.event.inputs.architecture }}
run: /bin/bash $(node --print 'require.resolve("@rnx-kit/build/scripts/build-apple")') build-ios --scheme ${{ github.event.inputs.scheme }} --device-type ${{ github.event.inputs.deviceType }} --archs ${{ github.event.inputs.architecture }}
working-directory: ${{ github.event.inputs.projectRoot }}/ios
- name: Remove Apple signing certificate and provisioning profile
# Always run this job step, even if previous ones fail. See also
# https://docs.github.com/en/actions/deployment/deploying-xcode-applications/installing-an-apple-certificate-on-macos-runners-for-xcode-development#required-clean-up-on-self-hosted-runners
if: ${{ always() && github.event.inputs.deviceType == 'device' }}
run: npx --prefix . rnx-build-apple uninstall-certificate
run: /bin/bash $(node --print 'require.resolve("@rnx-kit/build/scripts/build-apple")') uninstall-certificate
- name: Prepare build artifact
id: prepare-build-artifact
run: |
if [[ ${{ github.event.inputs.distribution }} == 'local' ]]; then
app=$(find ${XCARCHIVE_FILE}/Products/Applications -maxdepth 1 -name '*.app' -type d | head -1)
npx --prefix . rnx-build-apple archive ios-artifact.tar "${app}"
/bin/bash $(node --print 'require.resolve("@rnx-kit/build/scripts/build-apple")') archive ios-artifact.tar "${app}"
echo 'filename=ios-artifact.tar' >> $GITHUB_OUTPUT
else
xcodebuild -exportArchive -archivePath ${XCARCHIVE_FILE} -exportPath export -exportOptionsPlist ExportOptions.plist 2>&1
Expand Down Expand Up @@ -131,13 +131,13 @@ jobs:
run: pod install --project-directory=macos --verbose
working-directory: ${{ github.event.inputs.projectRoot }}
- name: Build macOS app
run: npx --prefix . rnx-build-apple build-macos --scheme ${{ github.event.inputs.scheme }}
run: /bin/bash $(node --print 'require.resolve("@rnx-kit/build/scripts/build-apple")') build-macos --scheme ${{ github.event.inputs.scheme }}
working-directory: ${{ github.event.inputs.projectRoot }}/macos
- name: Prepare build artifact
run: |
output_path=DerivedData/Build/Products
app=$(find ${output_path} -maxdepth 2 -name '*.app' -type d | head -1)
npx --prefix . rnx-build-apple archive macos-artifact.tar "${app}"
/bin/bash $(node --print 'require.resolve("@rnx-kit/build/scripts/build-apple")') archive macos-artifact.tar "${app}"
working-directory: ${{ github.event.inputs.projectRoot }}/macos
- name: Upload build artifact
uses: actions/upload-artifact@v4
Expand Down
1 change: 1 addition & 0 deletions packages/test-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@react-native/babel-preset": "^0.73.0",
"@react-native/metro-config": "^0.73.0",
"@rnx-kit/babel-preset-metro-react-native": "workspace:*",
"@rnx-kit/build": "workspace:*",
"@rnx-kit/cli": "workspace:*",
"@rnx-kit/eslint-config": "workspace:*",
"@rnx-kit/jest-preset": "workspace:*",
Expand Down
8 changes: 5 additions & 3 deletions packages/tools-shell/src/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ export function idle(ms: number): Promise<void> {
/**
* Wraps the function, making sure it only gets called once.
*/
export function once<R>(func: () => R): () => R {
export function once<R>(
func: (...args: unknown[]) => R
): (...args: unknown[]) => R {
let result: R | undefined;
return () => {
return (...args) => {
if (!result) {
result = func();
result = func(...args);
}
return result;
};
Expand Down
Loading

0 comments on commit 80567d6

Please sign in to comment.