Skip to content
Merged
196 changes: 196 additions & 0 deletions .github/workflows/ui-preview.yml
Original file line number Diff line number Diff line change
@@ -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=<number>`). 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]
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

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]'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: github.actor identifies the event actor, so a maintainer labeling or reopening a Dependabot PR makes this condition pass. Check github.event.pull_request.user.login instead, otherwise Dependabot PR code can be built and published despite the documented exclusion.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/workflows/ui-preview.yml, line 42:

<comment>`github.actor` identifies the event actor, so a maintainer labeling or reopening a Dependabot PR makes this condition pass. Check `github.event.pull_request.user.login` instead, otherwise Dependabot PR code can be built and published despite the documented exclusion.</comment>

<file context>
@@ -31,10 +34,13 @@ jobs:
+        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
</file context>
Suggested change
github.actor != 'dependabot[bot]'
github.event.pull_request.user.login != '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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: actions/download-artifact downloads artifacts through the Actions REST API (GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts), which the GITHUB_TOKEN only may call with the actions: read scope. The workflow sets permissions: {}, and this job re-declares permissions as only pull-requests: write, so the token has no actions scope and the download step fails with 403 on every run, breaking the entire publish job. Add actions: read to this job's permissions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/ui-preview.yml, line 78:

<comment>`actions/download-artifact` downloads artifacts through the Actions REST API (`GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`), which the `GITHUB_TOKEN` only may call with the `actions: read` scope. The workflow sets `permissions: {}`, and this job re-declares permissions as only `pull-requests: write`, so the token has no `actions` scope and the download step fails with 403 on every run, breaking the entire publish job. Add `actions: read` to this job's permissions.</comment>

<file context>
@@ -0,0 +1,131 @@
+    needs: build
+    runs-on: ubuntu-24.04-arm
+    permissions:
+      pull-requests: write
+
+    steps:
</file context>
Suggested change
pull-requests: write
pull-requests: write
actions: read


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-<n>-<sha>` keeps every build of a PR findable after `pr-<n>` 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='<!-- ui-preview -->'
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='<!-- ui-preview -->'
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
9 changes: 7 additions & 2 deletions apps/meteor/vite/vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }];
}),
);
Expand Down
4 changes: 2 additions & 2 deletions apps/meteor/vite/workspacePackages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Loading