From 730ba044456c55b011127fc12d77a3e787e53425 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 13:33:39 -0300 Subject: [PATCH 1/8] ci: publish PR UI previews to ghcr.io for Rocket.Chat Desktop Builds the standalone Vite client for each PR and publishes it as an OCI artifact at ghcr.io/rocketchat/rocket.chat-ui-preview (tags `pr-` and the head SHA), where Rocket.Chat Desktop pulls it anonymously via `rocketchat://ui-preview?pr=`. - `UI Preview Build` runs the PR's code on `pull_request` with read-only permissions and no secrets, and uploads the bundle as an artifact. - `UI Preview Publish` runs on `workflow_run`, checks the PR against GitHub's data (head SHA, fork, `ui-preview` label), pushes the artifact with oras and comments the Desktop links. It never extracts or runs the bundle. Fork PRs publish only with the label, which is removed after each publish. The production Vite build also needed two fixes: - Workspace packages whose entry is not `src/index.ts(x)` (base64, sha256, random) resolved to their unbuilt dist; the entry now follows the `browser`/`main` field back to its source. - `public/voice-call-popup.html` links to ui-voip's dist, so ui-voip is built with the other public asset packages. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ui-preview-build.yml | 70 ++++++++++++ .github/workflows/ui-preview-publish.yml | 131 +++++++++++++++++++++++ apps/meteor/vite/vite.config.mts | 9 +- apps/meteor/vite/workspacePackages.mjs | 4 +- 4 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ui-preview-build.yml create mode 100644 .github/workflows/ui-preview-publish.yml diff --git a/.github/workflows/ui-preview-build.yml b/.github/workflows/ui-preview-build.yml new file mode 100644 index 0000000000000..236c2341e6bd7 --- /dev/null +++ b/.github/workflows/ui-preview-build.yml @@ -0,0 +1,70 @@ +name: UI Preview Build + +# Builds the standalone web client for a PR. Runs the PR's code without secrets or write permissions; +# ui-preview-publish.yml decides whether the result is published. +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + paths: + - 'apps/meteor/client/**' + - 'apps/meteor/app/**' + - 'apps/meteor/ee/client/**' + - 'apps/meteor/public/**' + - 'apps/meteor/vite/**' + - 'packages/**' + - 'ee/packages/**' + - 'yarn.lock' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} + +permissions: {} + +jobs: + build: + name: Build UI preview + if: github.event.action != 'labeled' || github.event.label.name == 'ui-preview' + 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: | + filters=$(node --input-type=module -e " + import { distOnlyWorkspacePackages, publicAssetWorkspacePackages } from './apps/meteor/vite/workspacePackages.mjs'; + console.log([...distOnlyWorkspacePackages, ...publicAssetWorkspacePackages].map((name) => '--filter=' + name + '...').join(' ')); + ") + 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 + echo "${{ github.event.pull_request.number }}" > ui-preview/pr-number + 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 diff --git a/.github/workflows/ui-preview-publish.yml b/.github/workflows/ui-preview-publish.yml new file mode 100644 index 0000000000000..1de5b5370a6b2 --- /dev/null +++ b/.github/workflows/ui-preview-publish.yml @@ -0,0 +1,131 @@ +name: UI Preview Publish + +# Publishes the bundle built by ui-preview-build.yml to ghcr.io, where Rocket.Chat Desktop pulls it anonymously +# (`rocketchat://ui-preview?pr=`). The bundle is never extracted or run here. +on: + workflow_run: + workflows: [UI Preview Build] + types: [completed] + +permissions: {} + +env: + IMAGE: ghcr.io/rocketchat/rocket.chat-ui-preview + +jobs: + publish: + name: Publish UI preview + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-24.04-arm + permissions: + actions: read + issues: write + pull-requests: write + + steps: + # Absent when the build job was skipped (a label other than ui-preview was added). + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + continue-on-error: true + with: + name: ui-preview + path: ui-preview + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + # The artifact comes from PR code, so only its PR number is read, and it is checked against GitHub's own data. + - name: Resolve PR + id: pr + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + if [ ! -f ui-preview/pr-number ]; then + echo "publish=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + number=$(head -c 16 ui-preview/pr-number | tr -d '[:space:]') + if ! [[ "$number" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid PR number in artifact" + exit 1 + fi + + gh api "repos/$REPO/pulls/$number" > pr.json + + if [ "$(jq -r .head.sha pr.json)" != "$HEAD_SHA" ]; then + echo "PR #$number moved past $HEAD_SHA; a newer build will publish." + echo "publish=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + fork=$(jq -r --arg repo "$REPO" '.head.repo.full_name != $repo' pr.json) + labeled=$(jq -r 'any(.labels[]; .name == "ui-preview")' pr.json) + if [ "$fork" = "true" ] && [ "$labeled" != "true" ]; then + echo "Fork PR #$number needs the ui-preview label from a maintainer." + echo "publish=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "number=$number" >> "$GITHUB_OUTPUT" + echo "fork=$fork" >> "$GITHUB_OUTPUT" + echo "publish=true" >> "$GITHUB_OUTPUT" + + - uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 + if: steps.pr.outputs.publish == 'true' + + - name: Push to ghcr.io + if: steps.pr.outputs.publish == 'true' + env: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + REPO: ${{ github.repository }} + run: | + echo "$CR_PAT" | oras login ghcr.io -u "$CR_USER" --password-stdin + cd ui-preview + oras push "$IMAGE:pr-$NUMBER,$HEAD_SHA" \ + --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 + + # A fork's later pushes must be reviewed again before they are published. + - name: Remove ui-preview label from fork PR + if: steps.pr.outputs.publish == 'true' && steps.pr.outputs.fork == 'true' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + NUMBER: ${{ steps.pr.outputs.number }} + run: gh api -X DELETE "repos/$REPO/issues/$NUMBER/labels/ui-preview" + + - name: Comment on PR + if: steps.pr.outputs.publish == 'true' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ github.event.workflow_run.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 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']; From abe885e711ed3eb441d49aeadcf650a9f3dc66fd Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 14:21:36 -0300 Subject: [PATCH 2/8] ci: name the preview package rocket.chat-web-preview Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ui-preview-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ui-preview-publish.yml b/.github/workflows/ui-preview-publish.yml index 1de5b5370a6b2..cb0087a9b50bc 100644 --- a/.github/workflows/ui-preview-publish.yml +++ b/.github/workflows/ui-preview-publish.yml @@ -10,7 +10,7 @@ on: permissions: {} env: - IMAGE: ghcr.io/rocketchat/rocket.chat-ui-preview + IMAGE: ghcr.io/rocketchat/rocket.chat-web-preview jobs: publish: From 0c6a3cf4d222a2d4326ee21a8e36f7dc704289bb Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 14:22:59 -0300 Subject: [PATCH 3/8] ci: name the package rocket.chat-web Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ui-preview-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ui-preview-publish.yml b/.github/workflows/ui-preview-publish.yml index cb0087a9b50bc..8cab27d7d6379 100644 --- a/.github/workflows/ui-preview-publish.yml +++ b/.github/workflows/ui-preview-publish.yml @@ -10,7 +10,7 @@ on: permissions: {} env: - IMAGE: ghcr.io/rocketchat/rocket.chat-web-preview + IMAGE: ghcr.io/rocketchat/rocket.chat-web jobs: publish: From 37977952f35a2de837295c0101aefdfed39ac79a Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 22:20:28 -0300 Subject: [PATCH 4/8] ci: address shellcheck findings in UI preview workflows Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ui-preview-build.yml | 6 +++--- .github/workflows/ui-preview-publish.yml | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ui-preview-build.yml b/.github/workflows/ui-preview-build.yml index 236c2341e6bd7..72d5219ac04a3 100644 --- a/.github/workflows/ui-preview-build.yml +++ b/.github/workflows/ui-preview-build.yml @@ -46,11 +46,11 @@ jobs: # Same package set `yarn dev:next` builds against a remote server. - name: Build packages the client reads from dist run: | - filters=$(node --input-type=module -e " + 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(' ')); + console.log([...distOnlyWorkspacePackages, ...publicAssetWorkspacePackages].map((name) => '--filter=' + name + '...').join('\n')); ") - yarn turbo run build $filters + yarn turbo run build "${filters[@]}" - name: Build standalone client run: yarn workspace @rocket.chat/meteor build:vite diff --git a/.github/workflows/ui-preview-publish.yml b/.github/workflows/ui-preview-publish.yml index 8cab27d7d6379..87f22c60c6bf3 100644 --- a/.github/workflows/ui-preview-publish.yml +++ b/.github/workflows/ui-preview-publish.yml @@ -67,9 +67,11 @@ jobs: exit 0 fi - echo "number=$number" >> "$GITHUB_OUTPUT" - echo "fork=$fork" >> "$GITHUB_OUTPUT" - echo "publish=true" >> "$GITHUB_OUTPUT" + { + echo "number=$number" + echo "fork=$fork" + echo "publish=true" + } >> "$GITHUB_OUTPUT" - uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 if: steps.pr.outputs.publish == 'true' From 760b6b6bd32b2296c290982411769a83cba8b70d Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 22:30:22 -0300 Subject: [PATCH 5/8] ci: publish the UI preview from the pull_request workflow `workflow_run` workflows only run from the default branch, so the separate publish workflow could not run until merged. The publish is now a second job of the same `pull_request` workflow, limited to PRs from this repository (forks and Dependabot do not receive CR_PAT). It only downloads the artifact and pushes it, never running the PR's code. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ui-preview-build.yml | 70 ------------ .github/workflows/ui-preview-publish.yml | 133 ----------------------- .github/workflows/ui-preview.yml | 131 ++++++++++++++++++++++ 3 files changed, 131 insertions(+), 203 deletions(-) delete mode 100644 .github/workflows/ui-preview-build.yml delete mode 100644 .github/workflows/ui-preview-publish.yml create mode 100644 .github/workflows/ui-preview.yml diff --git a/.github/workflows/ui-preview-build.yml b/.github/workflows/ui-preview-build.yml deleted file mode 100644 index 72d5219ac04a3..0000000000000 --- a/.github/workflows/ui-preview-build.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: UI Preview Build - -# Builds the standalone web client for a PR. Runs the PR's code without secrets or write permissions; -# ui-preview-publish.yml decides whether the result is published. -on: - pull_request: - types: [opened, synchronize, reopened, labeled] - paths: - - 'apps/meteor/client/**' - - 'apps/meteor/app/**' - - 'apps/meteor/ee/client/**' - - 'apps/meteor/public/**' - - 'apps/meteor/vite/**' - - 'packages/**' - - 'ee/packages/**' - - 'yarn.lock' - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -env: - TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} - -permissions: {} - -jobs: - build: - name: Build UI preview - if: github.event.action != 'labeled' || github.event.label.name == 'ui-preview' - 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 - echo "${{ github.event.pull_request.number }}" > ui-preview/pr-number - 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 diff --git a/.github/workflows/ui-preview-publish.yml b/.github/workflows/ui-preview-publish.yml deleted file mode 100644 index 87f22c60c6bf3..0000000000000 --- a/.github/workflows/ui-preview-publish.yml +++ /dev/null @@ -1,133 +0,0 @@ -name: UI Preview Publish - -# Publishes the bundle built by ui-preview-build.yml to ghcr.io, where Rocket.Chat Desktop pulls it anonymously -# (`rocketchat://ui-preview?pr=`). The bundle is never extracted or run here. -on: - workflow_run: - workflows: [UI Preview Build] - types: [completed] - -permissions: {} - -env: - IMAGE: ghcr.io/rocketchat/rocket.chat-web - -jobs: - publish: - name: Publish UI preview - if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' - runs-on: ubuntu-24.04-arm - permissions: - actions: read - issues: write - pull-requests: write - - steps: - # Absent when the build job was skipped (a label other than ui-preview was added). - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - continue-on-error: true - with: - name: ui-preview - path: ui-preview - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - - # The artifact comes from PR code, so only its PR number is read, and it is checked against GitHub's own data. - - name: Resolve PR - id: pr - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - run: | - if [ ! -f ui-preview/pr-number ]; then - echo "publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - number=$(head -c 16 ui-preview/pr-number | tr -d '[:space:]') - if ! [[ "$number" =~ ^[0-9]+$ ]]; then - echo "::error::Invalid PR number in artifact" - exit 1 - fi - - gh api "repos/$REPO/pulls/$number" > pr.json - - if [ "$(jq -r .head.sha pr.json)" != "$HEAD_SHA" ]; then - echo "PR #$number moved past $HEAD_SHA; a newer build will publish." - echo "publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - fork=$(jq -r --arg repo "$REPO" '.head.repo.full_name != $repo' pr.json) - labeled=$(jq -r 'any(.labels[]; .name == "ui-preview")' pr.json) - if [ "$fork" = "true" ] && [ "$labeled" != "true" ]; then - echo "Fork PR #$number needs the ui-preview label from a maintainer." - echo "publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - { - echo "number=$number" - echo "fork=$fork" - echo "publish=true" - } >> "$GITHUB_OUTPUT" - - - uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 - if: steps.pr.outputs.publish == 'true' - - - name: Push to ghcr.io - if: steps.pr.outputs.publish == 'true' - env: - CR_USER: ${{ secrets.CR_USER }} - CR_PAT: ${{ secrets.CR_PAT }} - NUMBER: ${{ steps.pr.outputs.number }} - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - REPO: ${{ github.repository }} - run: | - echo "$CR_PAT" | oras login ghcr.io -u "$CR_USER" --password-stdin - cd ui-preview - oras push "$IMAGE:pr-$NUMBER,$HEAD_SHA" \ - --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 - - # A fork's later pushes must be reviewed again before they are published. - - name: Remove ui-preview label from fork PR - if: steps.pr.outputs.publish == 'true' && steps.pr.outputs.fork == 'true' - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - NUMBER: ${{ steps.pr.outputs.number }} - run: gh api -X DELETE "repos/$REPO/issues/$NUMBER/labels/ui-preview" - - - name: Comment on PR - if: steps.pr.outputs.publish == 'true' - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - NUMBER: ${{ steps.pr.outputs.number }} - HEAD_SHA: ${{ github.event.workflow_run.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 diff --git a/.github/workflows/ui-preview.yml b/.github/workflows/ui-preview.yml new file mode 100644 index 0000000000000..d9cc9f4845f19 --- /dev/null +++ b/.github/workflows/ui-preview.yml @@ -0,0 +1,131 @@ +name: UI Preview + +# Builds the standalone web client for a PR and publishes it to ghcr.io, where Rocket.Chat Desktop pulls it +# anonymously (`rocketchat://ui-preview?pr=`). +on: + pull_request: + paths: + - 'apps/meteor/client/**' + - 'apps/meteor/app/**' + - 'apps/meteor/ee/client/**' + - 'apps/meteor/public/**' + - 'apps/meteor/vite/**' + - 'packages/**' + - 'ee/packages/**' + - 'yarn.lock' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + 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 + # Publishing needs CR_PAT, which fork and Dependabot PRs do not receive. + if: 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 }} + REPO: ${{ github.repository }} + run: | + echo "$CR_PAT" | oras login ghcr.io -u "$CR_USER" --password-stdin + cd ui-preview + oras push "$IMAGE:pr-$NUMBER,$HEAD_SHA" \ + --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: Comment on PR + 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 From 266fbcab0d0551c904d7d021fcd9f6cfc97c387a Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 30 Sep 2026 11:41:51 -0300 Subject: [PATCH 6/8] ci: gate the UI preview build on the preview label --- .github/workflows/ui-preview.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ui-preview.yml b/.github/workflows/ui-preview.yml index d9cc9f4845f19..7041794847d26 100644 --- a/.github/workflows/ui-preview.yml +++ b/.github/workflows/ui-preview.yml @@ -4,6 +4,7 @@ name: UI Preview # anonymously (`rocketchat://ui-preview?pr=`). on: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - 'apps/meteor/client/**' - 'apps/meteor/app/**' @@ -15,7 +16,8 @@ on: - 'yarn.lock' concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + # Adding any other label must not cancel a preview build in progress. + group: ${{ github.workflow }}-${{ github.event.pull_request.number }}${{ github.event.action == 'labeled' && github.event.label.name != 'preview' && format('-{0}', github.run_id) || '' }} cancel-in-progress: true env: @@ -27,8 +29,12 @@ permissions: {} jobs: build: name: Build UI preview - # Publishing needs CR_PAT, which fork and Dependabot PRs do not receive. - if: github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' + # Opt-in through the `preview` label. Publishing needs CR_PAT, which fork and Dependabot PRs do not receive. + if: >- + contains(github.event.pull_request.labels.*.name, 'preview') && + (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 From e38ea69c87d59b7d813f61d690f469297efdf673 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 30 Sep 2026 12:03:49 -0300 Subject: [PATCH 7/8] ci: publish the develop UI preview and remove PR previews on unlabel or close --- .github/workflows/ui-preview.yml | 84 +++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ui-preview.yml b/.github/workflows/ui-preview.yml index 7041794847d26..470f5922a38b5 100644 --- a/.github/workflows/ui-preview.yml +++ b/.github/workflows/ui-preview.yml @@ -1,11 +1,11 @@ name: UI Preview -# Builds the standalone web client for a PR and publishes it to ghcr.io, where Rocket.Chat Desktop pulls it -# anonymously (`rocketchat://ui-preview?pr=`). +# 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: - pull_request: - types: [opened, synchronize, reopened, labeled] - paths: + push: + branches: [develop] + paths: &paths - 'apps/meteor/client/**' - 'apps/meteor/app/**' - 'apps/meteor/ee/client/**' @@ -14,10 +14,13 @@ on: - 'packages/**' - 'ee/packages/**' - 'yarn.lock' + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled, closed] + paths: *paths concurrency: - # Adding any other label must not cancel a preview build in progress. - group: ${{ github.workflow }}-${{ github.event.pull_request.number }}${{ github.event.action == 'labeled' && github.event.label.name != 'preview' && format('-{0}', github.run_id) || '' }} + # 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: @@ -31,10 +34,13 @@ jobs: name: Build UI preview # Opt-in through the `preview` label. Publishing needs CR_PAT, which fork and Dependabot PRs do not receive. if: >- - contains(github.event.pull_request.labels.*.name, 'preview') && - (github.event.action != 'labeled' || github.event.label.name == 'preview') && - github.event.pull_request.head.repo.full_name == github.repository && - github.actor != 'dependabot[bot]' + 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 @@ -96,18 +102,30 @@ jobs: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} NUMBER: ${{ github.event.pull_request.number }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} + 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:pr-$NUMBER,$HEAD_SHA" \ + 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: | + gh api --paginate "$PACKAGE" -q '.[] | select(.metadata.container.tags == []) | .id' | + 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 }} @@ -135,3 +153,43 @@ jobs: 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: | + gh api --paginate "$PACKAGE" \ + -q ".[] | select(any(.metadata.container.tags[]; . == \"pr-$NUMBER\" or startswith(\"pr-$NUMBER-\"))) | .id" | + 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 From eb79e9cf6173af43cd3aee956da47732539f5e4d Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 30 Sep 2026 12:21:12 -0300 Subject: [PATCH 8/8] ci: list package versions before deleting and run preview cleanup on any PR Deleting while --paginate is still reading shifts later pages and skips versions. The pull_request path filter also gated unlabeled/closed, so a PR that reverted its matching files never cleaned up its preview. --- .github/workflows/ui-preview.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ui-preview.yml b/.github/workflows/ui-preview.yml index 470f5922a38b5..a5d173494c987 100644 --- a/.github/workflows/ui-preview.yml +++ b/.github/workflows/ui-preview.yml @@ -5,7 +5,7 @@ name: UI Preview on: push: branches: [develop] - paths: &paths + paths: - 'apps/meteor/client/**' - 'apps/meteor/app/**' - 'apps/meteor/ee/client/**' @@ -14,9 +14,9 @@ on: - '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] - paths: *paths concurrency: # Adding or removing any other label must not cancel a preview build in progress. @@ -121,8 +121,9 @@ jobs: GH_TOKEN: ${{ secrets.CR_PAT }} PACKAGE: /orgs/${{ github.repository_owner }}/packages/container/rocket.chat-web/versions run: | - gh api --paginate "$PACKAGE" -q '.[] | select(.metadata.container.tags == []) | .id' | - xargs -r -I{} gh api -X DELETE "$PACKAGE/{}" + # 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' @@ -173,9 +174,9 @@ jobs: PACKAGE: /orgs/${{ github.repository_owner }}/packages/container/rocket.chat-web/versions NUMBER: ${{ github.event.pull_request.number }} run: | - gh api --paginate "$PACKAGE" \ - -q ".[] | select(any(.metadata.container.tags[]; . == \"pr-$NUMBER\" or startswith(\"pr-$NUMBER-\"))) | .id" | - xargs -r -I{} gh api -X DELETE "$PACKAGE/{}" + 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: