commit a774ac673aef359a4dc3027e28e1f3aac96d1f0c Author: upstream-mirror Date: Wed Sep 23 16:12:52 2026 +0500 snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted) diff --git a/.agents/skills b/.agents/skills new file mode 120000 index 0000000..42c5394 --- /dev/null +++ b/.agents/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..ba34f35 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "examples", + "runtimeExecutable": "yarn", + "runtimeArgs": ["dev"], + "port": 5420 + }, + { + "name": "dotcom-app", + "runtimeExecutable": "yarn", + "runtimeArgs": ["dev-app"], + "port": 3000 + }, + { + "name": "docs", + "runtimeExecutable": "yarn", + "runtimeArgs": ["dev-docs"], + "port": 3001 + } + ] +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..d5a4a47 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,53 @@ +{ + "spinnerVerbs": { + "mode": "replace", + "verbs": [ + "panning", + "zooming", + "scrolling", + "drawing", + "selecting", + "nudging", + "resizing", + "rotating", + "flipping", + "stretching", + "aligning", + "distributing", + "stacking", + "packing", + "grouping", + "cropping", + "styling", + "embedding", + "scribbling", + "brushing", + "pointing", + "snapping to grid" + ] + }, + "permissions": { + "allow": [ + "WebSearch", + "Bash(gh pr:*)", + "Bash(yarn install)", + "Bash(yarn test:*)", + "Bash(yarn lint:*)", + "Bash(yarn typecheck)", + "Bash(git add:*)" + ] + }, + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "cd \"$(git rev-parse --show-toplevel)\" && git diff --name-only --diff-filter=ACMR | grep -E '\\.(ts|tsx|js|jsx|json|md)$' | xargs -r yarn run -T prettier --write || true" + } + ] + } + ] + }, + "enabledPlugins": {} +} diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..42c5394 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..6819d20 --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "tldraw", + "owner": { + "name": "tldraw", + "email": "tim@tldraw.com" + }, + "metadata": { + "description": "Draw and visually collaborate with your agents inside Cursor", + "version": "0.1.0", + "pluginRoot": "apps/mcp-app/plugins" + }, + "plugins": [ + { + "name": "tldraw", + "source": "tldraw-mcp", + "description": "Draw and visually collaborate with your agents inside Cursor" + } + ] +} diff --git a/.cursor/skills b/.cursor/skills new file mode 120000 index 0000000..42c5394 --- /dev/null +++ b/.cursor/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..13d22ee --- /dev/null +++ b/.dockerignore @@ -0,0 +1,74 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +**/node_modules + +.git +**/.git + +dist +dist-cjs +dist-esm +.tsbuild* +.lazy +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# turborepo +.turbo + +coverage + +**/*.env +**/*.tsbuildinfo + +**/*.css.map +**/*.js.map +apps/webdriver/www/index.js +apps/webdriver/www/index.css +apps/dotcom/*-worker/.dev.vars +nohup.out + +packages/*/package +packages/*/*.tgz + +tsconfig.build.json +.vercel + +api-json +api-md + +apps/webdriver/www +!apps/webdriver/www/index.html + +# yarn v2 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +packages/*/api +apps/examples/www/index.css +apps/examples/www/index.js +.tsbuild diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..c8ab71e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,49 @@ +name: Bug report +description: File a bug report +type: Bug +labels: [] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Describe the bug and what you expected to happen instead. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: How can we reproduce the bug? + description: | + Please share the steps to reproduce this bug. A [StackBlitz reproduction](https://stackblitz.com/edit/vitejs-vite-ahoswhus?file=src%2FApp.tsx) or screen recording helps us fix it faster. + validations: + required: false + - type: dropdown + id: browsers + validations: + required: false + attributes: + label: What browsers are you seeing the problem on? + multiple: true + options: + - Firefox + - Chrome + - Safari + - Microsoft Edge + - type: dropdown + id: product + attributes: + label: Where did this happen? + options: + - tldraw SDK (@tldraw/tldraw npm package) + - tldraw.com + - tldraw.dev (documentation site) + - VS Code extension + - Obsidian plugin + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..968485a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Documentation + url: https://tldraw.dev + about: Looking for help with the tldraw SDK? Check out the docs. + - name: Discord + url: https://discord.tldraw.com + about: Chat with the community and get help from other developers. diff --git a/.github/ISSUE_TEMPLATE/example_request.yml b/.github/ISSUE_TEMPLATE/example_request.yml new file mode 100644 index 0000000..0c9f8e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/example_request.yml @@ -0,0 +1,16 @@ +name: Example request +description: Submit a request for an example +labels: [] +assignees: [] +body: + - type: markdown + attributes: + value: | + Need an example of how to do something with tldraw? We're happy to help! + - type: textarea + id: description + attributes: + label: What's the example? + description: Describe the example or the use case you'd like to see demonstrated. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..4ec9664 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,27 @@ +name: Feature request +description: Suggest a new feature or improvement +type: Feature +labels: [] +assignees: [] +body: + - type: markdown + attributes: + value: | + Have an idea for a feature or improvement in tldraw? We'd love to hear it! + - type: textarea + id: description + attributes: + label: What's the feature? + description: Describe the feature, the problem it solves, and any examples from other apps. + validations: + required: true + - type: dropdown + id: product + attributes: + label: Where would you like this feature? + options: + - tldraw SDK (@tldraw/tldraw npm package) + - tldraw.com + - makereal.tldraw.com + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/task.yml b/.github/ISSUE_TEMPLATE/task.yml new file mode 100644 index 0000000..099affd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.yml @@ -0,0 +1,13 @@ +name: Task +description: Create a task +type: Task +labels: [] +assignees: [] +body: + - type: textarea + id: description + attributes: + label: Description + description: Describe the task. + validations: + required: true diff --git a/.github/actions/deploy-templates/action.yml b/.github/actions/deploy-templates/action.yml new file mode 100644 index 0000000..3375f9d --- /dev/null +++ b/.github/actions/deploy-templates/action.yml @@ -0,0 +1,69 @@ +name: Deploy templates +description: > + Build the starter-kit demos against the checked-out SDK and deploy the cloudflare-hosted ones to + *.templates.tldraw.dev. Shared by deploy-templates.yml (template changes, manual runs) and + publish.yml (releases). Expects the repo to be checked out already; runs the setup action itself. + +# The cloudflare credentials are secrets on the deploy-production environment, so the job using +# this action has to select that environment and pass them in. This is a composite action rather +# than a workflow_call workflow because a called workflow only ever sees the secrets its caller +# hands it, and an `environment:` on a job inside the callee does not add that environment's +# secrets to them — see #10636. +inputs: + license-key: + description: tldraw license key the demos are built with (TEMPLATES_TLDRAW_LICENSE_KEY) + required: true + cloudflare-account-id: + description: Cloudflare account the workers deploy to (CLOUDFLARE_ACCOUNT_ID) + required: true + cloudflare-api-token: + description: Cloudflare API token with workers permissions (CLOUDFLARE_API_TOKEN) + required: true + +runs: + using: composite + steps: + - uses: ./.github/actions/setup + + # most templates run `tsc` as part of their build, which resolves the workspace packages + # through their project references and so needs the declarations built first + - name: Build types + shell: bash + env: + # build-types pulls in refresh-assets, which rewrites the generated version files. in CI it + # exits 1 on any change to a committed asset instead of writing it, which would fail the + # deploy on a release ref whose generated files aren't already in sync. + ALLOW_REFRESH_ASSETS_CHANGES: 1 + run: yarn build-types + + - name: Deploy templates + shell: bash + env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + ALLOW_REFRESH_ASSETS_CHANGES: 1 + TEMPLATES_TLDRAW_LICENSE_KEY: ${{ inputs.license-key }} + CLOUDFLARE_API_TOKEN: ${{ inputs.cloudflare-api-token }} + CLOUDFLARE_ACCOUNT_ID: ${{ inputs.cloudflare-account-id }} + run: | + # the templates import tldraw/tldraw.css, which is generated and gitignored + yarn lazy prebuild + + cd templates + all_templates=$(ls -d */ | cut -f1 -d'/') + cd .. + + # the demos are independent of each other, so deploy them all before failing: one broken + # template shouldn't leave the rest stranded on an old build with no signal about which. + # templates with no `deploy` config in their package.json skip themselves. + failed="" + for template in $all_templates; do + echo "::group::Deploying $template" + yarn tsx ./internal/scripts/deploy-template.ts $template || failed="$failed $template" + echo "::endgroup::" + done + + if [ -n "$failed" ]; then + echo "::error::Failed to deploy templates:$failed" + exit 1 + fi diff --git a/.github/actions/discord-fail-notify/action.yml b/.github/actions/discord-fail-notify/action.yml new file mode 100644 index 0000000..5b5eca1 --- /dev/null +++ b/.github/actions/discord-fail-notify/action.yml @@ -0,0 +1,32 @@ +name: Discord fail notify +description: Post a failure message to the Discord deploy/health channel. + +inputs: + webhook-url: + description: Discord webhook URL + required: true + status: + description: What happened to the workflow, used in the message verb + required: false + default: failed + +runs: + using: composite + steps: + - name: Notify Discord + shell: bash + env: + DISCORD_WEBHOOK_URL: ${{ inputs.webhook-url }} + WORKFLOW: ${{ github.workflow }} + REF: ${{ github.ref_name }} + STATUS: ${{ inputs.status }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [ -z "$DISCORD_WEBHOOK_URL" ]; then + echo "::warning::DISCORD_WEBHOOK_URL is empty; skipping Discord notification." + exit 0 + fi + payload=$(jq -nc \ + --arg content "<@&1414904550917144586> ❌ **${WORKFLOW}** ${STATUS} on \`${REF}\`: <${RUN_URL}>" \ + '{content: $content}') + curl -fsS -X POST -H "Content-Type: application/json" -d "$payload" "$DISCORD_WEBHOOK_URL" diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml new file mode 100644 index 0000000..6a99df7 --- /dev/null +++ b/.github/actions/setup-playwright/action.yml @@ -0,0 +1,46 @@ +name: 'Setup Playwright' +description: 'Install Playwright browsers with version-keyed caching shared across workflows.' + +inputs: + browsers: + description: 'Space-separated Playwright browsers to install and cache.' + required: false + # Only bundled Chromium by default — every e2e project uses `devices['Desktop Chrome']` / + # `['Pixel 5']` with no `channel`, so the branded `chrome` build is never launched. + default: 'chromium' + +runs: + using: 'composite' + steps: + # @playwright/test is hoisted to the root node_modules (it's a devDependency of the e2e + # workspaces), so Node resolves it from the repo root — no per-workspace cwd, nothing written + # to the tree. We also derive a key-safe browser token (spaces -> dashes) so multi-browser + # values like "chromium firefox" don't put a space in the cache key. + - name: Resolve Playwright version and browsers + shell: bash + env: + BROWSERS: ${{ inputs.browsers }} + run: | + echo "PLAYWRIGHT_VERSION=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_ENV" + echo "PLAYWRIGHT_BROWSERS_KEY=$(echo "$BROWSERS" | tr ' ' '-')" >> "$GITHUB_ENV" + + # Keyed on os + arch + version + browsers. The arch matters (ARM vs x64 need different + # binaries); the browser set matters too — without it, adding a browser would be a stale cache + # hit that skips the install and never downloads the new one. + - name: Check for Playwright browsers cache + id: playwright-cache + uses: actions/cache@v5 + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ env.PLAYWRIGHT_VERSION }}-${{ env.PLAYWRIGHT_BROWSERS_KEY }} + + # Cache miss only. `--with-deps` also installs the OS libraries the browsers need — a no-op for + # Chromium on GitHub's runner images, but it means adding webkit/firefox via `browsers` later + # just works without extra setup. + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + shell: bash + env: + BROWSERS: ${{ inputs.browsers }} + run: npx playwright install --with-deps $BROWSERS diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..e829707 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,39 @@ +name: Setup tldraw/tldraw +description: Set up node & yarn + +runs: + using: composite + steps: + # see https://github.com/actions/setup-node/issues/899 for one of the reasons why this + # needs to be done before action/setup-node + - name: Enable corepack + run: npm i -g corepack + shell: bash + + - name: Setup Node.js Environment + id: setup-node + uses: actions/setup-node@v6 + with: + node-version: 24.17.0 + + - name: Restore node_modules cache + uses: actions/cache@v5 + id: node-modules-cache + with: + path: | + node_modules + **/node_modules + .yarn/cache + .yarn/unplugged + .yarn/install-state.gz + key: ${{ runner.os }}-${{ runner.arch }}-node-${{ steps.setup-node.outputs.node-version }}-yarn-${{ hashFiles('yarn.lock', '.yarnrc.yml', '.yarn/patches/*') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-node-${{ steps.setup-node.outputs.node-version }}-yarn- + + - name: Install dependencies + run: yarn install --immutable + shell: bash + env: + # Hardened mode enables --check-resolutions on public PRs, adding ~15s. + # See https://yarnpkg.com/features/security - Yarn recommends disabling in most jobs. + YARN_ENABLE_HARDENED_MODE: 0 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2f68627 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +Describe what your pull request does. If you can, add GIFs or images showing the before and after of your change. + +### Change type + +- [ ] `bugfix` +- [ ] `improvement` +- [ ] `feature` +- [ ] `api` +- [ ] `other` + +### Test plan + +1. Create a shape... +2. + +- [ ] Unit tests +- [ ] End to end tests + +### Release notes + +- Fixed a bug with… \ No newline at end of file diff --git a/.github/workflows/bump-versions.yml b/.github/workflows/bump-versions.yml new file mode 100644 index 0000000..a7cebb9 --- /dev/null +++ b/.github/workflows/bump-versions.yml @@ -0,0 +1,73 @@ +name: Bump versions to latest tldraw version +# This bumps all package versions to match the latest tldraw version from npm + +# Prevent more than one version bumping job from running at the same time +concurrency: + group: version-bump + +# Only run on main branch +on: + workflow_dispatch: + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + bump_versions: + name: 'Bump package versions to latest tldraw version' + environment: npm deploy + timeout-minutes: 30 + runs-on: ubuntu-latest-16-cores-arm-open + + steps: + - name: Generate GH token + id: generate_token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + with: + app_id: ${{ secrets.HUPPY_APP_ID }} + private_key: ${{ secrets.HUPPY_APP_PRIVATE_KEY }} + + - name: Check out code + id: checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + token: ${{ steps.generate_token.outputs.token }} + + - name: Configure git credentials + run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Check branch + run: | + if [[ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]]; then + echo "ERROR This workflow only runs on the main branch." + exit 1 + fi + + - name: Run our setup + uses: ./.github/actions/setup + + - name: Bump versions + run: | + git config --global user.name 'huppy-bot[bot]' + git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com' + yarn tsx ./internal/scripts/bump-versions.ts + env: + GH_TOKEN: ${{ steps.generate_token.outputs.token }} + + # Preserve the setup action for post-job cleanup when checkout already succeeded. + - name: Check out Discord notify action + if: failure() && steps.checkout.outcome != 'success' + uses: actions/checkout@v6 + with: + sparse-checkout: .github/actions/discord-fail-notify + + - name: Notify Discord on failure + if: failure() + uses: ./.github/actions/discord-fail-notify + with: + webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..8bb211e --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,112 @@ +name: Checks + +on: + pull_request: + merge_group: + push: + branches: [main] + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + +defaults: + run: + shell: bash + +permissions: + contents: read + # update-pr-template reads the repo's label list (octo.issues.listLabelsForRepo). + issues: read + +jobs: + test: + name: 'Tests & checks' + timeout-minutes: 15 + runs-on: ubuntu-latest-16-cores-arm-open # TODO: this should probably run on multiple OSes + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - uses: ./.github/actions/setup + + - name: Check for duplicate dependencies + run: yarn dedupe --check + + - name: Check packages + run: yarn check-packages + + - name: Check circular dependencies + run: yarn check-circular-deps + + - name: Restore build tool caches + uses: actions/cache@v5 + with: + path: | + .lazy + **/.lazy + **/.tsbuild + **/*.tsbuildinfo + key: test-tools-${{ runner.os }}-${{ hashFiles('yarn.lock', '.oxlintrc.json', '.oxfmtrc.json', 'lazy.config.ts') }} + + - name: Typecheck + run: yarn build-types + + - name: Check bundle sizes + run: yarn lazy check-bundle-size + + - name: Check PR template + run: yarn update-pr-template --check + env: + GH_TOKEN: ${{ github.token }} + + - name: Check API declarations and docs work as intended + run: yarn api-check + + - name: Lint + run: yarn lint + + - name: Check dotcom localizations + run: | + yarn build-i18n + git diff --exit-code --quiet ./apps/dotcom/client/public/tla/locales/ ./apps/dotcom/client/public/tla/locales-compiled/ || (echo "Please run yarn build-i18n and commit the changes" && exit 1) + + # vitest's fsModuleCache (~54MB gzipped). Entries are content-hashed, so a stale + # restore is safe - unmatched modules are just re-transformed. Keyed on yarn.lock so + # one entry is reused across commits instead of saving a new copy on every run. + - name: Restore vitest module cache + uses: actions/cache@v5 + with: + path: node_modules/.vitest-cache + key: vitest-cache-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: vitest-cache-${{ runner.os }}- + + - name: Test + run: yarn test-ci + + build: + name: 'Build all projects' + timeout-minutes: 15 + runs-on: ubuntu-latest-16-cores-arm-open + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - uses: ./.github/actions/setup + + - name: Restore build tool caches + uses: actions/cache@v5 + with: + path: | + .lazy + **/.lazy + **/.tsbuild + **/*.tsbuildinfo + key: build-tools-${{ runner.os }}-${{ hashFiles('yarn.lock', 'lazy.config.ts') }} + + - name: Build packages + # the sed pipe makes sure that github annotations come through without + # turbo's prefix + run: "yarn build-package | sed -E 's/^.*? ::/::/'" diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..54a5e40 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,52 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +permissions: + contents: read + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options + # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' diff --git a/.github/workflows/close-stale-issues.yml b/.github/workflows/close-stale-issues.yml new file mode 100644 index 0000000..e756725 --- /dev/null +++ b/.github/workflows/close-stale-issues.yml @@ -0,0 +1,37 @@ +name: Close inactive issues +on: + workflow_dispatch: + schedule: + - cron: '0 0 1 * *' + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + close-issues: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v10 + with: + days-before-issue-stale: 150 + days-before-issue-close: 30 + stale-issue-label: 'stale' + stale-issue-message: > + This issue has been marked as stale because it has been open for 150 days with no activity. + If there's no further discussion, it'll be closed in 30 days time. + If you would like to ensure it stays open, you can comment on the issue and revive the discussion. + Or, you can add the 'keep' label to prevent it from being closed. + close-issue-message: 'This issue was closed because it has been inactive for 30 days since being marked as stale.' + close-issue-reason: 'not_planned' + days-before-pr-stale: -1 + days-before-pr-close: -1 + operations-per-run: 1000 + exempt-issue-labels: keep + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/dependabot-dedupe.yml b/.github/workflows/dependabot-dedupe.yml new file mode 100644 index 0000000..f06521b --- /dev/null +++ b/.github/workflows/dependabot-dedupe.yml @@ -0,0 +1,41 @@ +name: Dedupe Dependabot PRs + +on: + push: + branches: ['dependabot/**'] + +permissions: + contents: write + pull-requests: write + repository-projects: write + +defaults: + run: + shell: bash + +jobs: + dedupe: + name: Dedupe Dependabot PRs + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - uses: ./.github/actions/setup + + - name: Configure Git + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + - name: Dedupe dependencies + run: yarn dedupe + + - name: Commit changes + run: | + git add . + git commit -m 'Dedupe' || true + + - name: Push changes + run: git push diff --git a/.github/workflows/deploy-analytics.yml b/.github/workflows/deploy-analytics.yml new file mode 100644 index 0000000..29fd57d --- /dev/null +++ b/.github/workflows/deploy-analytics.yml @@ -0,0 +1,55 @@ +name: Deploy analytics worker +permissions: + contents: read + deployments: write + +on: + pull_request: + paths: + - 'apps/analytics-worker/**' + - 'internal/scripts/deploy-analytics.ts' + - 'internal/scripts/lib/deploy.ts' + - '.github/workflows/deploy-analytics.yml' + - '.github/actions/setup/**' + push: + branches: + - main + - production + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + TLDRAW_ENV: ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} + +defaults: + run: + shell: bash + +jobs: + deploy: + name: Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} + timeout-minutes: 10 + runs-on: ubuntu-latest + environment: ${{ github.ref == 'refs/heads/production' && 'deploy-production' || 'deploy-staging' }} + concurrency: analytics-worker-${{ github.ref == 'refs/heads/production' && 'deploy-production' || github.ref }} + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + submodules: true + + - uses: ./.github/actions/setup + + - name: Build types + run: yarn build-types + + - name: Deploy + run: yarn tsx internal/scripts/deploy-analytics.ts + env: + RELEASE_COMMIT_HASH: ${{ github.sha }} + GH_TOKEN: ${{ github.token }} + + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} diff --git a/.github/workflows/deploy-bemo.yml b/.github/workflows/deploy-bemo.yml new file mode 100644 index 0000000..24f6020 --- /dev/null +++ b/.github/workflows/deploy-bemo.yml @@ -0,0 +1,61 @@ +name: Deploy bemo + +# TODO: add some sort of production trigger +on: + pull_request: + push: + branches: + - main + - bemo-production + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + TLDRAW_ENV: ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} +defaults: + run: + shell: bash + +permissions: + contents: read + # createGithubDeployment (internal/scripts/lib/deploy.ts) registers the deployment that + # renders the 'View Deployment' button on the PR. + deployments: write + +jobs: + deploy: + name: Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} + timeout-minutes: 15 + runs-on: ubuntu-latest + environment: ${{ github.ref == 'refs/heads/bemo-production' && 'bemo-production' || 'bemo-canary' }} + concurrency: bemo-${{ github.ref == 'refs/heads/bemo-production' && 'bemo-production' || github.ref }} + + steps: + # - name: Notify initial start + # uses: MineBartekSA/discord-webhook@v2 + # if: github.ref == 'refs/heads/bemo-production' + # with: + # webhook: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} + # content: 'Preparing ${{ env.TLDRAW_ENV }} bemo deploy: ${{ github.event.head_commit.message }} by ${{ github.event.head_commit.author.name }}' + + - name: Check out code + uses: actions/checkout@v6 + with: + submodules: true + + - uses: ./.github/actions/setup + + - name: Build types + run: yarn build-types + + - name: Deploy + run: yarn tsx internal/scripts/deploy-bemo.ts + env: + RELEASE_COMMIT_HASH: ${{ github.sha }} + GH_TOKEN: ${{ github.token }} + + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_BEMO_WORKER_DSN: ${{ secrets.SENTRY_BEMO_WORKER_DSN }} diff --git a/.github/workflows/deploy-dotcom.yml b/.github/workflows/deploy-dotcom.yml new file mode 100644 index 0000000..327fc7a --- /dev/null +++ b/.github/workflows/deploy-dotcom.yml @@ -0,0 +1,215 @@ +name: Deploy .com + +on: + pull_request: + types: [opened, synchronize, labeled, unlabeled] + push: + branches: + - main + - production + +permissions: + contents: read + # createGithubDeployment (internal/scripts/lib/deploy.ts) registers the deployment that + # renders the 'View Deployment' button on the PR. + deployments: write + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + TLDRAW_ENV: ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} + +defaults: + run: + shell: bash + +jobs: + deploy: + name: Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} + timeout-minutes: 15 + runs-on: ubuntu-latest-16-cores-arm-open + environment: ${{ github.ref == 'refs/heads/production' && 'deploy-production' || 'deploy-staging' }} + concurrency: dotcom-${{ github.ref == 'refs/heads/production' && 'deploy-production' || github.ref }} + if: | + github.event_name == 'push' || + (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'dotcom-preview-please')) + + steps: + # - name: Notify initial start + # uses: MineBartekSA/discord-webhook@v2 + # if: github.ref == 'refs/heads/production' + # with: + # webhook: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} + # content: 'Preparing ${{ env.TLDRAW_ENV }} dotcom deploy: ${{ github.event.head_commit.message }} by ${{ github.event.head_commit.author.name }}' + + - name: Check out code + uses: actions/checkout@v6 + env: + # Abort a stalled fetch so checkout's retry kicks in instead of hanging until the job timeout + GIT_HTTP_LOW_SPEED_LIMIT: 1000 + GIT_HTTP_LOW_SPEED_TIME: 60 + + - uses: ./.github/actions/setup + + - name: Determine zero deploy target + id: deploy_target + env: + GH_TOKEN: ${{ github.token }} + run: | + if [[ "${GITHUB_REF}" == "refs/heads/production" ]]; then + echo "DEPLOY_ZERO=flyio-multinode" >> $GITHUB_ENV + elif [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + echo "DEPLOY_ZERO=flyio-multinode" >> $GITHUB_ENV + else + echo "DEPLOY_ZERO=flyio" >> $GITHUB_ENV + fi + + - name: Install Supabase CLI + if: github.event_name == 'pull_request' + uses: supabase/setup-cli@ab058987d8d6c725971f6cf9d0b5c98467e30bd1 # v1 + with: + # Pinned: v2.112.0 rejects the Management API's own timestamps, which + # breaks `branches get` and `branches delete`. See supabase/cli#6115. + version: 2.111.0 + + - name: Delete Supabase branch + if: contains(github.event.pull_request.labels.*.name, 'reset-preview-db') + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + run: | + supabase branches delete "pr-${{ github.event.number }}" \ + --project-ref "${{ vars.SUPABASE_PREVIEW_PROJECT_ID }}" || echo "::warning::Failed to delete Supabase branch (may not exist yet)" + + - name: Create Supabase preview branch + if: github.event_name == 'pull_request' + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + run: | + BRANCH_NAME="pr-${{ github.event.number }}" + PROJECT_REF="${{ vars.SUPABASE_PREVIEW_PROJECT_ID }}" + + # Create branch if it doesn't already exist. + if supabase branches get "$BRANCH_NAME" \ + --project-ref "$PROJECT_REF" > /dev/null 2>&1; then + echo "Branch $BRANCH_NAME already exists." + else + supabase branches create "$BRANCH_NAME" --project-ref "$PROJECT_REF" > /dev/null + echo "Branch $BRANCH_NAME created." + fi + + # Wait for branch to be ready, then extract connection strings. + # Write to a temp file to avoid secrets leaking into CI step output. + BRANCH_JSON=$(mktemp) + for i in $(seq 1 24); do + # While the branch is still provisioning, `branches get` may exit + # non-zero and/or write a non-JSON line to stdout (the newer CLI + # renders an error there). Tolerate both: `|| true` keeps `set -e` + # from aborting on a non-zero get, and the guarded jq below treats + # any non-JSON / missing field as "not ready yet" so the loop retries. + supabase branches get "$BRANCH_NAME" \ + --project-ref "$PROJECT_REF" \ + -o json > "$BRANCH_JSON" 2>/dev/null || true + POSTGRES_URL_TRANSACTION=$(jq -r '.POSTGRES_URL // empty' "$BRANCH_JSON" 2>/dev/null || true) + if [ -n "$POSTGRES_URL_TRANSACTION" ]; then + echo "Branch $BRANCH_NAME is ready." + break + fi + echo "Waiting for branch to be ready ($i/24)..." + sleep 5 + done + POSTGRES_URL_NON_POOLING=$(jq -r '.POSTGRES_URL_NON_POOLING // empty' "$BRANCH_JSON" 2>/dev/null || true) + rm "$BRANCH_JSON" + + if [ -z "$POSTGRES_URL_TRANSACTION" ]; then + echo "::error::Branch not ready after 2 minutes — POSTGRES_URL still empty" + exit 1 + fi + + if [ -z "$POSTGRES_URL_NON_POOLING" ] || [ "$POSTGRES_URL_NON_POOLING" = "null" ]; then + echo "::error::Failed to get POSTGRES_URL_NON_POOLING from branch" + exit 1 + fi + + # Rewrite transaction pooler (port 6543, IPv6-only) to session pooler + # (port 5432, IPv4-compatible) since GitHub Actions runners are IPv4-only. + POSTGRES_URL="${POSTGRES_URL_TRANSACTION/:6543/:5432}" + + echo "::add-mask::$POSTGRES_URL" + echo "::add-mask::$POSTGRES_URL_TRANSACTION" + echo "::add-mask::$POSTGRES_URL_NON_POOLING" + echo "POSTGRES_URL=$POSTGRES_URL" >> "$GITHUB_ENV" + echo "POSTGRES_URL_NON_POOLING=$POSTGRES_URL_NON_POOLING" >> "$GITHUB_ENV" + + - name: Build types + run: yarn build-types + + - name: Setup Fly cli + if: env.DEPLOY_ZERO == 'flyio' || env.DEPLOY_ZERO == 'flyio-multinode' + uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6 + + - name: Deploy + run: yarn tsx internal/scripts/deploy-dotcom.ts + env: + RELEASE_COMMIT_HASH: ${{ github.sha }} + GH_TOKEN: ${{ github.token }} + + APP_ORIGIN: ${{ vars.APP_ORIGIN }} + ASSET_UPLOAD: ${{ vars.ASSET_UPLOAD }} + IMAGE_WORKER: ${{ vars.IMAGE_WORKER }} + MULTIPLAYER_SERVER: ${{ vars.MULTIPLAYER_SERVER }} + USER_CONTENT_SENTRY_DSN: ${{ secrets.USER_CONTENT_SENTRY_DSN }} + USER_CONTENT_URL: ${{ vars.USER_CONTENT_URL }} + NEXT_PUBLIC_GOOGLE_CLOUD_PROJECT_NUMBER: ${{ vars.NEXT_PUBLIC_GOOGLE_CLOUD_PROJECT_NUMBER }} + SUPABASE_LITE_URL: ${{ vars.SUPABASE_LITE_URL }} + VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ vars.VERCEL_DOTCOM_PROJECT_ID }} + + ANALYTICS_API_URL: ${{ secrets.ANALYTICS_API_URL }} + ANALYTICS_API_TOKEN: ${{ secrets.ANALYTICS_API_TOKEN }} + ASSET_UPLOAD_SENTRY_DSN: ${{ secrets.ASSET_UPLOAD_SENTRY_DSN }} + BOTCOM_POSTGRES_CONNECTION_STRING: ${{ env.POSTGRES_URL_NON_POOLING || secrets.BOTCOM_POSTGRES_CONNECTION_STRING }} + BOTCOM_POSTGRES_POOLED_CONNECTION_STRING: ${{ env.POSTGRES_URL || secrets.BOTCOM_POSTGRES_POOLED_CONNECTION_STRING }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + MCP_SCREENSHOT_TOKEN_SECRET: ${{ secrets.MCP_SCREENSHOT_TOKEN_SECRET }} + DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} + DISCORD_FEEDBACK_WEBHOOK_URL: ${{ secrets.DISCORD_FEEDBACK_WEBHOOK_URL }} + PLAIN_API_KEY: ${{ secrets.PLAIN_API_KEY }} + PLAIN_LABEL_TYPE_ID: ${{ secrets.PLAIN_LABEL_TYPE_ID }} + PLAIN_WORKSPACE_ID: ${{ secrets.PLAIN_WORKSPACE_ID }} + DISCORD_HEALTH_WEBHOOK_URL: ${{ secrets.DISCORD_HEALTH_WEBHOOK_URL }} + GC_MAPS_API_KEY: ${{ secrets.GC_MAPS_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + HEALTH_CHECK_BEARER_TOKEN: ${{ secrets.HEALTH_CHECK_BEARER_TOKEN }} + HEALTH_WORKER_UPDOWN_WEBHOOK_PATH: ${{ secrets.HEALTH_WORKER_UPDOWN_WEBHOOK_PATH }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_CSP_REPORT_URI: ${{ secrets.SENTRY_CSP_REPORT_URI }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SUPABASE_LITE_ANON_KEY: ${{ secrets.SUPABASE_LITE_ANON_KEY }} + TLDRAW_LICENSE: ${{ secrets.TLDRAW_LICENSE }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }} + VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }} + VITE_GA4_MEASUREMENT_ID: ${{ secrets.VITE_GA4_MEASUREMENT_ID }} + WORKER_SENTRY_DSN: ${{ secrets.WORKER_SENTRY_DSN }} + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + ZERO_ADMIN_PASSWORD: ${{ secrets.ZERO_ADMIN_PASSWORD }} + ZERO_R2_ENDPOINT: ${{ secrets.ZERO_R2_ENDPOINT }} + ZERO_R2_BUCKET_NAME: ${{ secrets.ZERO_R2_BUCKET_NAME }} + ZERO_R2_ACCESS_KEY_ID: ${{ secrets.ZERO_R2_ACCESS_KEY_ID }} + ZERO_R2_SECRET_ACCESS_KEY: ${{ secrets.ZERO_R2_SECRET_ACCESS_KEY }} + ZERO_OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.ZERO_OTEL_EXPORTER_OTLP_ENDPOINT }} + ZERO_OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.ZERO_OTEL_EXPORTER_OTLP_HEADERS }} + + # The deploy script posts its own "Deploy failed" message, but a job timeout + # kills it without running that handler, so cover the cancelled case here. + - name: Notify Discord on cancelled deploy + if: cancelled() && github.event_name == 'push' + uses: ./.github/actions/discord-fail-notify + with: + webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} + status: was cancelled (job timeout?) diff --git a/.github/workflows/deploy-grafana.yml b/.github/workflows/deploy-grafana.yml new file mode 100644 index 0000000..c8a807b --- /dev/null +++ b/.github/workflows/deploy-grafana.yml @@ -0,0 +1,115 @@ +name: Deploy grafana resources +permissions: + contents: read + +on: + pull_request: + paths: + - 'internal/grafana/**' + - '.github/workflows/deploy-grafana.yml' + push: + branches: + - main + paths: + - 'internal/grafana/**' + - '.github/workflows/deploy-grafana.yml' + +defaults: + run: + shell: bash + +env: + GCX_VERSION: 1.0.0 + GCX_SHA256: c88c65958d19d83dc3a192a2a5037e08307a9c776019f24249b89f369c9c4d41 + +jobs: + deploy: + name: ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} + # Validate and push both need the Grafana secrets, which fork PRs don't get. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 10 + runs-on: ubuntu-latest + concurrency: grafana-deploy-${{ github.event_name == 'push' && 'main' || github.ref }} + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Install gcx + run: | + curl -fsSL -o gcx.tar.gz "https://github.com/grafana/gcx/releases/download/v${GCX_VERSION}/gcx_${GCX_VERSION}_linux_amd64.tar.gz" + echo "${GCX_SHA256} gcx.tar.gz" | sha256sum -c - + tar xzf gcx.tar.gz gcx + sudo mv gcx /usr/local/bin/gcx + gcx --version + + - name: Validate resources + run: gcx resources validate -p internal/grafana/resources + env: + GRAFANA_SERVER: ${{ secrets.GRAFANA_SERVER }} + GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }} + + - name: Dry run + if: github.event_name == 'pull_request' + run: gcx resources push -p internal/grafana/resources --dry-run --on-error abort + env: + GRAFANA_SERVER: ${{ secrets.GRAFANA_SERVER }} + GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }} + + # Everything except alert rules, folders first. Two orderings matter and neither is + # the alphabetical one this glob would otherwise give: folders have to exist before + # anything annotated into them, and alert rules go last (see the step below for why). + # A commit adding a folder and a dashboard inside it lands both in one push, so the + # folder cannot be assumed to already be there. + - name: Push resources + if: github.event_name == 'push' + run: | + shopt -s nullglob + for dir in internal/grafana/resources/*folders*/; do + gcx resources push -p "$dir" --on-error abort + done + for dir in internal/grafana/resources/*/; do + case "$dir" in *folders*|*alertrules*) continue ;; esac + gcx resources push -p "$dir" --on-error abort + done + env: + GRAFANA_SERVER: ${{ secrets.GRAFANA_SERVER }} + GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }} + + # Alert rules are the one resource type that can fail here having passed the PR + # dry run, because alerting has no server-side dry-run — the PR reports them as + # "skipped". A newly created rule carrying group labels is rejected outright + # ("cannot set group when creating a new rule"), so the first time any new rule + # lands, this step is where it surfaces. Kept last and separate so that failure + # costs the rule, not every dashboard in the tree. Still fails the job. + - name: Push alert rules + if: github.event_name == 'push' + run: | + shopt -s nullglob + for dir in internal/grafana/resources/*alertrules*/; do + gcx resources push -p "$dir" --on-error abort + done + env: + GRAFANA_SERVER: ${{ secrets.GRAFANA_SERVER }} + GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }} + + # Notification templates can't go through `gcx resources push` — gcx's discovery + # registry ignores the notifications.alerting.grafana.app API group entirely — so + # template files live in templates/ (not resources/) and go through the classic + # provisioning API. The PUT is idempotent and sets `api` provenance, which locks + # UI edits the same way provisioned resources are locked. + - name: Push notification templates + if: github.event_name == 'push' + run: | + shopt -s nullglob + for f in internal/grafana/templates/*.gotmpl; do + name=$(basename "$f" .gotmpl) + jq -Rs '{template: .}' "$f" | curl -fsS -X PUT \ + "$GRAFANA_SERVER/api/v1/provisioning/templates/$name" \ + -H "Authorization: Bearer $GRAFANA_TOKEN" \ + -H "Content-Type: application/json" \ + -d @- > /dev/null + done + env: + GRAFANA_SERVER: ${{ secrets.GRAFANA_SERVER }} + GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }} diff --git a/.github/workflows/deploy-mcp-app.yml b/.github/workflows/deploy-mcp-app.yml new file mode 100644 index 0000000..24d1799 --- /dev/null +++ b/.github/workflows/deploy-mcp-app.yml @@ -0,0 +1,63 @@ +name: Deploy MCP app + +on: + # Auto-deploy whenever production is updated, keeping the public MCP worker on + # the same commit and SDK build as production dotcom. Only production triggers a + # deploy: there is no staging worker, so a push to main must not overwrite the + # shared `tldraw-mcp-app` worker. + push: + branches: + - production + workflow_dispatch: + inputs: + target: + description: 'Target ref to deploy' + required: true + default: 'production' + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + TLDRAW_ENV: production + TARGET: ${{ github.event.inputs.target || github.ref_name }} + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + deploy: + name: Deploy MCP app to production + timeout-minutes: 10 + runs-on: ubuntu-latest + environment: deploy-production + concurrency: mcp-app-deploy-production + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + ref: ${{ env.TARGET }} + submodules: true + fetch-depth: 0 + + - uses: ./.github/actions/setup + + - name: Build types + run: yarn build-types + + - name: Deploy + run: yarn workspace mcp-app deploy + env: + VITE_TLDRAW_LICENSE_KEY: ${{ secrets.MCP_APP_TLDRAW_LICENSE_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + + - name: Notify Discord on failure + if: failure() + uses: ./.github/actions/discord-fail-notify + with: + webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} diff --git a/.github/workflows/deploy-templates.yml b/.github/workflows/deploy-templates.yml new file mode 100644 index 0000000..6eee2b2 --- /dev/null +++ b/.github/workflows/deploy-templates.yml @@ -0,0 +1,57 @@ +name: Deploy templates + +# Deploys the cloudflare-hosted starter kit demos at *.templates.tldraw.dev, which are the live +# embeds on the docs pages. Two things should reach them: a change to a template (so edits go live +# without waiting for a release), and a release (so the demos pick up the new SDK — an SDK bump +# never touches templates/, so the path filter alone would never fire for one). This workflow +# covers the first; publish.yml covers the second with a job of its own, using the same +# .github/actions/deploy-templates steps. +# +# The vercel-hosted demos (chat, shader, workflow) deploy through vercel's own git integration and +# aren't handled here. +on: + push: + branches: + - main + paths: + - 'templates/**' + workflow_dispatch: + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + deploy: + name: Deploy templates + # CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are secrets on this environment rather than on + # the repository, so the job has to select it to see them at all. + environment: deploy-production + timeout-minutes: 60 + runs-on: ubuntu-latest-16-cores-arm-open + # a release deploy and a template-change deploy can otherwise race each other onto the same + # custom domains, and the loser silently wins. + concurrency: + group: deploy-templates + cancel-in-progress: false + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/deploy-templates + with: + license-key: ${{ secrets.TEMPLATES_TLDRAW_LICENSE_KEY }} + cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + + - name: Notify Discord on failure + if: failure() + uses: ./.github/actions/discord-fail-notify + with: + webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} diff --git a/.github/workflows/e2e-device.yml b/.github/workflows/e2e-device.yml new file mode 100644 index 0000000..b9b3b5d --- /dev/null +++ b/.github/workflows/e2e-device.yml @@ -0,0 +1,183 @@ +name: End to end tests (device) + +# Run-on-request device-realistic e2e tests. +# +# These run real Mobile Safari (iOS Simulator) and real Chrome (Android emulator) +# through the OS input pipeline via Appium + WebdriverIO. Unlike the Playwright +# suite, this exercises the browser's own gesture recogniser and platform +# behaviour, so it can catch regressions that synthetic DOM events (CDP) cannot. +# It currently covers multi-touch gestures (pinch), but the harness is meant to +# grow to other device-specific behaviour (exports, clipboard, shortcuts, etc.) +# and other platforms. +# +# It is slower and a bit flaky by nature, so it is NOT part of the per-PR checks. +# Trigger it on demand: +# +# - Manually from the Actions tab (workflow_dispatch), choosing the platform. +# - By adding the `e2e-device-tests` label to a PR that touches device behaviour. + +on: + workflow_dispatch: + inputs: + platform: + description: Which platform(s) to run + type: choice + default: both + options: [both, ios, android] + ios_device: + description: iOS simulator device name (optional; auto-picks newest iPhone if empty) + type: string + default: '' + pull_request: + types: [labeled] + +env: + CI: 1 + EXAMPLES_PORT: '5420' + +defaults: + run: + shell: bash + +# A new push to a PR (or a re-dispatch) cancels the previous in-flight run. +concurrency: + group: e2e-device-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + ios: + name: iOS Safari + # Run when dispatched with ios/both, or when a PR is labelled `e2e-device-tests`. + if: > + (github.event_name == 'workflow_dispatch' && inputs.platform != 'android') || + (github.event_name == 'pull_request' && github.event.label.name == 'e2e-device-tests') + runs-on: macos-26 # pinned: image version determines Xcode + iOS sim runtimes + timeout-minutes: 40 + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Run our setup + uses: ./.github/actions/setup + + - name: Build examples + run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && cd apps/examples && yarn build + + - name: Install Appium and the XCUITest driver + run: | + npm i -g appium@3 + appium driver install xcuitest + + - name: Resolve Xcode version (WDA cache key) + id: xcode + run: echo "version=$(xcodebuild -version | head -1 | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Cache WebDriverAgent build + # WDA is rebuilt from source on a cold simulator (~3.5 min). Cache its + # DerivedData keyed on the Xcode version so iterating on test fixes reuses + # the prebuilt WDA instead of rebuilding. Bump the trailing -vN to bust. + id: wda-cache + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/wda-derived + key: wda-${{ runner.os }}-xcode-${{ steps.xcode.outputs.version }}-v1 + + - name: Boot iOS simulator + id: sim + # A fresh iOS simulator's first boot runs slow one-time migration plugins + # that exceed XCUITest's boot-wait. So boot ONE simulator here, wait for it + # to finish (`bootstatus -b`), and hand its UDID to Appium so it attaches to + # the ready device instead of booting its own. The available iPhone models + # depend on the Xcode the runner image ships, so auto-pick rather than + # hardcode a name (an explicit `ios_device` input wins if it matches). + env: + DEVICE_NAME: ${{ inputs.ios_device }} + run: | + xcrun simctl list devices available + SEL=$(xcrun simctl list devices available --json | node -e ' + const data = JSON.parse(require("fs").readFileSync(0, "utf8")); + const want = process.env.DEVICE_NAME || ""; + const runtimes = Object.keys(data.devices).filter(r => r.includes("iOS")).sort().reverse(); + const phones = []; + for (const r of runtimes) for (const d of (data.devices[r] || [])) { + if (d.isAvailable !== false && d.name.startsWith("iPhone")) phones.push(d); + } + const pick = (want && phones.find(d => d.name === want)) || phones[0]; + if (!pick) process.exit(2); + process.stdout.write(pick.udid + "|" + pick.name); + ') || { echo "No available iPhone simulator on this runner"; exit 1; } + UDID="${SEL%%|*}" + NAME="${SEL#*|}" + echo "Using $NAME ($UDID)" + xcrun simctl boot "$UDID" || true + xcrun simctl bootstatus "$UDID" -b + echo "udid=$UDID" >> "$GITHUB_OUTPUT" + echo "name=$NAME" >> "$GITHUB_OUTPUT" + + - name: Serve built examples + run: yarn preview --port "$EXAMPLES_PORT" & + working-directory: apps/examples + + - name: Wait for examples server + run: npx --yes wait-on "http://localhost:$EXAMPLES_PORT/end-to-end" -t 120000 + + - name: Run device tests (iOS) + run: yarn e2e-device-ios + working-directory: apps/examples + env: + APPIUM_DEVICE_NAME: ${{ steps.sim.outputs.name }} + APPIUM_UDID: ${{ steps.sim.outputs.udid }} + # Build WDA into the cached dir; reuse the prebuilt one on a cache hit. + APPIUM_DERIVED_DATA_PATH: ${{ runner.temp }}/wda-derived + APPIUM_USE_PREBUILT_WDA: ${{ steps.wda-cache.outputs.cache-hit }} + + android: + name: Android Chrome + if: > + (github.event_name == 'workflow_dispatch' && inputs.platform != 'ios') || + (github.event_name == 'pull_request' && github.event.label.name == 'e2e-device-tests') + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Run our setup + uses: ./.github/actions/setup + + - name: Enable KVM (hardware acceleration for the emulator) + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Build examples + run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && cd apps/examples && yarn build + + - name: Install Appium and the UiAutomator2 driver + run: | + npm i -g appium@3 + appium driver install uiautomator2 + + - name: Serve built examples + run: yarn preview --port "$EXAMPLES_PORT" & + working-directory: apps/examples + + - name: Wait for examples server + run: npx --yes wait-on "http://localhost:$EXAMPLES_PORT/end-to-end" -t 120000 + + - name: Run device tests on emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + arch: x86_64 + target: google_apis + # The emulator's `localhost` is the emulator itself, so forward the host + # dev server into it before running the tests. + script: | + adb reverse tcp:${{ env.EXAMPLES_PORT }} tcp:${{ env.EXAMPLES_PORT }} + cd apps/examples && yarn e2e-device-android diff --git a/.github/workflows/get-changelog.yml b/.github/workflows/get-changelog.yml new file mode 100644 index 0000000..072737e --- /dev/null +++ b/.github/workflows/get-changelog.yml @@ -0,0 +1,35 @@ +name: Get draft changelog + +on: + workflow_dispatch: + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + deploy: + name: 'Get draft changlog' + timeout-minutes: 60 + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Run our setup + uses: ./.github/actions/setup + + - name: Extract changelog + run: yarn extract-draft-changelog + + - name: Upload changelog.md to artifacts + uses: actions/upload-artifact@v6 + with: + name: changelog-output + path: changelog.md diff --git a/.github/workflows/i18n-download-strings.yml b/.github/workflows/i18n-download-strings.yml new file mode 100644 index 0000000..dad79fa --- /dev/null +++ b/.github/workflows/i18n-download-strings.yml @@ -0,0 +1,73 @@ +name: i18n download strings +on: + workflow_dispatch: + schedule: + - cron: '0 10 * * 1' + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + download: + name: 'Download latest strings' + runs-on: ubuntu-latest + environment: 'deploy-production' + + permissions: + # Give the default GITHUB_TOKEN write permission to commit and push the + # added or changed files to the repository. + contents: write + pull-requests: write + repository-projects: write + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: ./.github/actions/setup + + - name: Download i18n strings + run: 'yarn i18n-download-strings' + env: + LOKALISE_API_TOKEN: ${{ secrets.LOKALISE_API_TOKEN }} + LOKALISE_PROJECT_ID: ${{ secrets.LOKALISE_PROJECT_ID }} + LOKALISE_TLDRAW_PROJECT_ID: ${{ secrets.LOKALISE_TLDRAW_PROJECT_ID }} + + - name: Compile i18n strings + run: 'yarn build-i18n' + + - name: Validate the strings + run: 'yarn i18n-check -f react-intl --locales ./apps/dotcom/client/public/tla/locales/ --source en --only invalidKeys' + + - name: Commit and push changes + env: + GH_TOKEN: ${{ github.token }} + run: | + git config --global user.name 'huppy-bot[bot]' + git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com' + + # Check if there are any changes to JSON files + if ! git diff --quiet '*.json' || ! git diff --cached --quiet '*.json'; then + echo "Changes detected, creating PR..." + else + echo "No changes to commit" + exit 0 + fi + + # Delete the branch if it already exists + git push origin --delete update-i18n-strings 2>/dev/null || true + + git checkout -b update-i18n-strings + git add "*.json" + git commit --no-verify -m '[automated] update i18n strings' + git push --set-upstream origin update-i18n-strings + gh pr create --title "[automated] update i18n strings" --body "This PR updates the i18n strings. + + ### Change type + - [x] \`other\`" --base main --head update-i18n-strings diff --git a/.github/workflows/i18n-upload-strings.yml b/.github/workflows/i18n-upload-strings.yml new file mode 100644 index 0000000..2ad3187 --- /dev/null +++ b/.github/workflows/i18n-upload-strings.yml @@ -0,0 +1,35 @@ +name: i18n upload strings +on: + workflow_dispatch: + push: + branches: [main] + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + upload: + name: 'Upload latest strings' + runs-on: ubuntu-latest + environment: 'deploy-production' + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: ./.github/actions/setup + + - name: Upload i18n strings + run: 'yarn i18n-upload-strings' + env: + LOKALISE_API_TOKEN: ${{ secrets.LOKALISE_API_TOKEN }} + LOKALISE_PROJECT_ID: ${{ secrets.LOKALISE_PROJECT_ID }} + LOKALISE_TLDRAW_PROJECT_ID: ${{ secrets.LOKALISE_TLDRAW_PROJECT_ID }} + LOKALISE_TEAM_ID: ${{ secrets.LOKALISE_TEAM_ID }} + LOKALISE_CARD_ID: ${{ secrets.LOKALISE_CARD_ID }} diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 0000000..846b25a --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,122 @@ +name: Issue Triage + +on: + issues: + types: [opened] + workflow_dispatch: + inputs: + issue_number: + description: 'Issue number to triage (for testing)' + required: true + type: number + +permissions: + contents: read + +jobs: + triage: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + id-token: write + env: + ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Get issue details + id: issue + env: + GH_TOKEN: ${{ github.token }} + run: | + gh issue view ${{ env.ISSUE_NUMBER }} --json title,body,labels,state > /tmp/issue.json + # Fetch issue type via API (not yet supported in gh CLI) + ISSUE_TYPE=$(gh api repos/${{ github.repository }}/issues/${{ env.ISSUE_NUMBER }} --jq '.type.name // "none"') + # Merge the type into the JSON + jq --arg type "$ISSUE_TYPE" '. + {issueType: $type}' /tmp/issue.json > /tmp/issue_with_type.json + echo "details<> $GITHUB_OUTPUT + cat /tmp/issue_with_type.json >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Triage Issue with Claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + You are an issue triage bot for the tldraw repository. Tidy up issue #${{ env.ISSUE_NUMBER }}. + + ## Issue details + ```json + ${{ steps.issue.outputs.details }} + ``` + + ## Tasks + + ### 1. Improve the title (if needed) + - Make sure the title is clear and descriptive + - Use sentence case (capitalize only first word and proper nouns) + - For bugs: describe the symptom (e.g., "Arrow bindings break with rotated shapes") + - For features: use imperative mood (e.g., "Add padding option to zoomToFit") + - If the title is already good, leave it as-is + + ### 2. Improve the body (if needed) + - If the issue is written in a language other than English, add an English translation at the top of the body (keep the original text below) + - Make sure the body has enough context to understand the issue + - If the body is already good, leave it as-is + + To update the title or body: + ```bash + gh issue edit ${{ env.ISSUE_NUMBER }} --title "New title" --body "New body" + ``` + + ### 3. Set the issue type + Determine if this is a: + - **Bug**: Something is broken or not working as designed (crashes, rendering errors, data loss) + - **Feature**: A request for new functionality or enhancements (including UX preferences like "I wish X worked like Y") + - **Task**: Internal team task + - **Example**: Request for a new SDK example + + Important: Distinguish between TRUE bugs and UX preferences disguised as bugs: + - TRUE BUG: "Clicking save crashes the app" or "Text doesn't render correctly" + - NOT A BUG (use Feature): "I don't like the color of the button" or "The menu should be on the left instead of right" + + To set or change the issue type: + ```bash + gh api repos/${{ github.repository }}/issues/${{ env.ISSUE_NUMBER }} -X PATCH -f type="Bug" + ``` + Replace "Bug" with the appropriate type (Bug, Feature, Task, or Example). + + ### 4. Apply appropriate labels + Use these labels as appropriate: + - `sdk` - Affects the tldraw SDK (@tldraw/tldraw package) + - `dotcom` - Related to tldraw.com + - `docs` - Changes only affect documentation + - `examples` - Request for a new SDK example + - `performance` - Performance-related issues + - `a11y` - Accessibility-related issues + - `bugfix` - For confirmed bug fixes + - `feature` - For new features + - `improvement` - For product improvements + - `More Info Needed` - If the issue lacks critical information needed to understand or reproduce + + ### 5. Assign a milestone (if appropriate) + If the issue clearly fits one of these milestones, assign it. Otherwise, leave the milestone empty. + + Available milestones: + - **Improve developer resources**: For examples, documentation, improved code comments, starter kits, and `npm create tldraw` improvements + - **Improve automations**: For GitHub Actions, review bots, CI/CD, and other automation improvements + + To assign a milestone: + ```bash + gh issue edit ${{ env.ISSUE_NUMBER }} --milestone "Milestone Name" + ``` + + Only assign a milestone if there's a clear fit. Most issues won't need a milestone. + + ## Output format + Use the GitHub CLI (`gh`) to make any changes. Do NOT add comments to the issue. + claude_args: '--allowedTools "Bash(gh issue:*),Bash(gh api:*)"' diff --git a/.github/workflows/license-report.yml b/.github/workflows/license-report.yml new file mode 100644 index 0000000..808e1be --- /dev/null +++ b/.github/workflows/license-report.yml @@ -0,0 +1,52 @@ +name: Update license report + +# Skipped on PRs from forks: the push below needs write access to the head branch. + +permissions: + contents: write + +on: + pull_request: + paths: + - '**/package.json' + - 'yarn.lock' + - 'internal/scripts/license-report.ts' + - '.github/workflows/license-report.yml' + +env: + CI: 1 + +defaults: + run: + shell: bash + +jobs: + update: + name: 'Update license report' + if: github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 15 + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + # `pull_request` checks out a detached merge commit, which we can't push back to. + ref: ${{ github.event.pull_request.head.ref }} + + - uses: ./.github/actions/setup + + - name: Configure Git + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + - name: Generate license report + run: yarn license-report --prod + + - name: Commit changes + run: | + git add apps/docs/content/community/dependencies.mdx + git diff --cached --quiet || git commit -m 'Update license report' + git push diff --git a/.github/workflows/playwright-dotcom.yml b/.github/workflows/playwright-dotcom.yml new file mode 100644 index 0000000..642957e --- /dev/null +++ b/.github/workflows/playwright-dotcom.yml @@ -0,0 +1,129 @@ +name: End to end tests (dotcom) + +on: + pull_request: + merge_group: + push: + branches: [main] + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + TLDRAW_ENV: 'development' + # process-compose (the dev-app orchestrator) has no TTY in CI — run it headless. + PC_DISABLE_TUI: '1' + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + check: + name: 'Check for relevant changes' + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.head.repo.fork == false }} + outputs: + relevant: ${{ steps.includes.outputs.match == 'true' && steps.nondocs.outputs.match == 'true' }} + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Check for changes to relevant paths + uses: dorny/paths-filter@v3 + id: includes + with: + filters: | + match: + - 'packages/**' + - 'apps/dotcom/**' + - '.github/workflows/playwright-dotcom.yml' + - '.github/actions/**' + - 'yarn.lock' + + - name: Check for any non-doc changes + uses: dorny/paths-filter@v3 + id: nondocs + with: + predicate-quantifier: 'every' + filters: | + match: + - '**' + - '!**/*.md' + - '!**/*.mdx' + + build: + name: 'End to end tests (dotcom)' + needs: check + if: needs.check.outputs.relevant == 'true' + timeout-minutes: 20 + runs-on: ubuntu-latest-8-cores-open + environment: 'e2e-dotcom' + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Run our setup + uses: ./.github/actions/setup + + # No process-compose install step: `yarn dev-app` (the e2e webServer) calls the `process-compose` + # bin of @tldraw/scripts, which fetches the pinned binary on first use. + + - name: Set up Playwright + uses: ./.github/actions/setup-playwright + + - name: Build dotcom client + run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && yarn run -T lazy run build-i18n && cd apps/dotcom/client && yarn build + env: + VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }} + + - name: Run Playwright tests + run: | + echo "CLERK_PUBLISHABLE_KEY=${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}" > apps/dotcom/sync-worker/.dev.vars + echo "CLERK_SECRET_KEY=${{ secrets.CLERK_SECRET_KEY }}" >> apps/dotcom/sync-worker/.dev.vars + yarn e2e-dotcom + env: + VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + if: ${{!contains(github.event.pull_request.labels.*.name, 'e2e-x10') }} + + - name: Run Playwright tests (x10) + run: | + echo "CLERK_PUBLISHABLE_KEY=${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}" > apps/dotcom/sync-worker/.dev.vars + echo "CLERK_SECRET_KEY=${{ secrets.CLERK_SECRET_KEY }}" >> apps/dotcom/sync-worker/.dev.vars + yarn e2e-dotcom-x10 + env: + VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + if: contains(github.event.pull_request.labels.*.name, 'e2e-x10') + + - uses: actions/upload-artifact@v6 + if: always() + with: + name: playwright-report + path: apps/dotcom/client/playwright-report + retention-days: 30 + + - uses: shallwefootball/s3-upload-action@0d261a6f15b3b2e209dfebdecace4b100c04f95b # master + # only upload if we have AWS credentials: + env: + HAS_AWS_KEY: ${{ secrets.AWS_S3_SECRET_KEY && '1' || '' }} + if: always() && env.HAS_AWS_KEY == '1' + name: Upload S3 + id: s3 + with: + aws_key_id: ${{ secrets.AWS_S3_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_S3_SECRET_KEY}} + aws_bucket: playwright.tldraw.xyz + source_dir: apps/dotcom/client/playwright-report + + - name: Log report to summary + if: always() + run: | + report_url="https://playwright.tldraw.xyz/${{ steps.s3.outputs.object_key }}/index.html" + echo "Report: $report_url" + echo "## Playwright report" >> $GITHUB_STEP_SUMMARY + echo "* $report_url" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/playwright-examples.yml b/.github/workflows/playwright-examples.yml new file mode 100644 index 0000000..fc41be7 --- /dev/null +++ b/.github/workflows/playwright-examples.yml @@ -0,0 +1,106 @@ +name: End to end tests (examples) + +on: + pull_request: + merge_group: + push: + branches: [main] + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + check: + name: 'Check for relevant changes' + runs-on: ubuntu-latest + outputs: + relevant: ${{ steps.includes.outputs.match == 'true' && steps.nondocs.outputs.match == 'true' }} + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Check for changes to relevant paths + uses: dorny/paths-filter@v3 + id: includes + with: + filters: | + match: + - 'packages/**' + - 'apps/examples/**' + - '.github/workflows/playwright-examples.yml' + - '.github/actions/**' + - 'yarn.lock' + + - name: Check for any non-doc changes + uses: dorny/paths-filter@v3 + id: nondocs + with: + predicate-quantifier: 'every' + filters: | + match: + - '**' + - '!**/*.md' + - '!**/*.mdx' + + build: + name: 'End to end tests (examples)' + needs: check + if: needs.check.outputs.relevant == 'true' + timeout-minutes: 20 + runs-on: ubuntu-latest-32-cores-open + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Run our setup + uses: ./.github/actions/setup + + - name: Set up Playwright + uses: ./.github/actions/setup-playwright + + - name: Build examples + run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && cd apps/examples && yarn build + + - name: Run Playwright tests + run: 'yarn e2e' + working-directory: apps/examples + env: + GITHUB_REF_NAME: ${{ github.ref_name }} + GIT_COMMIT: ${{ github.sha }} + + - uses: actions/upload-artifact@v6 + if: always() + with: + name: playwright-report + path: apps/examples/playwright-report + retention-days: 30 + + - uses: shallwefootball/s3-upload-action@0d261a6f15b3b2e209dfebdecace4b100c04f95b # master + # only upload if we have AWS credentials: + env: + HAS_AWS_KEY: ${{ secrets.AWS_S3_SECRET_KEY && '1' || '' }} + if: always() && env.HAS_AWS_KEY == '1' + name: Upload S3 + id: s3 + with: + aws_key_id: ${{ secrets.AWS_S3_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_S3_SECRET_KEY}} + aws_bucket: playwright.tldraw.xyz + source_dir: apps/examples/playwright-report + + - name: Log report to summary + if: always() + run: | + report_url="https://playwright.tldraw.xyz/${{ steps.s3.outputs.object_key }}/index.html" + echo "Report: $report_url" + echo "## Playwright report" >> $GITHUB_STEP_SUMMARY + echo "* $report_url" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/playwright-mermaid.yml b/.github/workflows/playwright-mermaid.yml new file mode 100644 index 0000000..7e4b439 --- /dev/null +++ b/.github/workflows/playwright-mermaid.yml @@ -0,0 +1,71 @@ +name: End to end tests (mermaid readability) + +on: + pull_request: + merge_group: + push: + branches: [main] + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + +defaults: + run: + shell: bash + +jobs: + check: + name: 'Check for relevant changes' + runs-on: ubuntu-latest + outputs: + relevant: ${{ steps.includes.outputs.match == 'true' }} + steps: + - name: Check out code + uses: actions/checkout@v6 + + # Only mermaid conversion changes can affect these checks, unlike the rest of the e2e suites. + - name: Check for changes to relevant paths + uses: dorny/paths-filter@v3 + id: includes + with: + filters: | + match: + - 'packages/mermaid/**' + - 'apps/examples/src/examples/use-cases/hundred-mermaids/**' + - 'apps/examples/src/misc/mermaidReadability.ts' + - 'apps/examples/e2e/mermaid-readability/**' + - '.github/workflows/playwright-mermaid.yml' + - '.github/actions/**' + - 'yarn.lock' + + build: + name: 'End to end tests (mermaid readability)' + needs: check + if: needs.check.outputs.relevant == 'true' + timeout-minutes: 20 + runs-on: ubuntu-latest-16-cores-open + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Run our setup + uses: ./.github/actions/setup + + - name: Set up Playwright + uses: ./.github/actions/setup-playwright + + - name: Build examples + run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && cd apps/examples && yarn build + + - name: Run Playwright tests (mermaid readability) + run: 'yarn e2e-mermaid-readability' + working-directory: apps/examples + + - uses: actions/upload-artifact@v6 + if: always() + with: + name: playwright-report + path: apps/examples/playwright-report + retention-days: 30 diff --git a/.github/workflows/playwright-perf.yml b/.github/workflows/playwright-perf.yml new file mode 100644 index 0000000..448e025 --- /dev/null +++ b/.github/workflows/playwright-perf.yml @@ -0,0 +1,107 @@ +name: End to end tests (perf) + +on: + pull_request: + merge_group: + push: + branches: [main] + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + check: + name: 'Check for relevant changes' + runs-on: ubuntu-latest + outputs: + relevant: ${{ steps.includes.outputs.match == 'true' && steps.nondocs.outputs.match == 'true' }} + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Check for changes to relevant paths + uses: dorny/paths-filter@v3 + id: includes + with: + filters: | + match: + - 'packages/**' + - 'apps/examples/**' + - '.github/workflows/playwright-perf.yml' + - '.github/actions/**' + - 'yarn.lock' + + - name: Check for any non-doc changes + uses: dorny/paths-filter@v3 + id: nondocs + with: + predicate-quantifier: 'every' + filters: | + match: + - '**' + - '!**/*.md' + - '!**/*.mdx' + + build: + name: 'End to end tests (perf)' + needs: check + if: needs.check.outputs.relevant == 'true' + timeout-minutes: 20 + runs-on: ubuntu-latest-16-cores-open + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Run our setup + uses: ./.github/actions/setup + + - name: Set up Playwright + uses: ./.github/actions/setup-playwright + + - name: Build examples + run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && cd apps/examples && yarn build + + - name: Run Playwright tests (perf) + run: 'yarn e2e-perf' + working-directory: apps/examples + env: + PERFORMANCE_ANALYTICS_ENABLED: true + GITHUB_REF_NAME: ${{ github.ref_name }} + GIT_COMMIT: ${{ github.sha }} + + - uses: actions/upload-artifact@v6 + if: always() + with: + name: playwright-report + path: apps/examples/playwright-report + retention-days: 30 + + - uses: shallwefootball/s3-upload-action@0d261a6f15b3b2e209dfebdecace4b100c04f95b # master + # only upload if we have AWS credentials: + env: + HAS_AWS_KEY: ${{ secrets.AWS_S3_SECRET_KEY && '1' || '' }} + if: always() && env.HAS_AWS_KEY == '1' + name: Upload S3 + id: s3 + with: + aws_key_id: ${{ secrets.AWS_S3_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_S3_SECRET_KEY}} + aws_bucket: playwright.tldraw.xyz + source_dir: apps/examples/playwright-report + + - name: Log report to summary + if: always() + run: | + report_url="https://playwright.tldraw.xyz/${{ steps.s3.outputs.object_key }}/index.html" + echo "Report: $report_url" + echo "## Playwright report" >> $GITHUB_STEP_SUMMARY + echo "* $report_url" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/playwright-update-snapshots.yml b/.github/workflows/playwright-update-snapshots.yml new file mode 100644 index 0000000..4f6c09b --- /dev/null +++ b/.github/workflows/playwright-update-snapshots.yml @@ -0,0 +1,84 @@ +# Adapted from https://mmazzarolo.com/blog/2022-09-09-visual-regression-testing-with-playwright-and-github-actions/ + +# This workflow's goal is forcing an update of the reference snapshots used +# by Playwright tests. Add the 'update-snapshots' label to a PR. +# From a high-level perspective, it works like this: +# 1. Because of a GitHub Action limitation, this workflow is triggered on every +# label added to a PR. We manually interrupt it unless the label is +# "update-snapshots". +# 2. Use the GitHub API to grab the information about the branch name and SHA of +# the latest commit of the current pull request. +# 3. Remove the label from the PR. +# 4. Update the Playwright reference snapshots based on the UI of this branch. +# 5. Commit the newly generated Playwright reference snapshots into this branch. +name: Update playwright snapshots + +on: + pull_request: + types: [labeled] + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + update_snapshots: + name: 'Run' + timeout-minutes: 60 + runs-on: ubuntu-latest-8-cores-open + if: github.event.label.name == 'update-snapshots' + + permissions: + # Give the default GITHUB_TOKEN write permission to commit and push the + # added or changed files to the repository. + contents: write + pull-requests: write + + steps: + - name: Remove the update-snapshots label + uses: actions-ecosystem/action-remove-labels@v1 + with: + labels: update-snapshots + + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 5 + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.event.pull_request.head.ref }} + + - name: Run our setup + uses: ./.github/actions/setup + + - name: Set up Playwright + uses: ./.github/actions/setup-playwright + + - name: Build examples + run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && cd apps/examples && yarn build + + - name: Run Playwright tests & update snapshots + run: 'yarn e2e --update-snapshots' + working-directory: 'apps/examples' + + - name: Commit and push changes + if: always() + run: | + set -eux + git config --global user.name 'huppy-bot[bot]' + git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com' + git add -A + if git diff --cached --quiet; then + echo 'No snapshot changes to commit.' + exit 0 + fi + git commit --no-verify -m '[automated] update snapshots' + git add -A && git reset --hard HEAD # we sometimes get a dirty tree because husky makes changes for some reason + git pull --rebase + git push diff --git a/.github/workflows/post-release-crew.yml b/.github/workflows/post-release-crew.yml new file mode 100644 index 0000000..6a54fc8 --- /dev/null +++ b/.github/workflows/post-release-crew.yml @@ -0,0 +1,56 @@ +name: Post release crew to Discord + +# Every Wednesday at 07:07 UTC, post to #development naming the people whose critical +# fixes / significant features are in this week's dotcom release. +# GitHub cron is UTC-only. We use a single fixed UTC time (no BST/GMT adjustment) so +# the job never fires twice: 07:07 UTC is 07:07 in London during winter (GMT) and +# 08:07 during summer (BST). GitHub often starts scheduled runs 15-60 minutes late, +# so this is scheduled early (and off the congested :00/:30 slots) to land before +# the working day ramps up. To change the day, edit the day-of-week field +# (`3` = Wednesday) in the cron line. + +on: + schedule: + - cron: '7 7 * * 3' # Wednesday 07:07 UTC + workflow_dispatch: # allow manual runs + +concurrency: + group: post-release-crew + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + post-release-crew: + name: Post release crew to Discord + timeout-minutes: 20 + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Fail fast if the Discord webhook is not configured + run: | + if [ -z "$DISCORD_RELEASE_WEBHOOK_URL" ]; then + echo "::error::DISCORD_RELEASE_WEBHOOK_URL secret is not set — the release crew message cannot be posted." + exit 1 + fi + env: + DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code@2.1.206 + + - name: Run dotcom-release-crew skill + run: claude --dangerously-skip-permissions --print "/dotcom-release-crew" + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GH_TOKEN: ${{ github.token }} + DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} diff --git a/.github/workflows/post-release-marketing.yml b/.github/workflows/post-release-marketing.yml new file mode 100644 index 0000000..d6b2006 --- /dev/null +++ b/.github/workflows/post-release-marketing.yml @@ -0,0 +1,57 @@ +name: Post release marketing summary to Discord + +# Every Wednesday at 07:07 UTC, post a plain-language summary of what's shipping in +# this week's dotcom release to the marketing team's Discord channel. Same schedule as +# post-release-crew.yml. +# GitHub cron is UTC-only. We use a single fixed UTC time (no BST/GMT adjustment) so +# the job never fires twice: 07:07 UTC is 07:07 in London during winter (GMT) and +# 08:07 during summer (BST). GitHub often starts scheduled runs 15-60 minutes late, +# so this is scheduled early (and off the congested :00/:30 slots) to land before +# the working day ramps up. To change the day, edit the day-of-week field +# (`3` = Wednesday) in the cron line. + +on: + schedule: + - cron: '7 7 * * 3' # Wednesday 07:07 UTC + workflow_dispatch: # allow manual runs + +concurrency: + group: post-release-marketing + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + post-release-marketing: + name: Post release marketing summary to Discord + timeout-minutes: 20 + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Fail fast if the Discord webhook is not configured + run: | + if [ -z "$DISCORD_MARKETING_WEBHOOK_URL" ]; then + echo "::error::DISCORD_MARKETING_WEBHOOK_URL secret is not set — the release marketing summary cannot be posted." + exit 1 + fi + env: + DISCORD_MARKETING_WEBHOOK_URL: ${{ secrets.DISCORD_MARKETING_WEBHOOK_URL }} + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code@2.1.206 + + - name: Run dotcom-release-marketing skill + run: claude --dangerously-skip-permissions --print "/dotcom-release-marketing" + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GH_TOKEN: ${{ github.token }} + DISCORD_MARKETING_WEBHOOK_URL: ${{ secrets.DISCORD_MARKETING_WEBHOOK_URL }} diff --git a/.github/workflows/prune-preview-deploys.yml b/.github/workflows/prune-preview-deploys.yml new file mode 100644 index 0000000..8a4b419 --- /dev/null +++ b/.github/workflows/prune-preview-deploys.yml @@ -0,0 +1,64 @@ +name: Prune preview deploys + +on: + schedule: + # run once per day at midnight or whatever + - cron: '0 0 * * *' + workflow_dispatch: + +permissions: + id-token: 'write' + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 +defaults: + run: + shell: bash + +jobs: + deploy: + name: Prune preview deploys + timeout-minutes: 30 + runs-on: ubuntu-latest + environment: deploy-staging + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + submodules: true + fetch-depth: 0 + + - uses: ./.github/actions/setup + + - name: Setup Fly IO + uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6 + + - name: Configure AWS credentials using OIDC + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: arn:aws:iam::571248596162:role/huppy-bot + aws-region: 'eu-north-1' + + - name: Prune preview deploys + run: yarn tsx internal/scripts/prune-preview-deploys.ts + env: + GH_TOKEN: ${{ github.token }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + SUPABASE_PREVIEW_PROJECT_ID: ${{ vars.SUPABASE_PREVIEW_PROJECT_ID }} + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + ZERO_R2_ENDPOINT: ${{ secrets.ZERO_R2_ENDPOINT }} + ZERO_R2_BUCKET_NAME: ${{ secrets.ZERO_R2_BUCKET_NAME }} + ZERO_R2_ACCESS_KEY_ID: ${{ secrets.ZERO_R2_ACCESS_KEY_ID }} + ZERO_R2_SECRET_ACCESS_KEY: ${{ secrets.ZERO_R2_SECRET_ACCESS_KEY }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }} + + - name: Notify Discord on failure + if: failure() + uses: ./.github/actions/discord-fail-notify + with: + webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} diff --git a/.github/workflows/publish-editor-extensions.yml b/.github/workflows/publish-editor-extensions.yml new file mode 100644 index 0000000..f04122f --- /dev/null +++ b/.github/workflows/publish-editor-extensions.yml @@ -0,0 +1,73 @@ +name: Publish editor extensions + +on: + push: + branches: + - main + - production + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + publish: + name: Publish editor extensions + environment: vsce publish + timeout-minutes: 20 + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: vscode-extension-publish + + steps: + - name: Generate GH token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HUPPY_APP_ID }} + private-key: ${{ secrets.HUPPY_APP_PRIVATE_KEY }} + + - name: Check out code + id: checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: true + token: ${{ steps.generate_token.outputs.token }} + + - name: Configure git credentials + run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - uses: ./.github/actions/setup + + - name: Build types + run: yarn build-types + + - name: Publish extension + timeout-minutes: 10 + run: yarn tsx ./internal/scripts/publish-editor-extensions.ts + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} + TLDRAW_ENV: ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') }} + GH_TOKEN: ${{ steps.generate_token.outputs.token }} + + # Preserve the setup action for post-job cleanup when checkout already succeeded. + - name: Check out Discord notify action + if: failure() && steps.checkout.outcome != 'success' + uses: actions/checkout@v6 + with: + sparse-checkout: .github/actions/discord-fail-notify + + - name: Notify Discord on failure + if: failure() + uses: ./.github/actions/discord-fail-notify + with: + webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} diff --git a/.github/workflows/publish-templates.yml b/.github/workflows/publish-templates.yml new file mode 100644 index 0000000..43e1d0a --- /dev/null +++ b/.github/workflows/publish-templates.yml @@ -0,0 +1,71 @@ +name: Publish templates + +on: + workflow_dispatch: + workflow_call: + secrets: + HUPPY_PRIVATE_KEY: + required: true + HUPPY_APP_ID: + required: true + DISCORD_DEPLOY_WEBHOOK_URL: + required: false + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + ALLOW_REFRESH_ASSETS_CHANGES: 1 + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + publish: + name: Publish templates + timeout-minutes: 60 + runs-on: ubuntu-latest-16-cores-arm-open + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + + - name: Generate a token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HUPPY_APP_ID }} + private-key: ${{ secrets.HUPPY_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Configure git + run: | + git config --global user.name 'huppy-bot[bot]' + git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com' + + - name: Export templates + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + run: | + cd templates + all_templates=$(ls -d */ | cut -f1 -d'/') + cd .. + + for template in $all_templates; do + echo "::group::Exporting $template" + yarn tsx ./internal/scripts/export-template.ts $template + echo "::endgroup::" + done + + - name: Notify Discord on failure + if: failure() + uses: ./.github/actions/discord-fail-notify + with: + webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} diff --git a/.github/workflows/publish-vscode-extension.yml b/.github/workflows/publish-vscode-extension.yml new file mode 100644 index 0000000..d8db88b --- /dev/null +++ b/.github/workflows/publish-vscode-extension.yml @@ -0,0 +1,70 @@ +name: Publish VSCE/OVX + +on: + workflow_dispatch: + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + publish: + name: Publish VSCE/OVX + environment: vsce publish + timeout-minutes: 20 + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: vscode-extension-publish + + steps: + - name: Generate GH token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HUPPY_APP_ID }} + private-key: ${{ secrets.HUPPY_APP_PRIVATE_KEY }} + + - name: Check out code + id: checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: true + token: ${{ steps.generate_token.outputs.token }} + + - name: Configure git credentials + run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - uses: ./.github/actions/setup + + - name: Build types + run: yarn build-types + + - name: Publish extension + timeout-minutes: 10 + run: yarn tsx ./internal/scripts/publish-editor-extensions.ts + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} + TLDRAW_ENV: production + GH_TOKEN: ${{ steps.generate_token.outputs.token }} + + # Preserve the setup action for post-job cleanup when checkout already succeeded. + - name: Check out Discord notify action + if: failure() && steps.checkout.outcome != 'success' + uses: actions/checkout@v6 + with: + sparse-checkout: .github/actions/discord-fail-notify + + - name: Notify Discord on failure + if: failure() + uses: ./.github/actions/discord-fail-notify + with: + webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..1b88bb5 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,330 @@ +name: Publish SDK packages + +on: + push: + branches: + - main + - production + - 'v*.*.x' + pull_request: + types: [labeled] + workflow_dispatch: + inputs: + publish_type: + type: choice + description: What to publish + required: true + default: manual + options: + - manual + - new + version: + description: Version to republish (manual only) + required: false + bump_type: + type: choice + description: Version bump type (new release only) + required: false + default: minor + options: + - minor + - major + - override + version_override: + type: string + description: Version override (new release only) + required: false + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + remove-label: + name: Remove the publish-packages label + if: | + github.event_name == 'pull_request' && + github.event.label.name == 'publish-packages' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Remove the publish-packages label + uses: actions-ecosystem/action-remove-labels@v1 + with: + labels: publish-packages + + publish: + name: Publish SDK packages + if: | + github.event_name != 'pull_request' || + ( + github.event.label.name == 'publish-packages' && + github.event.pull_request.head.repo.full_name == github.repository + ) + environment: npm deploy + timeout-minutes: 60 + runs-on: ubuntu-latest-16-cores-arm-open + concurrency: + group: ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/heads/v')) && 'npm-publish' || format('npm-publish-{0}', github.run_id) }} + + permissions: + contents: read + id-token: write + + outputs: + mode: ${{ steps.publish_mode.outputs.mode }} + version: ${{ steps.version.outputs.value }} + is_latest_version: ${{ steps.publish.outputs.is_latest_version }} + + steps: + - name: Determine publish mode + id: publish_mode + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_LABEL_NAME: ${{ github.event.label.name }} + REF: ${{ github.ref }} + REF_NAME: ${{ github.ref_name }} + DISPATCH_PUBLISH_TYPE: ${{ inputs.publish_type }} + DISPATCH_VERSION: ${{ inputs.version }} + DISPATCH_BUMP_TYPE: ${{ inputs.bump_type }} + DISPATCH_VERSION_OVERRIDE: ${{ inputs.version_override }} + run: | + mode= + bemo_url=https://demo.tldraw.xyz + use_huppy=false + + case "$EVENT_NAME" in + push) + if [[ "$REF" == "refs/heads/main" ]]; then + mode=canary + bemo_url=https://canary-demo.tldraw.xyz + elif [[ "$REF" == "refs/heads/production" ]]; then + mode=next + bemo_url=https://canary-demo.tldraw.xyz + elif [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.x$ ]]; then + mode=patch + use_huppy=true + fi + ;; + pull_request) + if [[ "$EVENT_LABEL_NAME" == "publish-packages" ]]; then + mode=internal + bemo_url=https://canary-demo.tldraw.xyz + fi + ;; + workflow_dispatch) + mode="$DISPATCH_PUBLISH_TYPE" + if [[ "$mode" == "manual" || "$mode" == "new" ]]; then + use_huppy=true + fi + ;; + esac + + if [[ -z "$mode" ]]; then + echo "ERROR Could not determine publish mode for $EVENT_NAME on $REF." + exit 1 + fi + + if [[ "$mode" == "manual" && -z "$DISPATCH_VERSION" ]]; then + echo "ERROR You must provide a version when manually republishing packages." + exit 1 + fi + + if [[ "$mode" == "new" ]]; then + if [[ "$REF_NAME" != "production" ]]; then + echo "ERROR You must be on the production branch to publish a new version." + exit 1 + fi + if [[ "$DISPATCH_BUMP_TYPE" != "minor" && "$DISPATCH_BUMP_TYPE" != "major" && "$DISPATCH_BUMP_TYPE" != "override" ]]; then + echo "ERROR New releases must use a minor, major, or override bump type." + exit 1 + fi + if [[ "$DISPATCH_BUMP_TYPE" != "override" && -n "$DISPATCH_VERSION_OVERRIDE" ]]; then + echo "ERROR You must set the bump type to override if you supply a custom version number override." + exit 1 + fi + if [[ "$DISPATCH_BUMP_TYPE" == "override" && -z "$DISPATCH_VERSION_OVERRIDE" ]]; then + echo "ERROR You must supply a version override when using the override bump type." + exit 1 + fi + fi + + { + echo "mode=$mode" + echo "bemo_url=$bemo_url" + echo "use_huppy=$use_huppy" + } >> "$GITHUB_OUTPUT" + + - name: Generate GH token + id: generate_token + if: steps.publish_mode.outputs.use_huppy == 'true' + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + with: + app_id: ${{ secrets.HUPPY_APP_ID }} + private_key: ${{ secrets.HUPPY_APP_PRIVATE_KEY }} + + - name: Check out code + id: checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: ${{ steps.publish_mode.outputs.use_huppy != 'true' }} + token: ${{ (steps.publish_mode.outputs.use_huppy == 'true' && steps.generate_token.outputs.token) || github.token }} + + - name: Configure git credentials + if: steps.publish_mode.outputs.use_huppy == 'true' + run: | + git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/" + git config --global user.name 'huppy-bot[bot]' + git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com' + + - name: Run our setup + uses: ./.github/actions/setup + + - name: Publish + id: publish + env: + GH_TOKEN: ${{ (steps.publish_mode.outputs.use_huppy == 'true' && steps.generate_token.outputs.token) || github.token }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }} + TLDRAW_BEMO_URL: ${{ steps.publish_mode.outputs.bemo_url }} + TLDRAW_VERSION_STRING: ${{ inputs.version }} + PUBLISH_MODE: ${{ steps.publish_mode.outputs.mode }} + BUMP_TYPE: ${{ inputs.bump_type }} + VERSION_OVERRIDE: ${{ inputs.version_override }} + run: | + case "$PUBLISH_MODE" in + canary | internal | next) + yarn tsx ./internal/scripts/publish-prerelease.ts "$PUBLISH_MODE" + ;; + patch) + yarn tsx ./internal/scripts/publish-patch.ts + ;; + manual) + yarn tsx ./internal/scripts/publish-manual.ts + ;; + new) + bump="${BUMP_TYPE:-minor}" + if [[ "$bump" == "override" ]]; then + bump="$VERSION_OVERRIDE" + fi + yarn tsx ./internal/scripts/publish-new.ts --bump "$bump" + ;; + *) + echo "ERROR Unknown publish mode: $PUBLISH_MODE" + exit 1 + ;; + esac + + - name: Get published version + id: version + run: echo "value=$(jq -r .version ./packages/tldraw/package.json)" >> "$GITHUB_OUTPUT" + + # Tell tldraw-internal to update its pinned SDK version. Fires only on a + # `next` publish — the prerelease channel the internal monorepo tracks. + # The tldraw-internal "Update tldraw" workflow listens for the + # `tldraw-release` repository_dispatch and resolves the latest `next` + # version itself, so the payload version is informational. + - name: Generate tldraw-internal token + if: steps.publish_mode.outputs.mode == 'next' + id: generate_internal_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.HUPPY_APP_ID }} + private-key: ${{ secrets.HUPPY_PRIVATE_KEY }} + owner: tldraw + repositories: tldraw-internal + + - name: Notify tldraw-internal + if: steps.publish_mode.outputs.mode == 'next' + run: | + curl -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ steps.generate_internal_token.outputs.token }}" \ + https://api.github.com/repos/tldraw/tldraw-internal/dispatches \ + -d '{"event_type":"tldraw-release","client_payload":{"version":"${{ steps.version.outputs.value }}","tag":"next"}}' + + # Preserve the setup action for post-job cleanup when checkout already succeeded. + - name: Check out Discord notify action + if: failure() && steps.checkout.outcome != 'success' + uses: actions/checkout@v6 + with: + sparse-checkout: .github/actions/discord-fail-notify + + - name: Notify Discord on failure + if: failure() + uses: ./.github/actions/discord-fail-notify + with: + webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} + + comment: + name: Tell user released version + if: needs.publish.outputs.mode == 'internal' + needs: publish + runs-on: ubuntu-latest + + permissions: + contents: read + pull-requests: write + + steps: + - name: Tell user released version + uses: actions-ecosystem/action-create-comment@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + body: | + Published version ${{ needs.publish.outputs.version }} to npm. + + publish_templates: + name: Publishes code templates to separate repositories + uses: tldraw/tldraw/.github/workflows/publish-templates.yml@main + if: needs.publish.outputs.mode == 'new' || ((needs.publish.outputs.mode == 'patch' || needs.publish.outputs.mode == 'manual') && needs.publish.outputs.is_latest_version == 'true') + secrets: + HUPPY_PRIVATE_KEY: ${{ secrets.HUPPY_PRIVATE_KEY }} + HUPPY_APP_ID: ${{ vars.HUPPY_APP_ID }} + DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} + needs: publish + + deploy_templates: + name: Deploys the template demos with the new SDK version + if: needs.publish.outputs.mode == 'new' || ((needs.publish.outputs.mode == 'patch' || needs.publish.outputs.mode == 'manual') && needs.publish.outputs.is_latest_version == 'true') + needs: publish + # CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are secrets on this environment rather than on + # the repository, and a job has to select the environment to see them. Calling deploy-templates.yml + # as a reusable workflow doesn't do it: a called workflow only ever sees the secrets its caller + # hands it, so the v5.4.0 release reached the deploy with both empty (#10636) even though the + # called job selected deploy-production itself. The deploy runs here as a job of its own instead, + # sharing its steps with deploy-templates.yml through the composite action. + environment: deploy-production + timeout-minutes: 60 + runs-on: ubuntu-latest-16-cores-arm-open + # same group as deploy-templates.yml: a release deploy and a template-change deploy can otherwise + # race each other onto the same custom domains, and the loser silently wins. + concurrency: + group: deploy-templates + cancel-in-progress: false + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/deploy-templates + with: + license-key: ${{ secrets.TEMPLATES_TLDRAW_LICENSE_KEY }} + cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + + - name: Notify Discord on failure + if: failure() + uses: ./.github/actions/discord-fail-notify + with: + webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} diff --git a/.github/workflows/staging-cleanup-daily.yml b/.github/workflows/staging-cleanup-daily.yml new file mode 100644 index 0000000..c1bc6b1 --- /dev/null +++ b/.github/workflows/staging-cleanup-daily.yml @@ -0,0 +1,38 @@ +name: Staging E2E Daily Cleanup + +on: + schedule: + - cron: '45 1 * * *' + +permissions: + contents: read + +env: + CI: 1 + +defaults: + run: + shell: bash + +jobs: + cleanup: + name: 'Daily Cleanup of Test Files' + timeout-minutes: 20 + runs-on: ubuntu-latest + environment: 'e2e-dotcom' + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Run our setup + uses: ./.github/actions/setup + + - name: Cleanup test files + run: yarn tsx internal/scripts/dotcom/cleanup-test-files.ts + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }} + SUPABASE_STAGING_DB_URL: ${{ secrets.SUPABASE_STAGING_DB_URL }} + STAGING_OWNER_ID: ${{ secrets.STAGING_OWNER_ID }} diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml new file mode 100644 index 0000000..5fc8dd1 --- /dev/null +++ b/.github/workflows/staging-e2e.yml @@ -0,0 +1,68 @@ +name: Staging E2E Tests + +on: + workflow_dispatch: + inputs: + test_files_count: + description: 'Number of test files to copy to staging' + required: false + default: '10' + type: number + +permissions: + contents: read + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + +defaults: + run: + shell: bash + +jobs: + test: + name: 'Staging E2E Tests' + timeout-minutes: 40 + runs-on: ubuntu-latest-8-cores-open + environment: 'e2e-dotcom' + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Run our setup + uses: ./.github/actions/setup + + - name: Set up Playwright + uses: ./.github/actions/setup-playwright + + - name: Run cleanup of test files + run: yarn tsx internal/scripts/dotcom/cleanup-test-files.ts + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }} + SUPABASE_STAGING_DB_URL: ${{ secrets.SUPABASE_STAGING_DB_URL }} + STAGING_OWNER_ID: ${{ secrets.STAGING_OWNER_ID }} + + - name: Copy random files to staging + run: yarn tsx internal/scripts/dotcom/copy-random-files-to-staging.ts + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }} + SUPABASE_PRODUCTION_DB_URL: ${{ secrets.SUPABASE_PRODUCTION_DB_URL }} + SUPABASE_STAGING_DB_URL: ${{ secrets.SUPABASE_STAGING_DB_URL }} + STAGING_OWNER_ID: ${{ secrets.STAGING_OWNER_ID }} + STAGING_TEST_FILES_COUNT: ${{ github.event.inputs.test_files_count || '10' }} + + - name: Run staging e2e tests + run: yarn e2e-staging + working-directory: apps/dotcom/client + env: + CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + STAGING_TEST_EMAIL: ${{ secrets.STAGING_TEST_EMAIL }} + STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }} + STAGING_TESTS: 'true' diff --git a/.github/workflows/trigger-dotcom-hotfix.yml b/.github/workflows/trigger-dotcom-hotfix.yml new file mode 100644 index 0000000..8244512 --- /dev/null +++ b/.github/workflows/trigger-dotcom-hotfix.yml @@ -0,0 +1,69 @@ +name: Trigger dotcom hotfix + +# pull_request_target rather than push/pull_request, for the same reason as +# trigger-sdk-hotfix.yml: release-notes PRs squash-merge with `[skip ci]` in the title, which +# suppresses the push-to-main run entirely, and the `labeled` event fires before the merge so the +# `merged == true` gate rejects it. Neither path ever reached the script. GitHub's skip-ci markers +# do not apply to pull_request_target. `closed` replaces the old push trigger (which ran on every +# merge to main just to check labels), and `labeled` allows re-triggering by re-adding the label to +# an already-merged PR. Secrets exposure is safe here: the job only runs for merged PRs carrying a +# maintainer-applied hotfix label, checkout uses the trusted base branch, and the only PR-derived +# code the script builds is the PR's already-merged commit from main. +on: + pull_request_target: + types: [closed, labeled] + branches: + - main + +defaults: + run: + shell: bash + +permissions: + contents: write + pull-requests: write + +jobs: + trigger: + name: Trigger a dotcom hotfix + runs-on: ubuntu-latest + timeout-minutes: 20 + concurrency: dotcom-hotfix + environment: npm deploy + if: | + github.event.pull_request.merged == true && + ((github.event.action == 'closed' && + contains(github.event.pull_request.labels.*.name, 'dotcom-hotfix-please')) || + (github.event.action == 'labeled' && + github.event.label.name == 'dotcom-hotfix-please')) + + steps: + - name: Generate a token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HUPPY_APP_ID }} + private-key: ${{ secrets.HUPPY_APP_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + persist-credentials: false + token: ${{ steps.generate_token.outputs.token }} + fetch-depth: 0 + + - name: Configure git credentials + run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - uses: ./.github/actions/setup + + - name: Maybe trigger dotcom hotfix + run: | + set -e + git config --global user.name 'huppy-bot[bot]' + git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com' + git log --oneline -10 + yarn tsx internal/scripts/trigger-dotcom-hotfix.ts + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} + PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/trigger-production-build.yml b/.github/workflows/trigger-production-build.yml new file mode 100644 index 0000000..4f19631 --- /dev/null +++ b/.github/workflows/trigger-production-build.yml @@ -0,0 +1,118 @@ +name: Trigger dotcom release + +on: + push: + branches: + - hotfixes + workflow_dispatch: + inputs: + target: + description: 'Target ref to deploy' + required: true + default: 'main' + +defaults: + run: + shell: bash + +env: + TARGET: ${{ github.event.inputs.target }} + +permissions: + contents: read + +jobs: + trigger: + name: ${{ github.event_name == 'workflow_dispatch' && 'Manual trigger' || 'Hotfix' }} + runs-on: ubuntu-latest + concurrency: trigger-production + + steps: + - name: Generate a token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.HUPPY_APP_ID }} + private-key: ${{ secrets.HUPPY_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + persist-credentials: false + token: ${{ steps.generate_token.outputs.token }} + ref: refs/heads/production + fetch-depth: 0 + + - name: Configure git credentials + run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Get target commit hash (manual dispatch) + if: github.event_name == 'workflow_dispatch' + run: | + set -x + + # if the target exists on its own use that + if git rev-parse "$TARGET" --quiet; then + target_hash=$(git rev-parse "$TARGET") + fi + # if not try prefixed with origin: + if [ -z "$target_hash" ]; then + target_hash=$(git rev-parse "origin/$TARGET") + fi + + echo "SHOULD_DEPLOY=true" >> $GITHUB_ENV + echo "TARGET_HASH=$target_hash" >> $GITHUB_ENV + + - name: Get target commit hash (hotfix) + if: github.event_name == 'push' + run: | + set -x + echo "TARGET_HASH=$GITHUB_SHA" >> $GITHUB_ENV + echo "TARGET=hotfix" >> $GITHUB_ENV + # is the hotfix sha already on production? + if git merge-base --is-ancestor "$GITHUB_SHA" production; then + echo "SHOULD_DEPLOY=false" >> $GITHUB_ENV + else + echo "SHOULD_DEPLOY=true" >> $GITHUB_ENV + fi + + - name: Author setup (manual dispatch) + if: github.event_name == 'workflow_dispatch' + run: | + set -x + git config --global user.name "${{ github.actor }}" + git config --global user.email 'huppy+${{ github.actor }}@tldraw.com' + + - name: Author setup (hotfix) + if: github.event_name == 'push' + run: | + set -x + commit_author_name=$(git log -1 --pretty=format:%cn "$TARGET_HASH") + commit_author_email=$(git log -1 --pretty=format:%ce "$TARGET_HASH") + git config --global user.name "$commit_author_name" + git config --global user.email "$commit_author_email" + + - name: Get target tree hash + run: | + set -x + tree_hash=$(git show --quiet --pretty=format:%T "$TARGET_HASH") + echo "TREE_HASH=$tree_hash" >> $GITHUB_ENV + + - name: Create commit & update production branch + run: | + set -eux + now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + message="Deploy from $TARGET ($TARGET_HASH) at $now" + current_prod_hash=$(git rev-parse HEAD) + + commit=$(git commit-tree -m "$message" -p "$current_prod_hash" -p "$TARGET_HASH" "$TREE_HASH") + + git update-ref refs/heads/production "$commit" + git checkout production + + - name: Push commit to trigger deployment + if: env.SHOULD_DEPLOY == 'true' + run: | + set -x + git push origin production + # reset hotfixes to the latest production + git push origin production:hotfixes --force diff --git a/.github/workflows/trigger-sdk-hotfix.yml b/.github/workflows/trigger-sdk-hotfix.yml new file mode 100644 index 0000000..3a08032 --- /dev/null +++ b/.github/workflows/trigger-sdk-hotfix.yml @@ -0,0 +1,70 @@ +name: Trigger sdk hotfix + +# pull_request_target rather than push/pull_request: hotfix PRs (e.g. the automated +# release-notes updates) carry `[skip ci]` in their commit messages, which suppresses +# push- and pull_request-triggered workflows entirely — the label had no effect. +# GitHub's skip-ci markers do not apply to pull_request_target. The `closed` event +# replaces the old push-to-main trigger (which ran on every merge just to check +# labels), and `labeled` allows re-triggering a hotfix by re-adding the label to an +# already-merged PR. Secrets exposure is safe here: the job only runs for merged PRs +# carrying a maintainer-applied hotfix label, checkout uses the trusted base branch, +# and the only PR-derived code the script builds is the PR's already-merged commit +# from main. +on: + pull_request_target: + types: [closed, labeled] + branches: + - main + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + trigger: + name: Trigger an SDK hotfix + runs-on: ubuntu-latest + concurrency: npm-publish + environment: npm deploy + if: | + github.event.pull_request.merged == true && + ((github.event.action == 'closed' && + (contains(github.event.pull_request.labels.*.name, 'sdk-hotfix-please') || + contains(github.event.pull_request.labels.*.name, 'docs-hotfix-please'))) || + (github.event.action == 'labeled' && + (github.event.label.name == 'sdk-hotfix-please' || github.event.label.name == 'docs-hotfix-please'))) + + steps: + - name: Generate a token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HUPPY_APP_ID }} + private-key: ${{ secrets.HUPPY_APP_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + persist-credentials: false + token: ${{ steps.generate_token.outputs.token }} + fetch-depth: 0 + + - name: Configure git credentials + run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - uses: ./.github/actions/setup + + - name: Maybe trigger sdk hotfix + run: | + set -e + git config --global user.name 'huppy-bot[bot]' + git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com' + git log --oneline -10 + yarn tsx internal/scripts/trigger-sdk-hotfix.ts + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + TLDRAW_BEMO_URL: https://demo.tldraw.xyz + DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} + PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/update-release-notes.yml b/.github/workflows/update-release-notes.yml new file mode 100644 index 0000000..da20418 --- /dev/null +++ b/.github/workflows/update-release-notes.yml @@ -0,0 +1,160 @@ +name: Update release notes + +on: + # Update release notes whenever production is updated, keeping next.mdx (and the + # draft GitHub release synced by the skill) in step with each dotcom release — + # the same production push that deploys dotcom and publishes the `next` package. + # Production only: a push to main must not trigger a notes run on every merge. + # Release-notes PR titles carry `[skip ci]` so their squash-merge commits skip main + # push workflows. Branch commits omit the marker so required PR checks run. + push: + branches: + - production + workflow_dispatch: + +concurrency: + group: update-release-notes + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + update-release-notes: + name: 'Update release notes with Claude Code' + timeout-minutes: 30 + runs-on: ubuntu-latest + + steps: + - name: Generate GH token + id: generate_token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + with: + app_id: ${{ secrets.HUPPY_APP_ID }} + private_key: ${{ secrets.HUPPY_PRIVATE_KEY }} + + - name: Check out code + uses: actions/checkout@v6 + with: + persist-credentials: false + fetch-depth: 0 + token: ${{ steps.generate_token.outputs.token }} + + - name: Configure git credentials + run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Run our setup + uses: ./.github/actions/setup + + - name: Configure git + run: | + git config --global user.name 'huppy-bot[bot]' + git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com' + + - name: Create branch + id: branch + run: | + BRANCH_NAME="release-notes/update-$(date +%Y%m%d-%H%M%S)" + git checkout -b "$BRANCH_NAME" + echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" + + # Recorded before the skill runs so the close step below can tell an + # already-open PR from an earlier run apart from the one this run opens. + - name: Find open release notes PRs + id: existing_prs + run: | + { + echo 'numbers<> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ steps.generate_token.outputs.token }} + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code + + - name: Run update-release-notes skill + run: | + claude --dangerously-skip-permissions --print "/update-release-notes" + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GH_TOKEN: ${{ steps.generate_token.outputs.token }} + + - name: Check for changes + id: changes + run: | + if [ -z "$(git status --porcelain)" ]; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit and push + if: steps.changes.outputs.has_changes == 'true' + run: | + git add -A + git commit -m "Update release notes" + git push -u origin "${{ steps.branch.outputs.branch_name }}" + + - name: Create PR + if: steps.changes.outputs.has_changes == 'true' + run: | + gh pr create \ + --title "docs(releases): update release notes [skip ci]" \ + --body "$(cat <<'EOF' + In order to keep the release notes up to date, this PR adds entries for PRs merged since the last update. + + ### Change type + + - [x] `other` + + ### Test plan + + - [ ] Unit tests + - [ ] End to end tests + EOF + )" \ + --base main \ + --head "${{ steps.branch.outputs.branch_name }}" \ + --label "docs-hotfix-please" + env: + GH_TOKEN: ${{ steps.generate_token.outputs.token }} + + # Each run regenerates the notes from scratch, so a PR opened by this run + # supersedes any release notes PR left open by an earlier run. The PR may come + # from the step above or from the skill itself, so this compares the currently + # open PRs against the ones recorded before the skill ran rather than relying on + # a single known PR number. When this run opened nothing, the older PR is still + # the pending update and is left alone. + - name: Close superseded PRs + run: | + EXISTING="${{ steps.existing_prs.outputs.numbers }}" + CURRENT=$(gh pr list --state open --base main --limit 100 --json number,headRefName \ + --jq '.[] | select(.headRefName | test("^(release-notes/update-|update-release-notes-)")) | .number') + + NEW_PRS=$(comm -13 <(echo "$EXISTING" | sort) <(echo "$CURRENT" | sort)) + if [ -z "$NEW_PRS" ]; then + echo "This run did not open a release notes PR, nothing to supersede" + exit 0 + fi + + # The new PR is already open at this point, so a failure to tidy up an old + # one is a warning, not a reason to fail the run. + NEWEST=$(echo "$NEW_PRS" | sort -n | tail -n 1) + echo "$EXISTING" | while read -r PR; do + [ -n "$PR" ] || continue + echo "Closing superseded release notes PR #$PR" + gh pr close "$PR" --delete-branch --comment "Superseded by #$NEWEST." \ + || echo "Warning: could not close #$PR" + done + env: + GH_TOKEN: ${{ steps.generate_token.outputs.token }} + + - name: No changes + if: steps.changes.outputs.has_changes == 'false' + run: echo "No release notes updates needed" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65273fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,141 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +/worktrees + +.rooms + +# Temporary files +*.tmp.ts + +node_modules +dist +dist-cjs +dist-esm +.tsbuild* +.lazy +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# turborepo +.turbo + +coverage + +# vitest reports, attachments and blob output +.vitest + +**/*.env +**/*.tsbuildinfo + +**/*.css.map +**/*.js.map +apps/examples/www/index.js +apps/examples/www/index.css +nohup.out + +packages/*/package +packages/*/*.tgz + +tsconfig.build.json +.vercel + +api-json +api-md + +packages/tldraw/tldraw.css +packages/namespaced-tldraw/tldraw.css +packages/commenting/commenting.css +packages/collaboration/collaboration.css + +packages/assets/embed-icons +packages/assets/fonts +packages/assets/icons +packages/assets/translations +packages/assets/watermarks +packages/commenting/assets +apps/examples/www/embed-icons +apps/examples/www/fonts +apps/examples/www/icons +apps/examples/www/translations + +# yarn v2 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +packages/*/api/temp +packages/*/api/api.json +packages/*/api/internal.d.ts +packages/*/api/public.d.ts + +apps/examples/www/index.css +apps/examples/www/index.js +.tsbuild + +apps/examples/build.esbuild.json + +test-results +apps/examples/e2e/test-results +apps/examples/playwright-report +apps/dotcom/client/test-results +apps/dotcom/client/playwright-report +apps/dotcom/client/e2e/.auth +apps/dotcom/zero-cache/.schema.js + +apps/docs/content/gen +apps/docs/api + +.dev.vars +.env.local +.env.development.local +.env* + +.wrangler +/vercel.json +license-report*.html +license-report*.md +license-report*.pdf + +.bundle-meta.json +.zed + +.mcp.json + +# TypeScript profiling +.ts-profile + +# ESLint cache +.eslintcache +opencode.json + +# PR walkthrough intermediate files +.claude/worktrees +.claude/settings.local.json +skills/pr-walkthrough/tmp +skills/pr-walkthrough/out +skills/pr-walkthrough/video/node_modules +skills/pr-walkthrough/video/dist +skills/pr-walkthrough/video/public/audio-* +skills/pr-walkthrough/video/public/manifest.json +todo.md diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100755 index 0000000..974fab8 --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +. "$(dirname -- "$0")/_/husky.sh" + +set -e + +rm -rf *.tsbuildinfo */*.tsbuildinfo */*/*.tsbuildinfo */*/*/*.tsbuildinfo */*/*/*/*.tsbuildinfo + +# This notifies the user if the yarn.lock file has changed. +CHANGED=$(git diff "$1" "$2" --stat -- ./yarn.lock | wc -l) +if (( CHANGED > 0 )); then + echo + echo "🚨 🚨 🚨 yarn.lock has changed! 🚨 🚨 🚨 " + echo "run 'yarn' to get the latest!" + echo +fi diff --git a/.husky/post-merge b/.husky/post-merge new file mode 100755 index 0000000..d24df76 --- /dev/null +++ b/.husky/post-merge @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +set -e + +# This notifies the user if the yarn.lock file has changed. +CHANGED=$(git diff HEAD@{1} --stat -- ./yarn.lock | wc -l) +if (( CHANGED > 0 )); then + echo + echo "🚨 🚨 🚨 yarn.lock has changed! 🚨 🚨 🚨 " + echo "run 'yarn' to get the latest!" + echo +fi diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..9c1ae2d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,31 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +# Only run yarn if package.json files have changed +CHANGED_PACKAGE_JSON_FILES=$(git diff --cached --name-only -- ':(glob)**/package.json') +if [ -n "$CHANGED_PACKAGE_JSON_FILES" ]; then + yarn install --immutable +fi + +# Only run build-api for packages with staged changes +CHANGED_PACKAGES=$(git diff --cached --name-only -- packages | cut -d/ -f2 | sort -u) +FILTER_ARGS="" +for pkg in $CHANGED_PACKAGES; do + if [ -f "packages/$pkg/package.json" ] && grep -q '"build-api"' "packages/$pkg/package.json"; then + FILTER_ARGS="$FILTER_ARGS --filter=packages/$pkg" + fi +done +if [ -n "$FILTER_ARGS" ]; then + ONLY_REFRESH_ASSETS_IF_NECESSARY=1 npx lazy run build-api $FILTER_ARGS + git add packages/*/api-report.api.md +fi + +# Only run build-i18n when dotcom client files change +CHANGED_DOTCOM_CLIENT=$(git diff --cached --name-only -- apps/dotcom/client) +if [ -n "$CHANGED_DOTCOM_CLIENT" ]; then + npx lazy run build-i18n --filter=apps/dotcom/client + git add apps/dotcom/client/public/tla/locales + git add apps/dotcom/client/public/tla/locales-compiled +fi + +yarn lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..5f26dc4 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs pre-push "$@" diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..e73158b --- /dev/null +++ b/.ignore @@ -0,0 +1,33 @@ +dist +.tsbuild-dev +.tsbuild-pub +.tsbuild +node_modules +*.d.ts +**/api-report.md +**/api-report.api.md +**/_archive +**/*.tsbuildinfo +yarn.lock +**/*.tldr +**/*.d.ts +**/*.d.ts.map +**/*.js.map +**/*.css.map +apps/example/www/index.css +**/dist/* +*.cjs +apps/docs/.next + +packages/tldraw/tldraw.css + +**/dist-cjs/**/* +**/dist-esm/**/* +**/*.js.map +**/*.api.json +apps/docs/utils/vector-db +apps/docs/content/releases/**/* +apps/docs/content/reference/**/* +packages/**/api + +**/.clasp.json \ No newline at end of file diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..5390e83 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,13 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "trailingComma": "es5", + "singleQuote": true, + "semi": false, + "printWidth": 100, + "tabWidth": 2, + "useTabs": true, + "sortImports": { + "newlinesBetween": false, + }, + "sortPackageJson": false, +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..ebc1c5a --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,460 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["unicorn", "react", "typescript", "nextjs"], + "jsPlugins": ["./internal/scripts/oxlint/tldraw-plugin.mjs"], + "categories": { + "correctness": "off" + }, + "options": { + "typeAware": true + }, + "env": { + "builtin": true + }, + "settings": { + "next": { + "rootDir": ["apps/*/", "packages/*/"] + }, + "react": { + "version": "19.2" + } + }, + "ignorePatterns": [ + "skills/**/*", + ".claude/**/*", + ".agents/**/*", + ".cursor/**/*", + "**/node_modules/*", + "**/out/*", + "**/dist/*", + "**/dist-cjs/*", + "**/dist-esm/*", + "**/.tsbuild*", + "**/.lazy/*", + "**/.next/*", + "**/.wrangler/*", + "**/.vercel/*", + "**/*.md", + "**/_archive/*", + "**/*.css.map", + "**/*.js.map", + "**/*.d.ts", + "**/api/*", + "!**/pages/api/*", + "**/*.json", + "**/lazy.config.ts", + "**/next.config.js", + "**/setupTests.js", + "apps/vscode/extension/editor", + "apps/examples/www", + "apps/docs/api-content.json", + "apps/docs/content.json", + "apps/vscode/extension/editor/index.js", + "apps/vscode/extension/editor/tldraw-assets.json", + "**/sentry.server.config.js", + "**/scripts/upload-sourcemaps.js", + "**/scripts/lib/auto-plugin.js", + "**/coverage/**/*", + "apps/docs/postcss.config.js", + "apps/docs/tailwind.config.js", + "apps/dotcom/client/public/sw.js", + "apps/dotcom/client/public/theme-init.js", + "apps/analytics/public/*", + "**/.clasp.json", + "**/*.mjs", + "**/.*.js", + "packages/assets/*", + "packages/create-tldraw/cli.cjs", + "**/*/vitest.config.ts", + "**/*/setupVitest.js", + "**/*/zod-locales-shim.js", + "**/*/scripts/vite-zod-locale-plugin.js" + ], + "rules": { + "no-unexpected-multiline": "error", + "constructor-super": "error", + "for-direction": "error", + "getter-return": "error", + "no-async-promise-executor": "error", + "no-case-declarations": "error", + "no-class-assign": "error", + "no-compare-neg-zero": "error", + "no-cond-assign": "error", + "no-const-assign": "error", + "no-constant-binary-expression": "error", + "no-constant-condition": "error", + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-dupe-class-members": "error", + "no-dupe-else-if": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty": "error", + "no-empty-character-class": "error", + "no-empty-pattern": "error", + "no-empty-static-block": "error", + "no-ex-assign": "error", + "no-extra-boolean-cast": "error", + "no-func-assign": "error", + "no-global-assign": "error", + "no-import-assign": "error", + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-loss-of-precision": "error", + "no-misleading-character-class": "error", + "no-new-native-nonconstructor": "error", + "no-nonoctal-decimal-escape": "error", + "no-obj-calls": "error", + "no-prototype-builtins": "error", + "no-redeclare": "error", + "no-regex-spaces": "error", + "no-self-assign": "error", + "no-setter-return": "error", + "no-shadow-restricted-names": "error", + "no-sparse-arrays": "error", + "no-this-before-super": "error", + "no-undef": "error", + "no-unreachable": "error", + "no-unsafe-finally": "error", + "no-unsafe-negation": "error", + "no-unsafe-optional-chaining": "error", + "no-unused-labels": "error", + "no-unused-private-class-members": "error", + "no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", + "fix": { + "imports": "safe-fix", + "variables": "off" + } + } + ], + "no-useless-backreference": "error", + "no-useless-catch": "error", + "no-useless-escape": "error", + "no-with": "error", + "require-yield": "error", + "use-isnan": "error", + "valid-typeof": "error", + "no-throw-literal": "error", + "no-array-constructor": "error", + "no-unused-expressions": "error", + + "react/jsx-key": "error", + "react/jsx-no-comment-textnodes": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-no-target-blank": "error", + "react/jsx-no-undef": "error", + "react/no-children-prop": "error", + "react/no-danger-with-children": "error", + "react/no-direct-mutation-state": "error", + "react/no-find-dom-node": "error", + "react/no-is-mounted": "error", + "react/no-render-return-value": "error", + "react/no-string-refs": "error", + "react/no-unescaped-entities": "error", + "react/no-unknown-property": "error", + "react/require-render-return": "error", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", + + "@typescript-eslint/no-duplicate-enum-values": "error", + "@typescript-eslint/no-empty-object-type": "error", + "@typescript-eslint/no-extra-non-null-assertion": "error", + "@typescript-eslint/no-misused-new": "error", + "@typescript-eslint/no-namespace": "error", + "@typescript-eslint/no-non-null-asserted-optional-chain": "error", + "@typescript-eslint/no-require-imports": "error", + "@typescript-eslint/no-this-alias": "error", + "@typescript-eslint/no-unnecessary-type-constraint": "error", + "@typescript-eslint/no-unsafe-declaration-merging": "error", + "@typescript-eslint/no-unsafe-function-type": "error", + "@typescript-eslint/no-wrapper-object-types": "error", + "@typescript-eslint/prefer-as-const": "error", + "@typescript-eslint/prefer-namespace-keyword": "error", + "@typescript-eslint/triple-slash-reference": "error", + "@typescript-eslint/no-deprecated": "error", + "@typescript-eslint/consistent-type-exports": [ + "error", + { "fixMixedExportsWithInlineTypeSpecifier": true } + ], + "@typescript-eslint/consistent-type-definitions": ["error", "interface"], + + "@next/next/google-font-display": "warn", + "@next/next/google-font-preconnect": "warn", + "@next/next/next-script-for-ga": "warn", + "@next/next/no-async-client-component": "warn", + "@next/next/no-before-interactive-script-outside-document": "warn", + "@next/next/no-css-tags": "warn", + "@next/next/no-head-element": "warn", + "@next/next/no-page-custom-font": "warn", + "@next/next/no-styled-jsx-in-document": "warn", + "@next/next/no-sync-scripts": "error", + "@next/next/no-title-in-document-head": "warn", + "@next/next/no-typos": "warn", + "@next/next/no-unwanted-polyfillio": "warn", + "@next/next/inline-script-id": "error", + "@next/next/no-assign-module-variable": "error", + "@next/next/no-document-import-in-page": "error", + "@next/next/no-duplicate-head": "error", + "@next/next/no-head-import-in-document": "error", + "@next/next/no-script-component-in-head": "error", + + "no-restricted-globals": [ + "error", + { + "name": "structuredClone", + "message": "Use structuredClone from @tldraw/util instead" + } + ], + + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "lodash", + "message": "The main `lodash` package is CommonJS and does not tree-shake. Use a deep import (e.g. `import throttle from 'lodash/throttle'`), `lodash-es`, a per-method package, or inline the helper." + } + ] + } + ], + + "tldraw/no-export-star": "error", + "tldraw/no-internal-imports": "error", + "tldraw/tagged-components": "error", + "tldraw/prefer-class-methods": "error", + "tldraw/tsdoc-param-matching": "error", + "tldraw/no-whilst": "error", + "tldraw/no-tiptap-default-import": "error", + "tldraw/no-setter-getter": "error", + "tldraw/no-direct-storage": "error", + "tldraw/no-path2d-roundrect": "error", + "tldraw/no-exported-arrow-const": "error", + "tldraw/no-focused-tests": "error", + "tldraw/enforce-default-message": ["error", "literal"], + "tldraw/method-signature-style": "error", + "tldraw/claude-agents-pairing": "error" + }, + "overrides": [ + { + "files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + "rules": { + "@typescript-eslint/explicit-module-boundary-types": "off", + "no-console": ["error", { "allow": ["warn", "error"] }], + "constructor-super": "off", + "getter-return": "off", + "no-class-assign": "off", + "no-const-assign": "off", + "no-dupe-class-members": "off", + "no-dupe-keys": "off", + "no-func-assign": "off", + "no-import-assign": "off", + "no-new-native-nonconstructor": "off", + "no-obj-calls": "off", + "no-redeclare": "off", + "no-setter-return": "off", + "no-this-before-super": "off", + "no-undef": "off", + "no-unreachable": "off", + "no-unsafe-negation": "off", + "no-with": "off", + "no-var": "error", + "prefer-const": "error", + "prefer-rest-params": "error", + "prefer-spread": "error" + } + }, + { + "files": ["packages/**/*", "apps/**/*", "internal/**/*", "templates/**/*"], + "rules": { + "tldraw/no-undeclared-dependencies": "error" + } + }, + { + "files": ["packages/editor/**/*", "packages/tldraw/**/*", "packages/utils/**/*"], + "rules": { + "no-restricted-globals": [ + "error", + { + "name": "fetch", + "message": "Use the fetch from @tldraw/util instead." + }, + { + "name": "Image", + "message": "Use the Image from @tldraw/util instead." + }, + { + "name": "setTimeout", + "message": "Use the timers from editor.timers instead." + }, + { + "name": "setInterval", + "message": "Use the timers from editor.timers instead." + }, + { + "name": "requestAnimationFrame", + "message": "Use the timers from editor.timers instead." + }, + { + "name": "structuredClone", + "message": "Use structuredClone from @tldraw/util instead" + } + ], + "tldraw/no-restricted-properties": [ + "error", + [ + { + "object": "window", + "property": "fetch", + "message": "Use the fetch from @tldraw/util instead." + }, + { + "object": "window", + "property": "Image", + "message": "Use the Image from @tldraw/util instead." + }, + { + "object": "window", + "property": "setTimeout", + "message": "Use the timers from editor.timers instead." + }, + { + "object": "window", + "property": "setInterval", + "message": "Use the timers from editor.timers instead." + }, + { + "object": "window", + "property": "requestAnimationFrame", + "message": "Use the timers from editor.timers instead." + } + ] + ], + "tldraw/img-referrer-policy": "error" + } + }, + { + "files": ["apps/dotcom/client/**/*"], + "rules": { + "no-restricted-globals": [ + "error", + { + "name": "fetch", + "message": "Use the fetch from @tldraw/util instead." + }, + { + "name": "Image", + "message": "Use the Image from @tldraw/util instead." + }, + { + "name": "structuredClone", + "message": "Use structuredClone from @tldraw/util instead" + } + ], + "tldraw/no-restricted-properties": [ + "error", + [ + { + "object": "window", + "property": "fetch", + "message": "Use the fetch from @tldraw/util instead." + }, + { + "object": "window", + "property": "Image", + "message": "Use the Image from @tldraw/util instead." + }, + { + "object": "crypto", + "property": "randomUUID", + "message": "Please use the makeUUID util instead." + } + ] + ] + } + }, + { + "files": ["apps/dotcom/client/src/tla/**/*"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "react-intl", + "message": "Please import useIntl from src/utils/intl instead." + }, + { + "name": "lodash", + "message": "The main `lodash` package is CommonJS and does not tree-shake. Use a deep import (e.g. `import throttle from 'lodash/throttle'`), `lodash-es`, a per-method package, or inline the helper." + } + ] + } + ], + "tldraw/jsx-no-literals": "error" + } + }, + { + "files": ["**/scripts/**/*"], + "rules": { + "@typescript-eslint/no-require-imports": "off" + } + }, + { + "files": ["internal/scripts/**/*"], + "rules": { + "tldraw/no-export-star": "off" + } + }, + { + "files": ["internal/scripts/process-compose-bin.cjs"], + "env": { "node": true }, + "globals": { "fetch": "readonly" } + }, + { + "files": ["apps/mcp-app/**/*"], + "rules": { + "tldraw/no-export-star": "off" + } + }, + { + "files": ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts"], + "rules": { + "tldraw/no-restricted-properties": "off", + "no-restricted-globals": "off", + "react/jsx-key": "off", + "react/no-string-refs": "off" + } + }, + { + "files": ["internal/**/*", "templates/simple-server-example/**/*"], + "rules": { + "no-console": "off" + } + }, + { + "files": ["apps/examples/**/*"], + "rules": { + "tldraw/no-setter-getter": "off", + "tldraw/no-direct-storage": "off", + "tldraw/img-referrer-policy": "off" + } + }, + { + "files": ["templates/**/*"], + "rules": { + "tldraw/prefer-class-methods": "off", + "tldraw/no-setter-getter": "off", + "tldraw/no-direct-storage": "off", + "tldraw/img-referrer-policy": "off", + "no-console": "off", + "tldraw/method-signature-style": "off" + } + } + ] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ad62b24 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,45 @@ +**/node_modules/* +**/out/* +**/dist/* +**/dist-cjs/* +**/dist-esm/* +**/.next/* +**/api/* +!**/app/api/* +!**/pages/api/* +**/.tsbuild* +**/.next/* +**/_archive/* +apps/docs/api-content.json +apps/docs/content.json +apps/vscode/extension/editor/* +apps/examples/www +content.json +apps/docs/utils/vector-db/index.json +**/gen/**/*.md +.github/pull_request_template.md +skills +.agents +.cursor + +**/api-report.md +**/api-report.api.md +**/CHANGELOG.md +apps/docs/content/releases/**/* +apps/docs/content/reference/**/* + +**/.vercel/* +**/.wrangler/* +**/.out/* +**/.temp/* +apps/dotcom/client/public/**/*.* +apps/analytics/public/**/* + +**/.clasp.json + +apps/dotcom/zero-cache/docker/docker-compose.yml + +lerna.json + +internal/scripts/eslint/eslint-plugin.mjs +apps/dotcom/zero-cache/.schema.js \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..7fd1df8 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "trailingComma": "es5", + "singleQuote": true, + "semi": false, + "printWidth": 100, + "tabWidth": 2, + "useTabs": true, + "plugins": ["prettier-plugin-organize-imports"] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..9879019 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["oxc.oxc-vscode"] +} diff --git a/.yarn/patches/@microsoft-tsdoc-npm-0.15.1-e24295d9bd.patch b/.yarn/patches/@microsoft-tsdoc-npm-0.15.1-e24295d9bd.patch new file mode 100644 index 0000000..77e161e --- /dev/null +++ b/.yarn/patches/@microsoft-tsdoc-npm-0.15.1-e24295d9bd.patch @@ -0,0 +1,91 @@ +diff --git a/lib-commonjs/emitters/TSDocEmitter.js b/lib-commonjs/emitters/TSDocEmitter.js +index 60535a109e3705baae7cb851e1f7cca6e2b5595b..5aedafa2da8ebcf22ec356d89921a93b33dca7ea 100644 +--- a/lib-commonjs/emitters/TSDocEmitter.js ++++ b/lib-commonjs/emitters/TSDocEmitter.js +@@ -282,6 +282,9 @@ var TSDocEmitter = /** @class */ (function () { + var docPlainText = docNode; + this._writeContent(docPlainText.text); + break; ++ case DocNode_1.DocNodeKind.SoftBreak: ++ this._ensureAtStartOfLine(); ++ break; + } + }; + TSDocEmitter.prototype._renderInlineTag = function (docInlineTagBase, writeInlineTagContent) { +diff --git a/lib-commonjs/transforms/TrimSpacesTransform.js b/lib-commonjs/transforms/TrimSpacesTransform.js +index b5fe0c49a27a8df5b08e9862541c2449c61c1158..1fa36cab07267eaabbb9a0b9d640cfcec116dae4 100644 +--- a/lib-commonjs/transforms/TrimSpacesTransform.js ++++ b/lib-commonjs/transforms/TrimSpacesTransform.js +@@ -20,6 +20,25 @@ var TrimSpacesTransform = /** @class */ (function () { + // We always trim leading whitespace for a paragraph. This flag gets set to true + // as soon as nonempty content is encountered. + var finishedSkippingLeadingSpaces = false; ++ ++ function pushAccumulatedText() { ++ const lines = accumulatedTextChunks.join('').split('\n') ++ for (let i = 0; i < lines.length; i++) { ++ const line = lines[i] ++ transformedNodes.push(new nodes_1.DocPlainText({ ++ configuration: docParagraph.configuration, ++ text: line ++ })); ++ if (i < lines.length - 1) { ++ transformedNodes.push(new nodes_1.DocSoftBreak({ ++ configuration: docParagraph.configuration ++ })); ++ } ++ } ++ accumulatedTextChunks.length = 0; ++ accumulatedNodes.length = 0; ++ } ++ + for (var _i = 0, _a = docParagraph.nodes; _i < _a.length; _i++) { + var node = _a[_i]; + switch (node.kind) { +@@ -46,9 +65,15 @@ var TrimSpacesTransform = /** @class */ (function () { + } + break; + case nodes_1.DocNodeKind.SoftBreak: +- if (finishedSkippingLeadingSpaces) { +- pendingSpace = true; +- } ++ // by default, this transform strips out all soft-breaks and replaces them with ++ // spaces where needed. because we're rendering to markdown and markdown is ++ // whitespace-sensitive, we don't want to do this. instead, we accumulate the ++ // soft-breaks and emit them as normal. ++ ++ // if (finishedSkippingLeadingSpaces) { ++ // pendingSpace = true; ++ // } ++ accumulatedTextChunks.push('\n'); + accumulatedNodes.push(node); + break; + default: +@@ -60,12 +85,7 @@ var TrimSpacesTransform = /** @class */ (function () { + if (accumulatedTextChunks.length > 0) { + // TODO: We should probably track the accumulatedNodes somehow, e.g. so we can map them back to the + // original excerpts. But we need a developer scenario before we can design this API. +- transformedNodes.push(new nodes_1.DocPlainText({ +- configuration: docParagraph.configuration, +- text: accumulatedTextChunks.join('') +- })); +- accumulatedTextChunks.length = 0; +- accumulatedNodes.length = 0; ++ pushAccumulatedText(); + } + transformedNodes.push(node); + finishedSkippingLeadingSpaces = true; +@@ -73,12 +93,7 @@ var TrimSpacesTransform = /** @class */ (function () { + } + // Push the accumulated text + if (accumulatedTextChunks.length > 0) { +- transformedNodes.push(new nodes_1.DocPlainText({ +- configuration: docParagraph.configuration, +- text: accumulatedTextChunks.join('') +- })); +- accumulatedTextChunks.length = 0; +- accumulatedNodes.length = 0; ++ pushAccumulatedText() + } + var transformedParagraph = new nodes_1.DocParagraph({ + configuration: docParagraph.configuration diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..e19deb7 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,14 @@ +compressionLevel: mixed + +enableGlobalCache: false + +# Don't run third-party dependency build/install scripts by default. Packages that +# genuinely need to build are allowlisted via dependenciesMeta..built in package.json. +# This closes the postinstall code-execution vector; see the note in AGENTS.md. +enableScripts: false + +enableInlineBuilds: true + +nodeLinker: node-modules + +npmMinimalAgeGate: '7d' diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..158445c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,222 @@ +# AGENTS.md + +This file provides guidance to AI coding agents working in this repository. + +## Core rules + +- Use `yarn`, not `npm`, for repo commands. This repo uses Yarn workspaces and Yarn 4. +- Run commands from the repo root unless a command explicitly says to run from a workspace. +- Never run bare `tsc`; use `yarn typecheck` from the repo root. +- Prefer targeted checks first. Avoid repo-wide test or e2e runs unless the change needs them. +- Keep changes scoped to the request and the affected package. Do not refactor unrelated code. +- Respect existing worktree changes. Do not revert user changes unless explicitly asked. +- Prefer editing existing files over creating new files. Do not add new documentation files unless requested. +- Use sentence case for headings, titles, labels, and documentation text. + +## Repo overview + +This is the tldraw monorepo, an infinite canvas SDK for React applications. It is organized with Yarn workspaces. + +Core packages: + +- `packages/editor` - foundational infinite canvas editor with no default shapes, tools, or UI +- `packages/tldraw` - complete SDK with default UI, shapes, tools, and interactions +- `packages/store` - reactive client-side database, persistence, and migrations +- `packages/tlschema` - shape, binding, and record type definitions and validators +- `packages/state` - reactive signals library +- `packages/sync` and `packages/sync-core` - multiplayer sync packages +- `packages/utils` and `packages/validate` - shared utilities and validation helpers +- `packages/assets` - icons, fonts, translations, and bundled assets + +Apps and examples: + +- `apps/examples` - SDK examples and demos; the main place for example development +- `apps/docs` - documentation site at tldraw.dev +- `apps/dotcom` - tldraw.com app and workers +- `apps/vscode` - VS Code extension +- `templates` - starter templates for supported frameworks + +## Setup + +Requires Node `>=22.12.0`. Enable Corepack before installing dependencies: + +```bash +npm i -g corepack && yarn +``` + +## Common commands + +Development: + +- `yarn dev` - start the examples app at localhost:5420 +- `yarn dev-app` - start the tldraw.com client app +- `yarn dev-docs` - start the docs site +- `yarn dev-vscode` - start VS Code extension development +- `yarn dev-template