diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml new file mode 100644 index 000000000..a20f6534a --- /dev/null +++ b/.github/workflows/preview-deploy.yml @@ -0,0 +1,441 @@ +name: Coded App Preview Deployments + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + branches: + - main + - 'support/**' + +# Deny-all default; jobs grant the minimum they need. +permissions: {} + +env: + TURBO_TELEMETRY_DISABLED: 1 + DO_NOT_TRACK: 1 + UIP_GO_VERSION: 0.3.0 + +concurrency: + group: coded-app-preview-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + initialize-comment: + name: Initialize Apollo Coded App Preview Comment + if: github.event.action != 'closed' && github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Initialize PR comment + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const identifier = ''; + const timestamp = new Date().toLocaleString('en-US', { + timeZone: 'America/Los_Angeles', + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true + }); + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const deployingLink = `[Deploying...](${runUrl})`; + const logsLink = `[Logs](${runUrl})`; + const projects = ['apollo-design', 'apollo-docs', 'apollo-landing', 'apollo-vertex']; + const body = [ + identifier, + 'Apollo Coded App preview deployments are running.', + '', + '| Project | Status | Preview | Updated (PT) |', + '|---------|--------|---------|--------------|', + ...projects.map(project => `| ${project} | ${deployingLink} | ${logsLink} | ${timestamp} |`) + ].join('\n'); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + const existing = comments.find(comment => + comment.body?.includes(identifier) && + comment.user?.type === 'Bot' && + ['github-actions[bot]', 'github-actions'].includes(comment.user?.login) + ); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + deploy: + name: Deploy ${{ matrix.label }} + needs: initialize-comment + if: ${{ !cancelled() && github.event.action != 'closed' && needs.initialize-comment.result == 'success' && github.event.pull_request.head.repo.fork == false }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + strategy: + # One app per runner, in parallel. Keep going if one app fails so the + # others still publish; each leg reports status via an artifact and the + # job fails at the end if any leg failed. + fail-fast: false + matrix: + include: + - label: apollo-landing + - label: apollo-docs + - label: apollo-design + - label: apollo-vertex + # Live AI Chat: pass the runtime OAuth client so uip-go bakes the + # platform-auth context in and registers this preview's redirect URI. + platform_auth: true + + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install Node dependencies + uses: ./.github/actions/install-node-deps + + - name: Restore Turborepo cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: .turbo + # Per-app key so parallel legs don't race to save one shared cache + # (which would persist only the first/fastest app's task outputs). + key: ${{ runner.os }}-turbo-${{ matrix.label }}-${{ github.ref_name }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ matrix.label }}-${{ github.ref_name }}- + + - name: Deploy Coded App preview with uip-go + id: deploy + env: + GH_NPM_REGISTRY_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + APP_LABEL: ${{ matrix.label }} + PLATFORM_AUTH: ${{ matrix.platform_auth == true }} + UIPATH_BASE_URL: ${{ vars.UIPATH_BASE_URL || secrets.UIPATH_BASE_URL }} + UIPATH_CLIENT_ID: ${{ vars.UIPATH_CLIENT_ID || secrets.UIPATH_CLIENT_ID }} + UIPATH_CLIENT_SECRET: ${{ secrets.UIPATH_CLIENT_SECRET }} + UIPATH_FOLDER_KEY: ${{ vars.UIPATH_FOLDER_KEY || secrets.UIPATH_FOLDER_KEY }} + UIPATH_ORG_NAME: ${{ vars.UIPATH_ORG_NAME || secrets.UIPATH_ORG_NAME }} + UIPATH_TENANT_NAME: ${{ vars.UIPATH_TENANT_NAME || secrets.UIPATH_TENANT_NAME }} + UIPATH_CLIENT_SCOPE: ${{ vars.UIPATH_CLIENT_SCOPE || 'Apps Apps.Read Apps.Write OR.Folders.Read OR.Folders.Write OR.Execution' }} + run: | + set -euo pipefail + + required=(GH_NPM_REGISTRY_TOKEN UIPATH_BASE_URL UIPATH_CLIENT_ID UIPATH_CLIENT_SECRET UIPATH_FOLDER_KEY UIPATH_ORG_NAME UIPATH_TENANT_NAME UIP_GO_VERSION) + missing=0 + for name in "${required[@]}"; do + if [ -z "${!name:-}" ]; then + echo "::error::$name is required for the ${APP_LABEL} Coded App preview deployment." + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + exit 1 + fi + + short_sha="${GITHUB_SHA:0:7}" + version="0.1.0-pr${PR_NUMBER}.${short_sha}.${GITHUB_RUN_ATTEMPT}" + app_name="${APP_LABEL}-pr-${PR_NUMBER}" + uip_go_npmrc="${RUNNER_TEMP}/uip-go.npmrc" + uip_go_prefix="${RUNNER_TEMP}/uip-go" + { + printf '@uipath:registry=https://npm.pkg.github.com\n' + printf '//npm.pkg.github.com/:_authToken=%s\n' "$GH_NPM_REGISTRY_TOKEN" + } > "$uip_go_npmrc" + NPM_CONFIG_USERCONFIG="$uip_go_npmrc" npm install --prefix "$uip_go_prefix" "@uipath/uip-go@${UIP_GO_VERSION}" + UIP_GO="${uip_go_prefix}/node_modules/.bin/uip-go" + + derive_app_url() { + APP_NAME="$1" node <<'NODE' + const baseUrl = process.env.UIPATH_BASE_URL || ''; + const orgName = process.env.UIPATH_ORG_NAME || ''; + const appName = process.env.APP_NAME || ''; + + let environment = ''; + try { + const hostname = new URL(baseUrl).hostname; + let inferred = hostname.replace(/\.uipath\.com$/, ''); + inferred = inferred.replace(/^api\./, '').replace(/\.api$/, ''); + environment = inferred && inferred !== 'api' && inferred !== 'cloud' ? inferred : ''; + } catch { + environment = ''; + } + + const suffix = environment ? `.${environment}` : ''; + console.log(`https://${orgName}${suffix}.uipath.host/${appName}`); + NODE + } + + # The platform-auth app (vertex) carries its client id in the recipe + # (first-party Uber.Client). Its Coded App host is pre-registered as a + # redirect URI via a host wildcard in first-party-service-metadata, so + # skip per-deployment redirect-URI registration. + extra_args=() + if [ "$PLATFORM_AUTH" = "true" ]; then + extra_args+=(--no-aichat-redirects) + fi + + output_file="$(mktemp)" + echo "::group::uip-go ${APP_LABEL}" + set +e + "$UIP_GO" "$APP_LABEL" \ + --name "$app_name" \ + --path-name "$app_name" \ + --tags "apollo,${APP_LABEL},preview" \ + --version "$version" \ + --folder-key "$UIPATH_FOLDER_KEY" \ + --base-url "$UIPATH_BASE_URL" \ + --org-name "$UIPATH_ORG_NAME" \ + --tenant-name "$UIPATH_TENANT_NAME" \ + --deploy-retries 3 \ + --deploy-retry-delay-ms 10000 \ + "${extra_args[@]}" 2>&1 | tee "$output_file" + command_exit=${PIPESTATUS[0]} + set -e + echo "::endgroup::" + + app_url="$(sed -nE 's/^[[:space:]]*App URL:[[:space:]]*//p' "$output_file" | tail -n 1)" + if [ -z "$app_url" ] && [ "$command_exit" -eq 0 ]; then + app_url="$(derive_app_url "$app_name")" + fi + if [ -n "$app_url" ]; then + case "$app_url" in + */) ;; + *) app_url="${app_url}/" ;; + esac + echo "url=$app_url" >> "$GITHUB_OUTPUT" + fi + + if [ "$command_exit" -ne 0 ]; then + echo "::error::uip-go ${APP_LABEL} failed." + exit "$command_exit" + fi + + - name: Save Turborepo cache + if: always() + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ matrix.label }}-${{ github.ref_name }}-${{ github.sha }} + + - name: Save deploy result + if: always() + env: + APP_LABEL: ${{ matrix.label }} + DEPLOY_URL: ${{ steps.deploy.outputs.url }} + DEPLOY_OUTCOME: ${{ steps.deploy.outcome }} + run: | + mkdir -p "$RUNNER_TEMP/deploy-result" + + if [ "$DEPLOY_OUTCOME" = "success" ]; then + outcome="success" + error="" + elif [ "$DEPLOY_OUTCOME" = "failure" ]; then + outcome="failure" + error="uip-go deploy failed for $APP_LABEL" + else + outcome="skipped" + error="" + fi + + printf '%s' "${DEPLOY_URL:-}" > "$RUNNER_TEMP/deploy-result/url" + printf '%s' "$outcome" > "$RUNNER_TEMP/deploy-result/outcome" + printf '%s' "$error" > "$RUNNER_TEMP/deploy-result/error" + + - name: Upload deploy result + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: deploy-result-${{ matrix.label }} + path: ${{ runner.temp }}/deploy-result/ + retention-days: 1 + + update-comment: + name: Update Apollo Coded App Preview Comment + needs: [initialize-comment, deploy] + if: ${{ always() && github.event.action != 'closed' && github.event.pull_request.head.repo.fork == false && needs.initialize-comment.result != 'skipped' }} + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Download deploy results + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: deploy-result-* + path: ${{ runner.temp }}/results + + - name: Update PR comment + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + RESULTS_DIR: ${{ runner.temp }}/results + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const identifier = ''; + const projects = ['apollo-design', 'apollo-docs', 'apollo-landing', 'apollo-vertex']; + const resultsDir = process.env.RESULTS_DIR; + const logsLink = `[Logs](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`; + const timestamp = new Date().toLocaleString('en-US', { + timeZone: 'America/Los_Angeles', + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true + }); + + const readResult = (project) => { + const dir = path.join(resultsDir, `deploy-result-${project}`); + const read = (name) => { + try { + return fs.readFileSync(path.join(dir, name), 'utf8').trim(); + } catch { + return ''; + } + }; + return { url: read('url'), outcome: read('outcome') || 'skipped' }; + }; + + let anyFailed = false; + const rows = projects.map(project => { + const { url, outcome } = readResult(project); + const ready = outcome === 'success' && Boolean(url); + if (outcome === 'failure') anyFailed = true; + const status = ready ? 'Ready' : outcome === 'failure' ? 'Failed' : 'Skipped'; + const preview = ready ? `[Preview](${url}) ยท ${logsLink}` : logsLink; + return `| ${project} | ${status} | ${preview} | ${timestamp} |`; + }); + + const body = [ + identifier, + anyFailed + ? 'Apollo Coded App preview deployments finished with failures.' + : 'Apollo Coded App preview deployments are ready.', + '', + '| Project | Status | Preview | Updated (PT) |', + '|---------|--------|---------|--------------|', + ...rows + ].join('\n'); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + const existing = comments.find(comment => + comment.body?.includes(identifier) && + comment.user?.type === 'Bot' && + ['github-actions[bot]', 'github-actions'].includes(comment.user?.login) + ); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + cleanup-previews: + name: Cleanup Apollo Coded App Previews + if: github.event.action == 'closed' && github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 1 + persist-credentials: false + # The PR head, not the base: cleanup needs the recipes in the + # branch's .uip-go.json. Same-repo PRs only (job is fork-gated), + # and the deploy job already runs head code with these secrets. + ref: ${{ github.event.pull_request.head.sha }} + + - name: Remove Apollo Coded App previews + env: + GH_NPM_REGISTRY_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + UIPATH_BASE_URL: ${{ vars.UIPATH_BASE_URL || secrets.UIPATH_BASE_URL }} + UIPATH_CLIENT_ID: ${{ vars.UIPATH_CLIENT_ID || secrets.UIPATH_CLIENT_ID }} + UIPATH_CLIENT_SECRET: ${{ secrets.UIPATH_CLIENT_SECRET }} + UIPATH_FOLDER_KEY: ${{ vars.UIPATH_FOLDER_KEY || secrets.UIPATH_FOLDER_KEY }} + UIPATH_ORG_NAME: ${{ vars.UIPATH_ORG_NAME || secrets.UIPATH_ORG_NAME }} + UIPATH_TENANT_NAME: ${{ vars.UIPATH_TENANT_NAME || secrets.UIPATH_TENANT_NAME }} + UIPATH_CLIENT_SCOPE: ${{ vars.UIPATH_CLIENT_SCOPE || 'Apps Apps.Read Apps.Write OR.Folders.Read OR.Folders.Write OR.Execution' }} + run: | + set -euo pipefail + + missing=0 + for name in GH_NPM_REGISTRY_TOKEN UIPATH_BASE_URL UIPATH_CLIENT_ID UIPATH_CLIENT_SECRET UIPATH_FOLDER_KEY UIPATH_ORG_NAME UIPATH_TENANT_NAME UIP_GO_VERSION; do + if [ -z "${!name:-}" ]; then + echo "::error::$name is required for Apollo Coded App preview cleanup." + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + exit 1 + fi + + uip_go_npmrc="${RUNNER_TEMP}/uip-go.npmrc" + uip_go_prefix="${RUNNER_TEMP}/uip-go" + { + printf '@uipath:registry=https://npm.pkg.github.com\n' + printf '//npm.pkg.github.com/:_authToken=%s\n' "$GH_NPM_REGISTRY_TOKEN" + } > "$uip_go_npmrc" + NPM_CONFIG_USERCONFIG="$uip_go_npmrc" npm install --prefix "$uip_go_prefix" "@uipath/uip-go@${UIP_GO_VERSION}" + UIP_GO="${uip_go_prefix}/node_modules/.bin/uip-go" + + # No redirect-URI cleanup: previews reuse the first-party Uber.Client, + # whose Coded App host is covered by a static wildcard registration. + for label in apollo-landing apollo-docs apollo-design apollo-vertex; do + app_name="${label}-pr-${PR_NUMBER}" + if ! "$UIP_GO" "$label" \ + --delete-app \ + --name "$app_name" \ + --path-name "$app_name" \ + --folder-key "$UIPATH_FOLDER_KEY" \ + --base-url "$UIPATH_BASE_URL" \ + --org-name "$UIPATH_ORG_NAME" \ + --tenant-name "$UIPATH_TENANT_NAME"; then + echo "::warning::Could not delete Coded App ${app_name}. External App auth can publish/deploy/upgrade, but the Apps delete API may reject CI tokens." + fi + done diff --git a/.github/workflows/production-deploy.yml b/.github/workflows/production-deploy.yml new file mode 100644 index 000000000..a3d5d701d --- /dev/null +++ b/.github/workflows/production-deploy.yml @@ -0,0 +1,152 @@ +name: Coded App Production Deployments + +# PR previews deploy per-PR Coded Apps via preview-deploy.yml; this workflow +# keeps stable production Coded Apps of the merged pages up to date on main. +on: + push: + branches: [main] + +# Deny-all at the workflow level; each job grants only what it needs. +permissions: {} + +concurrency: + group: coded-app-production + cancel-in-progress: true + +env: + TURBO_TELEMETRY_DISABLED: 1 + DO_NOT_TRACK: 1 + UIP_GO_VERSION: 0.3.0 + +jobs: + deploy: + name: Deploy Apollo Coded Apps + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install Node dependencies + uses: ./.github/actions/install-node-deps + + - name: Restore Turborepo cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.ref_name }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ github.ref_name }}- + + - name: Deploy Coded Apps with uip-go + env: + GH_NPM_REGISTRY_TOKEN: ${{ github.token }} + UIPATH_BASE_URL: ${{ vars.UIPATH_BASE_URL || secrets.UIPATH_BASE_URL }} + UIPATH_CLIENT_ID: ${{ vars.UIPATH_CLIENT_ID || secrets.UIPATH_CLIENT_ID }} + UIPATH_CLIENT_SECRET: ${{ secrets.UIPATH_CLIENT_SECRET }} + UIPATH_FOLDER_KEY: ${{ vars.UIPATH_FOLDER_KEY || secrets.UIPATH_FOLDER_KEY }} + UIPATH_ORG_NAME: ${{ vars.UIPATH_ORG_NAME || secrets.UIPATH_ORG_NAME }} + UIPATH_TENANT_NAME: ${{ vars.UIPATH_TENANT_NAME || secrets.UIPATH_TENANT_NAME }} + UIPATH_CLIENT_SCOPE: ${{ vars.UIPATH_CLIENT_SCOPE || 'Apps Apps.Read Apps.Write OR.Folders.Read OR.Folders.Write OR.Execution PM.OAuthApp' }} + UIPATH_RUNTIME_CLIENT_ID: ${{ vars.UIPATH_RUNTIME_CLIENT_ID || secrets.UIPATH_RUNTIME_CLIENT_ID }} + run: | + set -euo pipefail + + missing=0 + for name in GH_NPM_REGISTRY_TOKEN UIPATH_BASE_URL UIPATH_CLIENT_ID UIPATH_CLIENT_SECRET UIPATH_FOLDER_KEY UIPATH_ORG_NAME UIPATH_TENANT_NAME UIP_GO_VERSION UIPATH_RUNTIME_CLIENT_ID; do + if [ -z "${!name:-}" ]; then + echo "::error::$name is required for Coded App production deployments." + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + exit 1 + fi + + short_sha="${GITHUB_SHA:0:7}" + version="0.1.0-main.${short_sha}.${GITHUB_RUN_ATTEMPT}" + uip_go_npmrc="${RUNNER_TEMP}/uip-go.npmrc" + uip_go_prefix="${RUNNER_TEMP}/uip-go" + { + printf '@uipath:registry=https://npm.pkg.github.com\n' + printf '//npm.pkg.github.com/:_authToken=%s\n' "$GH_NPM_REGISTRY_TOKEN" + } > "$uip_go_npmrc" + NPM_CONFIG_USERCONFIG="$uip_go_npmrc" npm install --prefix "$uip_go_prefix" "@uipath/uip-go@${UIP_GO_VERSION}" + UIP_GO="${uip_go_prefix}/node_modules/.bin/uip-go" + + run_uip_go() { + local label="$1" + local output_file + local command_exit + local app_url + local extra_args=() + + output_file="$(mktemp)" + trap 'rm -f "$output_file"; trap - RETURN' RETURN + if [ "$label" = "apollo-vertex" ]; then + extra_args+=(--auth-client-id "$UIPATH_RUNTIME_CLIENT_ID") + fi + echo "::group::uip-go ${label}" + set +e + "$UIP_GO" "$label" \ + --name "$label" \ + --path-name "$label" \ + --version "$version" \ + --folder-key "$UIPATH_FOLDER_KEY" \ + --base-url "$UIPATH_BASE_URL" \ + --org-name "$UIPATH_ORG_NAME" \ + --tenant-name "$UIPATH_TENANT_NAME" \ + --deploy-retries 3 \ + --deploy-retry-delay-ms 10000 \ + "${extra_args[@]}" 2>&1 | tee "$output_file" + command_exit=${PIPESTATUS[0]} + set -e + echo "::endgroup::" + + if [ "$command_exit" -ne 0 ]; then + echo "::error::uip-go ${label} failed." + return "$command_exit" + fi + + app_url="$(sed -nE 's/^[[:space:]]*App URL:[[:space:]]*//p' "$output_file" | tail -n 1)" + if [ -n "$app_url" ]; then + echo "โœ… Deployed ${label}: ${app_url}" >> "$GITHUB_STEP_SUMMARY" + else + echo "โœ… Deployed ${label} (${version})" >> "$GITHUB_STEP_SUMMARY" + fi + } + + failed_apps=() + deploy_or_record_failure() { + local label="$1" + + if run_uip_go "$label"; then + return 0 + fi + + failed_apps+=("$label") + return 0 + } + + deploy_or_record_failure "apollo-landing" + deploy_or_record_failure "apollo-docs" + deploy_or_record_failure "apollo-design" + deploy_or_record_failure "apollo-vertex" + + if [ "${#failed_apps[@]}" -ne 0 ]; then + echo "::error::Coded App production deployment failed for: ${failed_apps[*]}" + exit 1 + fi + + - name: Save Turborepo cache + if: always() + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.ref_name }}-${{ github.sha }} diff --git a/.github/workflows/vercel-deploy.yml b/.github/workflows/vercel-deploy.yml deleted file mode 100644 index 88b2095fc..000000000 --- a/.github/workflows/vercel-deploy.yml +++ /dev/null @@ -1,430 +0,0 @@ -name: Vercel Deployments - -on: - pull_request: - push: - branches: [main] - -# Deny-all default; jobs grant the minimum they need. -permissions: {} - -concurrency: - group: vercel-${{ github.ref }} - cancel-in-progress: true - -jobs: - pre-deploy: - name: Initialize Deployment Status - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false - permissions: - pull-requests: write - - steps: - - name: Post initial deployment status - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const identifier = ''; - const timestamp = new Date().toLocaleString('en-US', { - timeZone: 'America/Los_Angeles', - year: 'numeric', - month: 'short', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: true - }); - - const projects = [ - 'apollo-design', - 'apollo-docs', - 'apollo-landing', - 'apollo-vertex' - ]; - - const tableRows = projects.map(projectName => { - const logsLink = `[Logs](https://github.com/${ context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`; - return `| ${projectName} | ๐ŸŸก Deploying... | ${logsLink} | ${timestamp} |`; - }).join('\n'); - - const comment = [ - identifier, - '', - 'The latest updates on your projects. Learn more about [Vercel for GitHub](https://vercel.com/docs/deployments/git).', - '', - '| Project | Deployment | Review | Updated (PT) |', - '|---------|------------|--------|---------------|', - tableRows - ].join('\n'); - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const existingComment = comments.find(c => c.body?.includes(identifier)); - - if (existingComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existingComment.id, - body: comment - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: comment - }); - } - - deploy: - name: Deploy ${{ matrix.project_name }} - runs-on: ubuntu-latest - needs: pre-deploy - if: ${{ !cancelled() && (github.event_name == 'push' || (needs.pre-deploy.result == 'success' && github.event.pull_request.head.repo.fork == false)) }} - permissions: - contents: read - strategy: - # Let every project deploy attempt complete so the PR comment surfaces all outcomes; - # a failure in one entry should not cancel the others, but should still fail the workflow. - fail-fast: false - matrix: - include: - - project_name: apollo-design - vercel_project_id_secret: VERCEL_PROJECT_ID_CANVAS - build_filter: storybook-app - build_task: storybook:build - - project_name: apollo-docs - vercel_project_id_secret: VERCEL_PROJECT_ID_DOCS - build_filter: apollo-docs - build_task: build - - project_name: apollo-landing - vercel_project_id_secret: VERCEL_PROJECT_ID_LANDING - build_filter: apollo-landing - build_task: build - - project_name: apollo-vertex - vercel_project_id_secret: VERCEL_PROJECT_ID_VERTEX - build_filter: apollo-vertex - build_task: build - - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Install Node dependencies - uses: ./.github/actions/install-node-deps - - - name: Restore Turborepo cache - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.ref_name }}-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-turbo-${{ github.ref_name }}- - - - name: Cache Vercel CLI - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: ~/.npm - key: ${{ runner.os }}-vercel-cli-53.4.0-${{ github.ref_name }}-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-vercel-cli-53.4.0-${{ github.ref_name }}- - - - name: Install Vercel CLI - run: npm install -g vercel@53.4.0 - - - name: Set deployment variables - id: vars - run: | - if [ "${{ github.event_name }}" == "pull_request" ]; then - echo "prod_flag=" >> "$GITHUB_OUTPUT" - else - echo "prod_flag=--prod" >> "$GITHUB_OUTPUT" - fi - - - name: Set Vercel Project ID - id: set-project-id - run: | - case "${{ matrix.vercel_project_id_secret }}" in - VERCEL_PROJECT_ID_CANVAS) - echo "VERCEL_PROJECT_ID=${{ secrets.VERCEL_PROJECT_ID_CANVAS }}" >> "$GITHUB_ENV" - ;; - VERCEL_PROJECT_ID_DOCS) - echo "VERCEL_PROJECT_ID=${{ secrets.VERCEL_PROJECT_ID_DOCS }}" >> "$GITHUB_ENV" - ;; - VERCEL_PROJECT_ID_LANDING) - echo "VERCEL_PROJECT_ID=${{ secrets.VERCEL_PROJECT_ID_LANDING }}" >> "$GITHUB_ENV" - ;; - VERCEL_PROJECT_ID_UI_REACT) - echo "VERCEL_PROJECT_ID=${{ secrets.VERCEL_PROJECT_ID_UI_REACT }}" >> "$GITHUB_ENV" - ;; - VERCEL_PROJECT_ID_VERTEX) - echo "VERCEL_PROJECT_ID=${{ secrets.VERCEL_PROJECT_ID_VERTEX }}" >> "$GITHUB_ENV" - ;; - *) - echo "Error: Unknown vercel_project_id_secret value '${{ matrix.vercel_project_id_secret }}'. Please update the case statement in .github/workflows/vercel-deploy.yml." >&2 - exit 1 - ;; - esac - - - name: Build project - id: build - run: pnpm turbo ${{ matrix.build_task }} --filter=${{ matrix.build_filter }} - - - name: Save Turborepo cache - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.ref_name }}-${{ github.sha }} - - - name: Build Vercel output - id: vercel-build - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - PROD_FLAG: ${{ steps.vars.outputs.prod_flag }} - run: | - # Run from repo root โ€” Vercel applies rootDirectory from project settings - # to locate the app subdirectory. Using --cwd here would double the path - # (e.g. apps/apollo-vertex/apps/apollo-vertex) because the dashboard - # rootDirectory is applied on top of --cwd. - VERCEL_ARGS=(build --yes) - [[ -n "$PROD_FLAG" ]] && VERCEL_ARGS+=("$PROD_FLAG") - vercel "${VERCEL_ARGS[@]}" - - - name: Deploy to Vercel - id: deploy - run: | - ERROR_MSG="" - DEPLOY_URL="" - set +e - # Run from repo root โ€” same reasoning as vercel build above. - # Vercel applies rootDirectory from project settings to locate - # the .vercel/output/ directory created by the build step. - # --archive=tgz: package the prebuilt output as a single tarball before - # upload. Without it, each file in .vercel/output/ counts as a separate - # upload against the 40k/day api-upload-paid quota โ€” Storybook + shadcn - # registries blow through that quickly across 5 projects ร— N PRs/day. - VERCEL_ARGS=(deploy --prebuilt --archive=tgz --yes) - [[ -n "$PROD_FLAG" ]] && VERCEL_ARGS+=("$PROD_FLAG") - DEPLOY_OUTPUT=$(vercel "${VERCEL_ARGS[@]}" 2>&1) - DEPLOY_EXIT_CODE=$? - set -e - - for __secret in "$VERCEL_TOKEN" "$VERCEL_ORG_ID"; do - if [ -n "$__secret" ]; then - DEPLOY_OUTPUT="${DEPLOY_OUTPUT//$__secret/***}" - fi - done - - # Always echo the full vercel CLI output so the runner log is traceable - # without having to download the deploy-result artifact. Collapsible so - # successful runs stay tidy. - echo "::group::vercel CLI output (exit $DEPLOY_EXIT_CODE)" - printf '%s\n' "$DEPLOY_OUTPUT" - echo "::endgroup::" - - if [ "$DEPLOY_EXIT_CODE" -eq 0 ]; then - if [ "$PROD_FLAG" == "--prod" ]; then - DEPLOY_URL="https://${{ matrix.project_name }}.vercel.app" - else - DEPLOY_URL=$(echo "$DEPLOY_OUTPUT" | grep -oP 'https://[^\s]+\.vercel\.app[^\s]*' | head -n 1) - if [ -z "$DEPLOY_URL" ]; then - DEPLOY_URL=$(echo "$DEPLOY_OUTPUT" | tail -n 1) - echo "โš ๏ธ Warning: URL extraction fallback used for ${{ matrix.project_name }}" - fi - fi - { - echo "url=$DEPLOY_URL" - echo "error_message=" - } >> "$GITHUB_OUTPUT" - echo "โœ… Deployed ${{ matrix.project_name }} to $DEPLOY_URL" >> "$GITHUB_STEP_SUMMARY" - else - # Prefer the actual `Error: โ€ฆ` line over a blind `tail -n 5`, which - # often grabs upload progress bars or Node stack-trace frames and - # hides the root cause. - ERROR_MSG=$(echo "$DEPLOY_OUTPUT" | grep -m1 -E '^Error: ' || true) - if [ -z "$ERROR_MSG" ]; then - ERROR_MSG=$(echo "$DEPLOY_OUTPUT" | tail -n 5 | tr '\n' ' ') - fi - if [ "${#ERROR_MSG}" -gt 500 ]; then - ERROR_MSG="${ERROR_MSG:0:500}..." - fi - echo "error_message=$ERROR_MSG" >> "$GITHUB_OUTPUT" - echo "โŒ Failed to deploy ${{ matrix.project_name }}: $ERROR_MSG" >> "$GITHUB_STEP_SUMMARY" - # Escape %, CR, LF before interpolating into a workflow command so a - # malformed/hostile error string can't break the annotation or inject - # a second `::workflow-command::` via an embedded newline. Order - # matters: % must be escaped first, since later subs emit %. - ERROR_ANNOTATION="${ERROR_MSG//%/%25}" - ERROR_ANNOTATION="${ERROR_ANNOTATION//$'\r'/%0D}" - ERROR_ANNOTATION="${ERROR_ANNOTATION//$'\n'/%0A}" - echo "::error title=Vercel deploy failed for ${{ matrix.project_name }}::$ERROR_ANNOTATION" - exit 1 - fi - env: - PROD_FLAG: ${{ steps.vars.outputs.prod_flag }} - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - CI: true - NODE_ENV: production - - - name: Save deploy result - if: always() && github.event_name == 'pull_request' - env: - DEPLOY_URL: ${{ steps.deploy.outputs.url }} - DEPLOY_OUTCOME: ${{ steps.deploy.outcome }} - DEPLOY_ERROR: ${{ steps.deploy.outputs.error_message }} - BUILD_OUTCOME: ${{ steps.build.outcome }} - VERCEL_BUILD_OUTCOME: ${{ steps.vercel-build.outcome }} - PROJECT_NAME: ${{ matrix.project_name }} - run: | - mkdir -p "$RUNNER_TEMP/deploy-result" - - # Attribute the failure to the earliest step that broke so the PR - # comment can show a meaningful status instead of a misleading - # "Skipped" when build/prebuild dies before deploy ever runs. - if [ "$DEPLOY_OUTCOME" = "success" ]; then - OUTCOME="success" - ERROR="" - elif [ "$BUILD_OUTCOME" = "failure" ]; then - OUTCOME="failure" - ERROR="Build failed (pnpm turbo) for $PROJECT_NAME" - elif [ "$VERCEL_BUILD_OUTCOME" = "failure" ]; then - OUTCOME="failure" - ERROR="Vercel build failed for $PROJECT_NAME" - elif [ "$DEPLOY_OUTCOME" = "failure" ]; then - OUTCOME="failure" - ERROR="${DEPLOY_ERROR:-Deploy step failed}" - else - OUTCOME="skipped" - ERROR="" - fi - - printf '%s' "${DEPLOY_URL:-}" > "$RUNNER_TEMP/deploy-result/url" - printf '%s' "$OUTCOME" > "$RUNNER_TEMP/deploy-result/outcome" - printf '%s' "$ERROR" > "$RUNNER_TEMP/deploy-result/error" - - - name: Upload deploy result - if: always() && github.event_name == 'pull_request' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: deploy-result-${{ matrix.project_name }} - path: ${{ runner.temp }}/deploy-result/ - retention-days: 1 - - update-pr-comment: - name: Update PR Comment - needs: deploy - if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false - runs-on: ubuntu-latest - permissions: - pull-requests: write - - steps: - - name: Download deploy results - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - pattern: deploy-result-* - path: ${{ runner.temp }}/results - - - name: Update PR comment - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - RESULTS_DIR: ${{ runner.temp }}/results - with: - script: | - const fs = require('fs'); - const path = require('path'); - - const projects = [ - 'apollo-design', - 'apollo-docs', - 'apollo-landing', - 'apollo-vertex' - ]; - - const resultsDir = process.env.RESULTS_DIR; - const logsLink = `[Logs](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`; - const timestamp = new Date().toLocaleString('en-US', { - timeZone: 'America/Los_Angeles', - year: 'numeric', - month: 'short', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: true - }); - - const truncateError = (msg, maxLength = 100) => { - if (!msg || msg.length <= maxLength) return msg; - const truncated = msg.substring(0, maxLength); - const lastSpace = truncated.lastIndexOf(' '); - return lastSpace > 0 ? truncated.substring(0, lastSpace) + '...' : truncated + '...'; - }; - - const tableRows = projects.map(project => { - const dir = path.join(resultsDir, `deploy-result-${project}`); - let url = '', outcome = 'skipped', error = ''; - if (fs.existsSync(dir)) { - url = fs.readFileSync(path.join(dir, 'url'), 'utf8').trim(); - outcome = fs.readFileSync(path.join(dir, 'outcome'), 'utf8').trim(); - error = fs.readFileSync(path.join(dir, 'error'), 'utf8').trim(); - } - - let status; - if (outcome === 'success') { - status = '๐ŸŸข Ready'; - } else if (outcome === 'failure') { - status = error ? `โŒ Failed: ${truncateError(error)}` : 'โŒ Failed'; - } else { - status = 'โš ๏ธ Skipped'; - } - - const projectLink = url ? `[${project}](${url})` : project; - const previewLink = url ? `[Preview](${url})` : 'N/A'; - return `| ${projectLink} | ${status} | ${previewLink}, ${logsLink} | ${timestamp} |`; - }).join('\n'); - - const identifier = ''; - const body = [ - identifier, - ``, - 'The latest updates on your projects. Learn more about [Vercel for GitHub](https://vercel.com/docs/deployments/git).', - '', - '| Project | Deployment | Review | Updated (PT) |', - '|---------|------------|--------|---------------|', - tableRows - ].join('\n'); - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const existing = comments.find(c => c.body?.includes(identifier)); - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body - }); - } diff --git a/.gitignore b/.gitignore index dfd56f70d..dc6da60e7 100644 --- a/.gitignore +++ b/.gitignore @@ -84,5 +84,10 @@ lerna-debug.log* .cache/ .turbo/ +# UiPath Coded Apps local packaging +.uipath/ +.uipath-build/ +uipath.json + # Vercel .vercel/ diff --git a/.uip-go.json b/.uip-go.json new file mode 100644 index 000000000..ea4407218 --- /dev/null +++ b/.uip-go.json @@ -0,0 +1,80 @@ +{ + "deployments": { + "apollo-docs": { + "type": "codedapp", + "build": { + "type": "staged-next-export", + "sourceDir": "apps/apollo-docs", + "stageDir": ".uipath-build/apollo-docs", + "preCommands": ["pnpm turbo build --filter=apollo-docs^..."], + "commands": ["pnpm exec next build"], + "copyOutput": "out", + "pagefind": true + }, + "outputDir": "apps/apollo-docs/out", + "pathNameEnv": "APOLLO_CODED_APP_PATH", + "folderKeyEnv": "UIPATH_FOLDER_KEY", + "postprocess": ["static-next-export"], + "tags": "apollo,apollo-docs", + "env": { + "CI": "true", + "APOLLO_CODED_APP": "1" + } + }, + "apollo-landing": { + "type": "codedapp", + "build": "pnpm turbo build --filter=apollo-landing", + "outputDir": "apps/landing/dist", + "pathNameEnv": "APOLLO_CODED_APP_PATH", + "folderKeyEnv": "UIPATH_FOLDER_KEY", + "postprocess": ["relative-root-assets"], + "tags": "apollo,apollo-landing", + "env": { + "CI": "true", + "APOLLO_CODED_APP": "1" + } + }, + "apollo-design": { + "type": "codedapp", + "build": "pnpm turbo run storybook:build --filter=storybook-app", + "outputDir": "apps/storybook/storybook-static", + "pathNameEnv": "APOLLO_CODED_APP_PATH", + "folderKeyEnv": "UIPATH_FOLDER_KEY", + "postprocess": ["relative-root-assets"], + "tags": "apollo,apollo-design", + "env": { + "CI": "true", + "APOLLO_CODED_APP": "1" + } + }, + "apollo-vertex": { + "type": "codedapp", + "build": { + "type": "staged-next-export", + "sourceDir": "apps/apollo-vertex", + "stageDir": ".uipath-build/apollo-vertex", + "exclude": ["app/api"], + "commands": [ + "pnpm generate:theme", + "pnpm registry:build", + "pnpm exec next build" + ], + "copyOutput": "out", + "pagefind": true + }, + "outputDir": "apps/apollo-vertex/out", + "pathNameEnv": "APOLLO_CODED_APP_PATH", + "folderKeyEnv": "UIPATH_FOLDER_KEY", + "postprocess": ["static-next-export"], + "authClientId": "1119a927-10ab-4543-bd1a-ad6bfbbc27f4", + "platformAuth": { + "redirectPath": "auth_callback/index.html" + }, + "tags": "apollo,apollo-vertex", + "env": { + "CI": "true", + "APOLLO_CODED_APP": "1" + } + } + } +} diff --git a/README.md b/README.md index 35e162585..2431a1501 100644 --- a/README.md +++ b/README.md @@ -138,10 +138,12 @@ Framework-agnostic AI chat interface web component for vanilla JS, Vue, Angular, ## ๐ŸŽจ Live Demos -Explore our components in interactive Storybook environments: +Explore our components in interactive Storybook environments. The sites are hosted as UiPath Coded Apps on the staging environment: -- **[Apollo Vertex](https://apollo-vertex.vercel.app/)** - Complete design system showcase -- **[Canvas Components](https://apollo-design.vercel.app/)** - Tailwind CSS components & Workflow and canvas demos +- **[Apollo Vertex](https://engdogfood.staging.uipath.host/apollo-vertex/)** - Complete design system showcase +- **[Apollo Docs](https://engdogfood.staging.uipath.host/apollo-docs/)** - Central documentation site +- **[Canvas Components](https://engdogfood.staging.uipath.host/apollo-design/)** - Tailwind CSS components & Workflow and canvas demos +- **[Landing](https://engdogfood.staging.uipath.host/apollo-landing/)** - Apollo landing page - **[React Playground](https://apollo-ui-react.vercel.app/)** - Material UI components, design tokens, CSS vars, icons, and chat component ## ๐Ÿ“š Documentation diff --git a/apps/apollo-docs/app/page.tsx b/apps/apollo-docs/app/page.tsx index 58ce8d3d0..65f4f7259 100644 --- a/apps/apollo-docs/app/page.tsx +++ b/apps/apollo-docs/app/page.tsx @@ -1,5 +1,13 @@ import { redirect } from 'next/navigation'; +import OverviewPage from './introduction/overview/page.mdx'; + +// Static Coded App exports cannot serve a redirect, so the root page renders +// the overview inline instead of redirecting to it. +const codedApp = process.env.APOLLO_CODED_APP === '1'; export default function Page() { - redirect('/introduction/overview'); + if (!codedApp) { + redirect('/introduction/overview'); + } + return ; } diff --git a/apps/apollo-docs/mdx.d.ts b/apps/apollo-docs/mdx.d.ts new file mode 100644 index 000000000..d35a16888 --- /dev/null +++ b/apps/apollo-docs/mdx.d.ts @@ -0,0 +1,6 @@ +declare module '*.mdx' { + import type { ComponentType } from 'react'; + + const MDXComponent: ComponentType; + export default MDXComponent; +} diff --git a/apps/apollo-docs/next.config.mjs b/apps/apollo-docs/next.config.mjs index 8c4f3b887..0b25513bf 100644 --- a/apps/apollo-docs/next.config.mjs +++ b/apps/apollo-docs/next.config.mjs @@ -4,7 +4,17 @@ const withNextra = nextra({ defaultShowCopyCode: true, }); +// Coded App preview builds (uip-go) need a static export served from a +// sub-path of the Coded App host. Regular dev/prod builds are unaffected. +const codedApp = process.env.APOLLO_CODED_APP === '1'; +const codedAppPath = process.env.APOLLO_CODED_APP_PATH?.replace(/^\/+|\/+$/g, ''); + export default withNextra({ + ...(codedApp && { + output: 'export', + trailingSlash: true, + ...(codedAppPath && { basePath: `/${codedAppPath}` }), + }), reactCompiler: true, turbopack: { resolveAlias: { diff --git a/apps/apollo-docs/package.json b/apps/apollo-docs/package.json index b21473e44..a11c18109 100644 --- a/apps/apollo-docs/package.json +++ b/apps/apollo-docs/package.json @@ -31,6 +31,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.6", "@types/react-dom": "^19.2.2", + "pagefind": "^1.4.0", "typescript": "^5.9.3" } } diff --git a/apps/apollo-vertex/app/auth_callback/page.tsx b/apps/apollo-vertex/app/auth_callback/page.tsx index 0b8607665..0db2f0c21 100644 --- a/apps/apollo-vertex/app/auth_callback/page.tsx +++ b/apps/apollo-vertex/app/auth_callback/page.tsx @@ -1,22 +1,24 @@ "use client"; -import { useRouter } from "next/navigation"; import { useEffect } from "react"; import { STORAGE_KEYS } from "@/lib/auth"; const FALLBACK_PATH = "/"; export default function AuthCallback() { - const router = useRouter(); - useEffect(() => { const returnTo = sessionStorage.getItem(STORAGE_KEYS.AUTH_RETURN_TO) ?? FALLBACK_PATH; sessionStorage.removeItem(STORAGE_KEYS.AUTH_RETURN_TO); + // returnTo is an absolute path already including the Coded App base path + // (captured from window.location.pathname at sign-in), so navigate with + // window.location rather than Next's router, which would prepend basePath + // again and double it. Carries the ?code/?state through to the page that + // completes the token exchange. const search = window.location.search; - router.replace(`${returnTo}${search}`); - }, [router]); + window.location.replace(`${returnTo}${search}`); + }, []); return null; } diff --git a/apps/apollo-vertex/app/components/page.tsx b/apps/apollo-vertex/app/components/page.tsx index 10f6c1118..de70eb0dd 100644 --- a/apps/apollo-vertex/app/components/page.tsx +++ b/apps/apollo-vertex/app/components/page.tsx @@ -1,5 +1,13 @@ import { redirect } from "next/navigation"; +import ComponentsOverviewPage from "./overview/page.mdx"; + +// Static Coded App exports cannot serve a redirect, so this index renders the +// overview inline instead of redirecting to it. +const codedApp = process.env.APOLLO_CODED_APP === "1"; export default function ComponentsIndex() { - redirect("/components/overview"); + if (!codedApp) { + redirect("/components/overview"); + } + return ; } diff --git a/apps/apollo-vertex/app/layout.tsx b/apps/apollo-vertex/app/layout.tsx index 6deeae785..202dfec96 100644 --- a/apps/apollo-vertex/app/layout.tsx +++ b/apps/apollo-vertex/app/layout.tsx @@ -49,12 +49,16 @@ export const metadata = { }, }; +// Coded App preview builds serve the app from a sub-path; Next.js basePath +// does not rewrite plain image sources, so the logo path is prefixed here. +const codedAppPath = process.env.NEXT_PUBLIC_APOLLO_CODED_APP_PATH; + const navbar = ( Apollo Vertex !process.env[name]); + if (missing.length > 0) { + throw new Error( + `Coded App build is missing platform-auth context: ${missing.join(", ")}. ` + + "uip-go injects these from the platformAuth section of .uip-go.json; check the deployment recipe.", + ); + } +} + export default withNextra({ + ...(codedApp + ? { + output: "export", + trailingSlash: true, + ...(codedAppPath && { basePath: `/${codedAppPath}` }), + env: { + NEXT_PUBLIC_APOLLO_CODED_APP: "1", + NEXT_PUBLIC_APOLLO_CODED_APP_PATH: codedAppPath ?? "", + // Client id and scope are fixed constants in the app (first-party + // Uber.Client), so they are not injected here. uip-go still resolves + // the per-deployment platform context below. + NEXT_PUBLIC_APOLLO_VERTEX_PLATFORM_AUTH_BASE_URL: + process.env.UIP_GO_PLATFORM_AUTH_BASE_URL ?? "", + NEXT_PUBLIC_APOLLO_VERTEX_PLATFORM_AUTH_ORG_NAME: + process.env.UIP_GO_PLATFORM_AUTH_ORG_NAME ?? "", + NEXT_PUBLIC_APOLLO_VERTEX_PLATFORM_AUTH_TENANT_NAME: + process.env.UIP_GO_PLATFORM_AUTH_TENANT_NAME ?? "", + NEXT_PUBLIC_APOLLO_VERTEX_PLATFORM_AUTH_TENANT_ID: + process.env.UIP_GO_PLATFORM_AUTH_TENANT_ID ?? "", + NEXT_PUBLIC_APOLLO_VERTEX_PLATFORM_AUTH_REDIRECT_PATH: + process.env.UIP_GO_PLATFORM_AUTH_REDIRECT_PATH ?? "", + }, + } + : { + rewrites() { + return [ + { + source: "/identity_/:path*", + destination: "https://alpha.uipath.com/identity_/:path*", + }, + { + source: "/_proxy/portal/:orgId/:path*", + destination: "https://alpha.uipath.com/:orgId/portal_/api/:path*", + }, + ]; + }, + headers() { + return [ + { + source: "/:path*", + headers: [ + { + key: "X-DNS-Prefetch-Control", + value: "on", + }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "X-Frame-Options", + value: "SAMEORIGIN", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-XSS-Protection", + value: "1; mode=block", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + ], + }, + ]; + }, + }), reactCompiler: true, turbopack: { resolveAlias: { "next-mdx-import-source-file": "./mdx-components.tsx", }, }, - rewrites() { - return [ - { - source: "/identity_/:path*", - destination: "https://alpha.uipath.com/identity_/:path*", - }, - { - source: "/_proxy/portal/:orgId/:path*", - destination: "https://alpha.uipath.com/:orgId/portal_/api/:path*", - }, - ]; - }, - headers() { - return [ - { - source: "/:path*", - headers: [ - { - key: "X-DNS-Prefetch-Control", - value: "on", - }, - { - key: "Strict-Transport-Security", - value: "max-age=63072000; includeSubDomains; preload", - }, - { - key: "X-Frame-Options", - value: "SAMEORIGIN", - }, - { - key: "X-Content-Type-Options", - value: "nosniff", - }, - { - key: "X-XSS-Protection", - value: "1; mode=block", - }, - { - key: "Referrer-Policy", - value: "strict-origin-when-cross-origin", - }, - { - key: "Permissions-Policy", - value: "camera=(), microphone=(), geolocation=()", - }, - ], - }, - ]; - }, }); diff --git a/apps/apollo-vertex/templates/AiChatTemplate.tsx b/apps/apollo-vertex/templates/AiChatTemplate.tsx index ef8fcc385..58a6f363b 100644 --- a/apps/apollo-vertex/templates/AiChatTemplate.tsx +++ b/apps/apollo-vertex/templates/AiChatTemplate.tsx @@ -11,6 +11,8 @@ import { ConversationalAgentChat } from "./ai-chat/AiChatConversationalAgentMode import { AiChatLoginGate, type OrgTenantInfo } from "./ai-chat/AiChatLoginGate"; import { AICHAT_CLIENT_ID, + AICHAT_DIRECT_BASE_URL, + AICHAT_REDIRECT_PATH, AICHAT_SCOPE, AICHAT_STORAGE_KEYS, type ChatMode, @@ -85,8 +87,8 @@ export function AiChatTemplate() { diff --git a/apps/apollo-vertex/templates/ai-chat/AiChatAgentHubMode.tsx b/apps/apollo-vertex/templates/ai-chat/AiChatAgentHubMode.tsx index e0adfd38d..1f2868f9b 100644 --- a/apps/apollo-vertex/templates/ai-chat/AiChatAgentHubMode.tsx +++ b/apps/apollo-vertex/templates/ai-chat/AiChatAgentHubMode.tsx @@ -42,7 +42,10 @@ import { import type { MessageFeedbackType } from "@/registry/ai-chat/types"; import { DataFabricGate } from "./AiChatDataFabricGate"; import type { OrgTenantInfo } from "./AiChatLoginGate"; -import { createUiPathSdk } from "./ai-chat-example-utils"; +import { + AICHAT_DIRECT_BASE_URL, + createUiPathSdk, +} from "./ai-chat-example-utils"; interface AgentHubChatProps { accessToken: string; @@ -61,7 +64,11 @@ function AgentHubChatInner({ entities, }: AgentHubChatInnerProps) { const { t } = useTranslation(); - const dataFabricBaseUrl = `/api/datafabric/${orgTenant.orgName}/${orgTenant.tenantName}/datafabric_/api`; + // Coded App builds have no server for the /api proxy routes, so the browser + // calls the platform host directly. + const dataFabricBaseUrl = AICHAT_DIRECT_BASE_URL + ? `${AICHAT_DIRECT_BASE_URL}/${orgTenant.orgName}/${orgTenant.tenantName}/datafabric_/api` + : `/api/datafabric/${orgTenant.orgName}/${orgTenant.tenantName}/datafabric_/api`; const tableTool = createDataFabricTableTool({ entities, @@ -138,7 +145,9 @@ function AgentHubChatInner({ ].join("\n\n"); const connection = createAgentHubConnection({ - baseUrl: `/api/agenthub/${orgTenant.orgName}/${orgTenant.tenantName}/agenthub_/llm/api`, + baseUrl: AICHAT_DIRECT_BASE_URL + ? `${AICHAT_DIRECT_BASE_URL}/${orgTenant.orgName}/${orgTenant.tenantName}/agenthub_/llm/api` + : `/api/agenthub/${orgTenant.orgName}/${orgTenant.tenantName}/agenthub_/llm/api`, model: { vendor: "openai", name: "gpt-4.1-mini-2025-04-14" }, accessToken: () => accessToken, systemPrompt, diff --git a/apps/apollo-vertex/templates/ai-chat/AiChatLoginGate.tsx b/apps/apollo-vertex/templates/ai-chat/AiChatLoginGate.tsx index 60b6b0914..fcb4e1b50 100644 --- a/apps/apollo-vertex/templates/ai-chat/AiChatLoginGate.tsx +++ b/apps/apollo-vertex/templates/ai-chat/AiChatLoginGate.tsx @@ -1,9 +1,9 @@ "use client"; +import { useLocalStorage } from "@mantine/hooks"; import { useQuery } from "@tanstack/react-query"; import { jwtDecode } from "jwt-decode"; import { ChevronRight, LogIn, LogOut } from "lucide-react"; -import { useLocalStorage } from "@mantine/hooks"; import type { ReactNode } from "react"; import { z } from "zod"; import { Button } from "@/components/ui/button"; @@ -17,7 +17,12 @@ import { SelectValue, } from "@/registry/select/select"; import { useAuth } from "@/registry/shell/shell-auth-provider"; -import { AICHAT_STORAGE_KEYS } from "./ai-chat-example-utils"; +import { + AICHAT_DIRECT_BASE_URL, + AICHAT_IS_CODED_APP, + AICHAT_STATIC_ORG, + AICHAT_STORAGE_KEYS, +} from "./ai-chat-example-utils"; export interface OrgTenantInfo { orgId: string; @@ -86,6 +91,15 @@ function useOrgInfo(accessToken: string | null) { queryFn: () => { if (!orgId || !accessToken) throw new Error("Missing orgId or accessToken"); + // Coded App builds cannot reach the portal proxy that lists tenants, so + // the deployment's org/tenant is baked in at build time instead. + if (AICHAT_STATIC_ORG) { + return Promise.resolve({ + orgId, + orgName: AICHAT_STATIC_ORG.orgName, + tenants: AICHAT_STATIC_ORG.tenants, + }); + } return fetchOrgInfo(orgId, accessToken); }, enabled: !!accessToken && !!orgId, @@ -179,16 +193,21 @@ export function AiChatLoginGate({ children }: AiChatLoginGateProps) { ); logout(); + // Coded App builds reach identity on the baked platform host; dev reaches + // it through the same-origin proxy. + const identityOrigin = AICHAT_IS_CODED_APP + ? AICHAT_DIRECT_BASE_URL + : window.location.origin; const endSessionUrl = new URL( "/identity_/connect/endsession", - window.location.origin, + identityOrigin, ); if (idToken) { endSessionUrl.searchParams.set("id_token_hint", idToken); } endSessionUrl.searchParams.set( "post_logout_redirect_uri", - `${window.location.origin}/portal_/cloudrpa`, + `${identityOrigin}/portal_/cloudrpa`, ); window.location.href = endSessionUrl.toString(); } diff --git a/apps/apollo-vertex/templates/ai-chat/ai-chat-example-utils.ts b/apps/apollo-vertex/templates/ai-chat/ai-chat-example-utils.ts index 6e28ba0b4..eeb5e628a 100644 --- a/apps/apollo-vertex/templates/ai-chat/ai-chat-example-utils.ts +++ b/apps/apollo-vertex/templates/ai-chat/ai-chat-example-utils.ts @@ -1,4 +1,5 @@ import { UiPath } from "@uipath/uipath-typescript/core"; +import { z } from "zod"; import type { OrgTenantInfo } from "./AiChatLoginGate"; export type ChatMode = "agenthub" | "conversational-agent"; @@ -9,10 +10,90 @@ export const AICHAT_STORAGE_KEYS = { AGENT: "apollo-vertex:ai-chat:agent", } as const; +// UiPath's first-party Portal client (Uber.Client). Its browser tokens skip +// the external-app audience checks, so the demo reaches Data Fabric and +// AgentHub with just the base OIDC scopes. Coded App preview hosts are +// pre-registered as redirect URIs on this client via a host wildcard in +// first-party-service-metadata, so there is no per-deployment client or +// redirect-URI registration. Same client id in dev and in Coded App builds. export const AICHAT_CLIENT_ID = "1119a927-10ab-4543-bd1a-ad6bfbbc27f4"; export const AICHAT_SCOPE = "openid profile offline_access"; -const UIPATH_BASE_URL = "https://alpha.uipath.com"; +export const AICHAT_IS_CODED_APP = + process.env.NEXT_PUBLIC_APOLLO_CODED_APP === "1"; + +// Two modes, chosen at build time: +// - Coded App export: there is no server, so the browser calls the platform +// host directly. uip-go bakes the platform context (base URL, org, tenant, +// redirect path) into the bundle, and it is required โ€” a missing value +// fails the build here rather than shipping a chat that cannot sign in. +// - Dev / regular build: the app reaches the platform through the +// same-origin Next.js proxy routes, so there is no baked platform context +// and the org/tenant are discovered at runtime. +function loadCodedAppPlatformAuth() { + const parsed = z + .object({ + baseUrl: z.string().min(1), + orgName: z.string().min(1), + tenantName: z.string().min(1), + tenantId: z.string().min(1), + redirectPath: z.string().min(1), + }) + .safeParse({ + baseUrl: process.env.NEXT_PUBLIC_APOLLO_VERTEX_PLATFORM_AUTH_BASE_URL, + orgName: process.env.NEXT_PUBLIC_APOLLO_VERTEX_PLATFORM_AUTH_ORG_NAME, + tenantName: + process.env.NEXT_PUBLIC_APOLLO_VERTEX_PLATFORM_AUTH_TENANT_NAME, + tenantId: process.env.NEXT_PUBLIC_APOLLO_VERTEX_PLATFORM_AUTH_TENANT_ID, + redirectPath: + process.env.NEXT_PUBLIC_APOLLO_VERTEX_PLATFORM_AUTH_REDIRECT_PATH, + }); + if (!parsed.success) { + const missing = parsed.error.issues + .map((issue) => issue.path.join(".")) + .join(", "); + throw new Error( + `Coded App build is missing platform-auth context (${missing}). uip-go injects these from the platformAuth section of .uip-go.json; check the deployment recipe.`, + ); + } + return parsed.data; +} + +const codedAppPlatformAuth = AICHAT_IS_CODED_APP + ? loadCodedAppPlatformAuth() + : null; + +// Empty in dev (use the same-origin proxy); the platform host in a Coded App +// build (call it directly). +export const AICHAT_DIRECT_BASE_URL = codedAppPlatformAuth + ? codedAppPlatformAuth.baseUrl.replace(/\/+$/, "") + : ""; + +export const AICHAT_REDIRECT_PATH = codedAppPlatformAuth + ? codedAppPlatformAuth.redirectPath.startsWith("/") + ? codedAppPlatformAuth.redirectPath + : `/${codedAppPlatformAuth.redirectPath}` + : "/auth_callback"; + +// Coded App builds bake in the org/tenant the deployment targets because the +// portal endpoint that lists them is not reachable cross-origin. In dev the +// login gate discovers them at runtime instead. +export const AICHAT_STATIC_ORG = codedAppPlatformAuth + ? { + orgName: codedAppPlatformAuth.orgName, + tenants: [ + { + id: codedAppPlatformAuth.tenantId, + name: codedAppPlatformAuth.tenantName, + }, + ], + } + : null; + +// Coded App builds call the baked host directly; dev targets alpha. +const UIPATH_BASE_URL = AICHAT_IS_CODED_APP + ? AICHAT_DIRECT_BASE_URL + : "https://alpha.uipath.com"; export function createUiPathSdk( accessToken: string, @@ -26,7 +107,7 @@ export function createUiPathSdk( orgName: orgTenant.orgName, tenantName: orgTenant.tenantName, clientId: AICHAT_CLIENT_ID, - redirectUri: `${origin}/auth_callback`, + redirectUri: `${origin}${AICHAT_REDIRECT_PATH}`, scope: AICHAT_SCOPE, }); sdk.updateToken({ token: accessToken, type: "oauth" }); diff --git a/apps/apollo-vertex/types/mdx.d.ts b/apps/apollo-vertex/types/mdx.d.ts new file mode 100644 index 000000000..2ecc39f19 --- /dev/null +++ b/apps/apollo-vertex/types/mdx.d.ts @@ -0,0 +1,7 @@ +// Typed default export for MDX page imports (e.g. the coded-app index pages). +declare module "*.mdx" { + import type { ComponentType } from "react"; + + const MDXComponent: ComponentType; + export default MDXComponent; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7daff635..94907537f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,6 +160,9 @@ importers: '@types/react-dom': specifier: ^19.2.2 version: 19.2.3(@types/react@19.2.8) + pagefind: + specifier: ^1.4.0 + version: 1.4.0 typescript: specifier: ^5.9.3 version: 5.9.3 diff --git a/turbo.json b/turbo.json index e780f4810..46aff326b 100644 --- a/turbo.json +++ b/turbo.json @@ -5,6 +5,7 @@ "tasks": { "build": { "dependsOn": ["^build"], + "env": ["APOLLO_CODED_APP", "APOLLO_CODED_APP_PATH", "UIP_GO_PLATFORM_AUTH_*", "UIP_GO_AICHAT_*"], "inputs": [ "$TURBO_DEFAULT$", "!src/**/*.stories.*", @@ -65,6 +66,7 @@ }, "storybook:build": { "dependsOn": ["^build"], + "env": ["APOLLO_CODED_APP", "APOLLO_CODED_APP_PATH"], "outputs": ["storybook-static/**"] }, "clean": {