diff --git a/.github/workflows/ui-preview.yml b/.github/workflows/ui-preview.yml new file mode 100644 index 0000000000000..a5d173494c987 --- /dev/null +++ b/.github/workflows/ui-preview.yml @@ -0,0 +1,196 @@ +name: UI Preview + +# Builds the standalone web client for a PR (or develop) and publishes it to ghcr.io, where Rocket.Chat Desktop +# pulls it anonymously (`rocketchat://ui-preview?pr=`). A PR's preview is removed with its label or on close. +on: + push: + branches: [develop] + paths: + - 'apps/meteor/client/**' + - 'apps/meteor/app/**' + - 'apps/meteor/ee/client/**' + - 'apps/meteor/public/**' + - 'apps/meteor/vite/**' + - 'packages/**' + - 'ee/packages/**' + - 'yarn.lock' + # No path filter: it would also gate `unlabeled`/`closed`, leaving a preview behind. The `preview` label gates builds. + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled, closed] + +concurrency: + # Adding or removing any other label must not cancel a preview build in progress. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}${{ (github.event.action == 'labeled' || github.event.action == 'unlabeled') && github.event.label.name != 'preview' && format('-{0}', github.run_id) || '' }} + cancel-in-progress: true + +env: + TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} + IMAGE: ghcr.io/rocketchat/rocket.chat-web + +permissions: {} + +jobs: + build: + name: Build UI preview + # Opt-in through the `preview` label. Publishing needs CR_PAT, which fork and Dependabot PRs do not receive. + if: >- + github.event_name == 'push' || ( + contains(github.event.pull_request.labels.*.name, 'preview') && + github.event.action != 'unlabeled' && github.event.action != 'closed' && + (github.event.action != 'labeled' || github.event.label.name == 'preview') && + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + ) + runs-on: ubuntu-24.04-arm + permissions: + contents: read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + cache-modules: true + install: true + + - uses: rharkor/caching-for-turbo@2238fae6eb9a9936f92356f54cb3660200d105e7 # v2.5.1 + + # Same package set `yarn dev:next` builds against a remote server. + - name: Build packages the client reads from dist + run: | + mapfile -t filters < <(node --input-type=module -e " + import { distOnlyWorkspacePackages, publicAssetWorkspacePackages } from './apps/meteor/vite/workspacePackages.mjs'; + console.log([...distOnlyWorkspacePackages, ...publicAssetWorkspacePackages].map((name) => '--filter=' + name + '...').join('\n')); + ") + yarn turbo run build "${filters[@]}" + + - name: Build standalone client + run: yarn workspace @rocket.chat/meteor build:vite + + - name: Package bundle + run: | + mkdir -p ui-preview + # The rest of dist/ is public/, which the Rocket.Chat server already serves. + tar --format=ustar -czf ui-preview/ui-preview.tar.gz -C apps/meteor/vite/dist index.html bundle + du -h ui-preview/ui-preview.tar.gz + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ui-preview + path: ui-preview + retention-days: 3 + + # Kept apart from the build so the job holding CR_PAT never runs the PR's code. + publish: + name: Publish UI preview + needs: build + runs-on: ubuntu-24.04-arm + permissions: + pull-requests: write + + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ui-preview + path: ui-preview + + - uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 + + - name: Push to ghcr.io + env: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + REPO: ${{ github.repository }} + run: | + # `pr--` keeps every build of a PR findable after `pr-` moves on, so cleanup can remove them all. + if [ -n "$NUMBER" ]; then tags="pr-$NUMBER,pr-$NUMBER-$HEAD_SHA,$HEAD_SHA"; else tags=develop; fi + echo "$CR_PAT" | oras login ghcr.io -u "$CR_USER" --password-stdin + cd ui-preview + oras push "$IMAGE:$tags" \ + --artifact-type application/vnd.rocketchat.ui-preview.v1 \ + --annotation "org.opencontainers.image.source=https://github.com/$REPO" \ + --annotation "org.opencontainers.image.revision=$HEAD_SHA" \ + ui-preview.tar.gz:application/vnd.oci.image.layer.v1.tar+gzip + + - name: Delete superseded develop builds + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ secrets.CR_PAT }} + PACKAGE: /orgs/${{ github.repository_owner }}/packages/container/rocket.chat-web/versions + run: | + # List every page before deleting, or deletions shift the pages still to be read. + ids=$(gh api --paginate "$PACKAGE" -q '.[] | select(.metadata.container.tags == []) | .id') + printf '%s' "$ids" | xargs -r -I{} gh api -X DELETE "$PACKAGE/{}" + + - name: Comment on PR + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + marker='' + body="$marker + ### 🖥️ UI preview + + Built from \`${HEAD_SHA:0:7}\` and published to \`$IMAGE:pr-$NUMBER\`. + + In Rocket.Chat Desktop with **Developer Mode** on, open one of these links to load this PR's UI into the workspace in focus: + + | | | + |---|---| + | Latest build of this PR | \`rocketchat://ui-preview?pr=$NUMBER\` | + | This exact build | \`rocketchat://ui-preview?pr=$NUMBER&sha=$HEAD_SHA\` | + + To pick the workspace, add \`&host=https://your.server\`. Use **View > Restore server UI**, or restart the app, to go back." + + comment_id=$(gh api "repos/$REPO/issues/$NUMBER/comments" --paginate -q ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n1) + if [ -n "$comment_id" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$comment_id" -f body="$body" + else + gh api "repos/$REPO/issues/$NUMBER/comments" -f body="$body" + fi + + remove: + name: Remove UI preview + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && ( + (github.event.action == 'unlabeled' && github.event.label.name == 'preview') || + (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview')) + ) + runs-on: ubuntu-24.04-arm + permissions: + pull-requests: write + + steps: + - name: Delete from ghcr.io + env: + GH_TOKEN: ${{ secrets.CR_PAT }} + PACKAGE: /orgs/${{ github.repository_owner }}/packages/container/rocket.chat-web/versions + NUMBER: ${{ github.event.pull_request.number }} + run: | + ids=$(gh api --paginate "$PACKAGE" \ + -q ".[] | select(any(.metadata.container.tags[]; . == \"pr-$NUMBER\" or startswith(\"pr-$NUMBER-\"))) | .id") + printf '%s' "$ids" | xargs -r -I{} gh api -X DELETE "$PACKAGE/{}" + + - name: Update PR comment + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + NUMBER: ${{ github.event.pull_request.number }} + run: | + marker='' + body="$marker + ### 🖥️ UI preview + + Removed from \`$IMAGE\`. Add the \`preview\` label again to publish a new one." + + comment_id=$(gh api "repos/$REPO/issues/$NUMBER/comments" --paginate -q ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n1) + if [ -n "$comment_id" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$comment_id" -f body="$body" + fi diff --git a/apps/meteor/vite/vite.config.mts b/apps/meteor/vite/vite.config.mts index 16ea19b93362a..f0c70663b202a 100644 --- a/apps/meteor/vite/vite.config.mts +++ b/apps/meteor/vite/vite.config.mts @@ -142,10 +142,15 @@ const findWorkspaceSources = (): { name: string; root: string; entry: string | u const manifest = join(root, 'package.json'); if (!existsSync(manifest) || !existsSync(join(root, 'src'))) return []; - const { name } = JSON.parse(readFileSync(manifest, 'utf8')); + const { name, main, browser } = JSON.parse(readFileSync(manifest, 'utf8')); if (distOnly.has(name)) return []; - const entry = ['src/index.ts', 'src/index.tsx'].map((file) => join(root, file)).find((file) => existsSync(file)); + // The declared client entry pointed back at its source (`./dist/main.client.js` → `src/main.client.ts`). + const declared: string | undefined = typeof browser === 'string' ? browser : main; + const declaredSource = declared?.replace(/^(\.\/)?dist\//, 'src/').replace(/\.js$/, ''); + const entry = [...(declaredSource ? [`${declaredSource}.ts`, `${declaredSource}.tsx`] : []), 'src/index.ts', 'src/index.tsx'] + .map((file) => join(root, file)) + .find((file) => existsSync(file)); return [{ name, root, entry }]; }), ); diff --git a/apps/meteor/vite/workspacePackages.mjs b/apps/meteor/vite/workspacePackages.mjs index bd966fe947aab..f537029c695eb 100644 --- a/apps/meteor/vite/workspacePackages.mjs +++ b/apps/meteor/vite/workspacePackages.mjs @@ -9,5 +9,5 @@ export const distOnlyWorkspacePackages = [ '@rocket.chat/ui-kit', ]; -// Built only for the files they place in public/ (the audio recording worker). -export const publicAssetWorkspacePackages = ['@rocket.chat/mp3-encoder']; +// Built only for the files public/ links to (the audio recording worker, the voice call popout page). +export const publicAssetWorkspacePackages = ['@rocket.chat/mp3-encoder', '@rocket.chat/ui-voip'];