Переглянути джерело

meta: add release automations (#3304)

* meta: add release automations

* Add missing versions

* remove old scripts that are no longer useful

* add special casing for robodog

* skip fetching if HEAD is already available locally

* Publish to npm from GitHub actions

* fixes

* Update afterVersionBump script

* Fix getUpToDateRefsFromGitHub script

* Make sub-package specific changelogs

* fix git and PR opening process in GH Actions

* fix Release action

* don't fetch GH user names in changelog

* add support for commits that touch several packages

* add website as a valid commit message prefix

* Assign the PR to the releaser and fix other GH related bugs

* Remove release branch from CI

* Restore git history at the end of local release session

* skip CI if assignee has not approved the PR

* fix release commit message

* beautify commit message

* Special case for @uppy/robodog

* Failure is not an option

* Rename release CI

* Always rewind before crashing

* Improve logging

* fix changelog table

* fix linter

* Update CONTRIBUTING.md

* Remove unused package

* Disable Release workflow between two releases

* Fix git command and workaround deleted branch
Antoine du Hamel 3 роки тому
батько
коміт
b8a466b6ad

+ 1 - 0
.eslintrc.js

@@ -176,6 +176,7 @@ module.exports = {
         '.eslintrc.js',
         'website/*.js',
         'website/**/*.js',
+        'private/**/*.js',
       ],
       rules: {
         'no-console': 'off',

+ 11 - 20
.github/CONTRIBUTING.md

@@ -143,26 +143,17 @@ yarn install
 yarn start
 ```
 
-Releases are managed by [Lerna](https://github.com/lerna/lerna). We do some cleanup and compile work around releases too. Use the npm release script:
-
-```bash
-yarn run release
-```
-
-If you have two-factor authentication enabled on your account, Lerna will ask for a one-time password. You may stumble upon a known issue with the CLI where the OTP prompt may be obscured by a publishing progress bar. If Lerna appears to freeze as it starts publishing, chances are it’s waiting for the password. Try typing in your OTP and hitting enter.
-
-Other things to keep in mind during release:
-
-* When adding a new package, add the following key to its package.json:
-  ```json
-  "publishConfig": { "access": "public" }
-  ```
-  Else, the release script will try and fail to publish a _private_ package, because the `@uppy` scope on npm does not support that.
-
-After a release, the demos on transloadit.com should also be updated. After updating, check that some things work locally:
-
-* the demos in the demo section work (try one that uses an import robot, and one that you need to upload to)
-* the demos on the homepage work and can import from Google Drive, Instagram, Dropbox, etc.
+Releases are managed by GitHub Actions, here’s an overview of the process to release a new Uppy version:
+
+* Run `yarn release` on your local machine.
+* Follow the instructions and select what packages to release.
+* Before committing, check if the generated files look good.
+* Push to the Transloadit repository using the command given by the tool. Do not open a PR yourself, the GitHub Actions will create one and assign you to it.
+* Wait for all the GitHub Actions checks to pass. If one fails, try to figure out why. Do not go ahead without consulting the rest of the team.
+* Review the PR thoroughly, and if everything looks good to you, approve the PR. Do not merge it manually!
+* After the PR is automatically merged, the demos on transloadit.com should also be updated. Check that some things work locally:
+  * the demos in the demo section work (try one that uses an import robot, and one that you need to upload to)
+  * the demos on the homepage work and can import from Google Drive, Instagram, Dropbox, etc.
 
 If you don’t have access to the transloadit.com source code ping @arturi or @goto-bus-stop and we’ll pick it up. :sparkles:
 

+ 0 - 42
.github/workflows/cdn.yml

@@ -1,42 +0,0 @@
-name: CDN
-on:
-  push:
-    branches: main
-
-jobs:
-  release:
-    if: ${{startsWith(github.event.head_commit.message, 'Release')}}
-    name: Publish releases
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout sources
-        uses: actions/checkout@v2
-      - name: Cache npm dependencies
-        id: cache-npm-libraries
-        uses: actions/cache@v2
-        with:
-          path: .yarn/cache/*
-          key: ${{ runner.os }}
-      - name: Install Node.js
-        uses: actions/setup-node@v2
-        with:
-          node-version: 16.x
-      - name: Install dependencies
-        run: corepack yarn install
-      - name: Build bundles
-        run: corepack yarn run build
-      - name: Upload `uppy` to CDN
-        run: corepack yarn run uploadcdn uppy
-        env:
-          EDGLY_KEY: ${{secrets.EDGLY_KEY}}
-          EDGLY_SECRET: ${{secrets.EDGLY_SECRET}}
-      - name: Upload `@uppy/robodog` to CDN
-        run: corepack yarn run uploadcdn @uppy/robodog
-        env:
-          EDGLY_KEY: ${{secrets.EDGLY_KEY}}
-          EDGLY_SECRET: ${{secrets.EDGLY_SECRET}}
-      - name: Upload `@uppy/locales` to CDN
-        run: corepack yarn run uploadcdn @uppy/locales
-        env:
-          EDGLY_KEY: ${{secrets.EDGLY_KEY}}
-          EDGLY_SECRET: ${{secrets.EDGLY_SECRET}}

+ 73 - 0
.github/workflows/release-candidate.yml

@@ -0,0 +1,73 @@
+name: Release candidate
+on:
+  push:
+    branches: release
+
+jobs:
+  prepare-release:
+    name: Prepare release candidate Pull Request
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout sources
+        uses: actions/checkout@v2
+        with:
+          branch: release
+      - name: Rebase
+        run: |
+          git fetch origin HEAD --depth=1
+          git config --global user.email "actions@github.com"
+          git config --global user.name "GitHub Actions"
+          git rebase FETCH_HEAD
+      - name: Cache npm dependencies
+        id: cache-npm-libraries
+        uses: actions/cache@v2
+        with:
+          path: .yarn/cache/*
+          key: ${{ runner.os }}
+      - name: Install Node.js
+        uses: actions/setup-node@v2
+        with:
+          node-version: 16.x
+      - name: Install dependencies
+        run: corepack yarn install
+      - name: Bump candidate packages version
+        run: corepack yarn version apply --all --json | jq -s > releases.json
+      - name: Prepare changelog
+        run: corepack yarn workspace @uppy-build/release update-changelogs releases.json | xargs git add
+      - name: Update contributors table
+        run: corepack yarn contributors:save && git add README.md
+      - name: Update CDN URLs
+        run: corepack yarn workspace @uppy-build/release update-version-URLs | xargs git add
+      - name: Stage changes and remove temp files
+        run: |
+          git rm -rf .yarn/versions
+          git rm CHANGELOG.next.md
+          jq -r 'map(.cwd) | join("\n")' < releases.json | awk '{ print "git add " $0 "/package.json" }' | sh
+      - name: Commit
+        run: |
+          echo "Release: uppy@$(jq -r 'map(select(.ident == "uppy"))[0].newVersion' < releases.json)" > commitMessage
+          echo >> commitMessage
+          echo "This is a release candidate for the following packages:" >> commitMessage
+          echo >> commitMessage
+          jq -r 'map("- `"+.ident+"`: "+.oldVersion+" -> "+.newVersion) | join("\n") ' < releases.json >> commitMessage
+          git commit -n --amend --file commitMessage
+      - name: Open Pull Request
+        id: pr_opening
+        run: |
+          git push origin HEAD:release-candidate
+          gh api repos/${{ github.repository }}/pulls \
+            -F base="$(gh api /repos/${{ github.repository }} | jq -r .default_branch)" \
+            -F head="release-candidate" \
+            -F title="$(head -1 commitMessage)" \
+            -F body="$(git --no-pager diff HEAD^ -- CHANGELOG.md | awk '{ if( substr($0,0,1) == "+" && $1 != "+##" && $1 != "+Released:" && $1 != "+++" ) { print substr($0,2) } }')" \
+            --jq '.number | tostring | "##[set-output name=pr_number;]"+.'
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: Assign to the releaser
+        run: echo '{"assignees":[${{ toJSON(github.actor) }}]}' | gh api repos/${{ github.repository }}/issues/${{ steps.pr_opening.outputs.pr_number }}/assignees --input -
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: Enable Release workflow
+        run: gh workflow enable Release --repo ${{ github.repository }}
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 91 - 0
.github/workflows/release.yml

@@ -0,0 +1,91 @@
+name: Release
+on:
+  pull_request_review:
+    types: [submitted]
+
+jobs:
+  release:
+    name: Publish releases
+    if: ${{ github.event.review.state == 'approved' && github.event.sender.login == github.event.pull_request.assignee.login && github.event.pull_request.head.ref == 'release-candidate' }}
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout sources
+        uses: actions/checkout@v2
+        with:
+          fetch-depth: 2
+      - name: Cache npm dependencies
+        id: cache-npm-libraries
+        uses: actions/cache@v2
+        with:
+          path: .yarn/cache/*
+          key: ${{ runner.os }}
+      - name: Install Node.js
+        uses: actions/setup-node@v2
+        with:
+          node-version: 16.x
+      - name: Install dependencies
+        run: corepack yarn install
+      - name: Get CHANGELOG diff
+        run: git --no-pager diff HEAD^ -- CHANGELOG.md | awk '{ if( substr($0,0,1) == "+" && $1 != "+##" && $1 != "+Released:" && $1 != "+++" ) { print substr($0,2) } }' > CHANGELOG.diff.md
+      - name: Build before publishing
+        run: corepack yarn run build
+      - name: Publish to NPM
+        run: corepack yarn workspaces foreach --no-private npm publish --access public --tolerate-republish
+        env:
+          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+      - name: Merge PR
+        id: merge
+        run: |
+          gh api -X PUT repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/merge \
+            -F merge_method="squash" \
+            -F commit_message="$(cat CHANGELOG.diff.md)" \
+            --jq 'if .merged then "##[set-output name=sha;]"+.sha else error("not merged") end'
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: Create tags
+        run: |
+          git --no-pager diff --name-only HEAD^ | awk '$0 ~ /^packages\/.+\/package\.json$/ { print "jq -r '"'"'\"gh api /repos/{owner}/{repo}/git/refs -f ref=\\\"refs/tags/\"+.name+\"@\"+.version+\"\\\" -f sha=${{ steps.merge.outputs.sha }}\"'"'"' < " $0 }' > createTags.sh
+          cat createTags.sh
+          sh createTags.sh | sh
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: Get Uppy version number
+        id: uppyVersion
+        run: jq -r '"##[set-output name=version;]"+.version' < packages/uppy/package.json
+      - name: Create GitHub release
+        run: gh release create uppy@${{ steps.uppyVersion.outputs.version }} -t "Uppy ${{ steps.uppyVersion.outputs.version }}" -F CHANGELOG.diff.md
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: Upload `uppy` to CDN
+        run: corepack yarn run uploadcdn uppy
+        env:
+          EDGLY_KEY: ${{secrets.EDGLY_KEY}}
+          EDGLY_SECRET: ${{secrets.EDGLY_SECRET}}
+      - name: Upload `@uppy/robodog` to CDN if it was released
+        run: git diff --exit-code --quiet HEAD^ -- packages/@uppy/robodog/package.json || corepack yarn run uploadcdn @uppy/robodog
+        env:
+          EDGLY_KEY: ${{secrets.EDGLY_KEY}}
+          EDGLY_SECRET: ${{secrets.EDGLY_SECRET}}
+      - name: Upload `@uppy/locales` to CDN if it was released
+        if: false
+        run: git diff --exit-code --quiet HEAD^ -- packages/@uppy/locales/package.json ||corepack yarn run uploadcdn @uppy/locales
+        env:
+          EDGLY_KEY: ${{secrets.EDGLY_KEY}}
+          EDGLY_SECRET: ${{secrets.EDGLY_SECRET}}
+      - name: Remove release-candidate branch
+        run: gh api -X DELETE repos/${{ github.repository }}/git/refs/heads/release-candidate
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: Remove release branch
+        run: gh api -X DELETE repos/${{ github.repository }}/git/refs/heads/release
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: Disable Release workflow
+        run: gh workflow disable Release --repo ${{ github.repository }}
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: In case of failure
+        if: ${{ failure() }}
+        run: gh pr comment ${{ github.event.pull_request.number }} --body "Release job failed, please take action."
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 0 - 106
bin/after-version-bump.js

@@ -1,106 +0,0 @@
-#!/usr/bin/env node
-/* eslint-disable import/no-dynamic-require */
-/* eslint-disable global-require */
-
-// Called by the `version` npm script.
-// This is run _after_ lerna updates the version numbers,
-// but _before_ it commits, so we have time to update the
-// version numbers throughout the repo and add it to the
-// release commit.
-// After updating version numbers, this runs a full
-// IS_RELEASE_BUILD=1 build, so that the version numbers
-// are properly embedded in the JS bundles.
-// NOTE this _amends_ the previous commit, which should
-// already be a "Release" commit generated by bin/release.
-
-const { spawn } = require('child_process')
-const { readFile, writeFile } = require('fs/promises')
-const once = require('events.once')
-const globby = require('globby')
-
-async function replaceInFile (filename, replacements) {
-  let content = await readFile(filename, 'utf8')
-  for (const [rx, replacement] of replacements) {
-    content = content.replace(rx, replacement)
-  }
-
-  await writeFile(filename, content, 'utf8')
-}
-
-async function updateVersions (files, packageName) {
-  const { version } = require(`../packages/${packageName}/package.json`)
-
-  // uppy → uppy
-  // @uppy/robodog → uppy/robodog
-  const urlPart = packageName === 'uppy' ? packageName : packageName.slice(1)
-
-  const replacements = new Map([
-    [RegExp(`${urlPart}/v\\d+\\.\\d+\\.\\d+\\/`, 'g'), `${urlPart}/v${version}/`],
-    // maybe more later
-  ])
-
-  console.log('replacing', replacements, 'in', files.length, 'files')
-
-  for (const f of files) {
-    await replaceInFile(f, replacements)
-  }
-}
-
-async function gitAdd (files) {
-  const git = spawn('git', ['add', ...files], { stdio: 'inherit' })
-  const [exitCode] = await once(git, 'exit')
-  if (exitCode !== 0) {
-    throw new Error(`git add failed with ${exitCode}`)
-  }
-}
-
-// Run the build as a release build (that inlines version numbers etc.)
-async function npmRunBuild () {
-  const npmRun = spawn('yarn', ['run', 'build'], {
-    stdio: 'inherit',
-    env: {
-      ...process.env,
-      FRESH: true, // force rebuild everything
-      IS_RELEASE_BUILD: true,
-    },
-  })
-  const [exitCode] = await once(npmRun, 'exit')
-  if (exitCode !== 0) {
-    throw new Error(`yarn run build failed with ${exitCode}`)
-  }
-}
-
-async function main () {
-  if (process.env.ENDTOEND === '1') {
-    console.log('Publishing for e2e tests, skipping version number sync.')
-    process.exit(0)
-  }
-
-  const files = await globby([
-    'README.md',
-    'BUNDLE-README.md',
-    'examples/**/*.html',
-    'packages/*/README.md',
-    'packages/@uppy/*/README.md',
-    'website/src/docs/**',
-    'website/src/examples/**',
-    'website/themes/uppy/layout/**',
-    '!**/node_modules/**',
-  ])
-
-  await updateVersions(files, 'uppy')
-  await updateVersions(files, '@uppy/robodog')
-  await updateVersions(files, '@uppy/locales')
-
-  // gitignored files were updated for the npm package, but can't be updated
-  // on git.
-  const isIgnored = await globby.gitignore()
-  await gitAdd(files.filter((filename) => !isIgnored(filename)))
-
-  await npmRunBuild()
-}
-
-main().catch((err) => {
-  console.error(err.stack)
-  process.exit(1)
-})

+ 0 - 8
bin/make-changelog

@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-# Make a draft changelog. Expects two tags to compate: previous release and current.
-# https://stackoverflow.com/questions/1441010/the-shortest-possible-output-from-git-log-containing-author-and-date
-# `./bin/make-changelog uppy@1.31.1 uppy@2.0.1`
-
-git_log=$(git log $1..$2 --pretty=format:"- %s %aE (%h)")
-echo "$git_log"
-exit;

+ 0 - 45
bin/make-new-versions-table

@@ -1,45 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Generate a version table from the most recent "Release" commit,
- * for use in changelogs.
- *
- * Usage:
- *   $ ./bin/make-new-versions-table
- */
-
-const { execSync } = require('child_process')
-
-const logStdout = execSync('git log --grep \'Release$\' -1 --pretty=oneline --no-decorate')
-let match = /^([0-9a-f]+) .*?$/m.exec(logStdout.toString())
-if (!match) {
-  console.error('Could not read Release commit')
-  process.exit(1)
-}
-
-const commit = match[1]
-
-const tagStdout = execSync(`git tag --list --contains ${commit}`)
-const tags = tagStdout.toString()
-const rx = /([@\/\w-]+)@(\d+\.\d+\.\d+)(-alpha\.\d+|-beta\.\d+)?/g
-
-const versions = []
-let m
-while ((m = rx.exec(tags))) {
-  const [, pkg, versionPrefix, versionSuffix] = m
-  const version = `${versionPrefix}${versionSuffix || ''}`
-  versions.push({ pkg, version })
-}
-
-const mid = Math.ceil(versions.length / 2)
-let table = [
-  '| Package | Version | Package | Version |',
-  '|-|-|-|-|'
-]
-for (let i = 0; i < mid; i++) {
-  const left = versions[i] || { pkg: '-', version: '-' }
-  const right = versions[i + mid] || { pkg: '-', version: '-' }
-  table.push(`| ${left.pkg} | ${left.version} | ${right.pkg} | ${right.version} |`)
-}
-
-console.log(table.join('\n'))

+ 0 - 72
bin/release

@@ -1,72 +0,0 @@
-#!/usr/bin/env bash
-
-set -o pipefail
-set -o errexit
-set -o nounset
-
-# Set magic variables for current file & dir
-__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-__file="${__dir}/$(basename "${BASH_SOURCE[0]}")"
-__base="$(basename ${__file} .sh)"
-__root="$(cd "$(dirname "${__dir}")" && pwd)"
-
-is_local="${LOCAL:-0}"
-echo "Local release: $is_local"
-
-CHANGED=$(git diff-index --name-only HEAD --)
-if [ -n "$CHANGED" ]; then
-  echo "Found local, uncomitted git changes"
-  echo ""
-  echo "Please ensure that your working tree is clean"
-  exit 1
-fi
-
-if [[ ! "$@" =~ -y ]]; then
-  echo "Make sure to read https://uppy.io/docs/contributing#Releases!"
-  echo "Press Enter when ready, or Ctrl+C if you still need to do something."
-  echo "Use 'yarn run release -- -y' to bypass this message."
-  read
-fi
-
-if [ $is_local == "0" ] && [[ ! "$(yarn config get registry)" =~ https://registry\.npmjs\.(com|org)/? ]]; then
-  echo "Found unexpected npm registry: $(yarn config get registry)"
-  echo "Run this to fix:"
-  echo ""
-  echo "yarn config set registry https://registry.npmjs.org"
-  exit 1
-fi
-
-if ! yarn whoami > /dev/null; then
-  echo "Not authenticated with yarn. First do:"
-  echo ""
-  echo "yarn login"
-  exit 1
-fi
-
-set -o xtrace
-
-# Update README before publishing `uppy`
-# So up-to-date contributors are shown on the npm page.
-yarn run contributors:save
-git add README.md
-
-# Add readme file to the main `uppy` package.
-cp README.md packages/uppy/README.md
-
-yarn run build:clean
-FRESH=1 yarn run build
-
-echo "!! The next step is the actual release!"
-echo "!! If something goes wrong after here, it becomes hard to reverse. Please make sure that everything is in order."
-echo "Press Enter when ready, or Ctrl+C if you still need to do something."
-read
-
-git commit --allow-empty -m "Release"
-lerna version --amend --no-push --exact
-
-lerna publish from-git
-
-if [ $is_local == "0" ]; then
-  git push
-  git push --tags
-fi

+ 0 - 16
bin/remove-accidental-git-tags.sh

@@ -1,16 +0,0 @@
-#!/bin/bash
-
-# removes tags that Lerna generated, but then failed to release,
-# and is now unfortunately stuck
-# usage: ./remove-tags.sh VERSION_NUMBER
-# where VERSION_NUMBER is something like 0.30.0
-
-Packages=(aws-s3 file-input react transloadit aws-s3-multipart form redux-dev-tools tus companion golden-retriever robodog url companion-client google-drive server-utils utils core informer status-bar webcam dashboard instagram store-default xhr-upload drag-drop progress-bar store-redux dropbox box provider-views thumbnail-generator zoom)
-Version = $*
-
-for i in "${Packages[@]}"
-do
-  TAG="@uppy/$i@$1"
-  echo "removing $TAG"
-  git tag -d $TAG
-done

+ 1 - 0
examples/react-native-expo/package.json

@@ -1,5 +1,6 @@
 {
   "name": "@uppy-example/react-native-expo",
+  "version": "0.0.0",
   "dependencies": {
     "@uppy/core": "workspace:*",
     "@uppy/dashboard": "workspace:*",

+ 1 - 4
package.json

@@ -86,11 +86,8 @@
     "fakefile": "^1.0.0",
     "github-contributors-list": "^1.2.4",
     "glob": "^7.1.6",
-    "globby": "^11.0.4",
     "isomorphic-fetch": "^3.0.0",
     "jest": "^27.0.6",
-    "last-commit-message": "^1.0.0",
-    "lerna": "^4.0.0",
     "lint-staged": "^11.0.0",
     "mime-types": "^2.1.26",
     "minify-stream": "^2.0.1",
@@ -150,7 +147,7 @@
     "lint:css": "stylelint ./packages/**/*.scss",
     "lint:css:fix": "stylelint ./packages/**/*.scss --fix",
     "lint": "eslint . --cache",
-    "release": "bash ./bin/release",
+    "release": "PACKAGES=$(yarn workspaces list --json) yarn workspace @uppy-build/release interactive",
     "size": "echo 'JS Bundle mingz:' && cat ./packages/uppy/dist/uppy.min.js | gzip | wc -c && echo 'CSS Bundle mingz:' && cat ./packages/uppy/dist/uppy.min.css | gzip | wc -c",
     "start:companion": "bash ./bin/companion",
     "start": "npm-run-all --parallel watch start:companion web:start",

+ 70 - 0
private/release/afterVersionBump.js

@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+
+import { readFileSync } from 'node:fs'
+import { open } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { globby } from 'globby'
+
+const ROOT = new  URL('../../', import.meta.url)
+const PACKAGES_FOLDER = new URL('./packages/', ROOT)
+
+const VERSION_URL = /(?<=https:\/\/\S+\/v)\d+\.\d+\.\d+(?:-(?:alpha|beta)(?:[.-]\d+)?)?(?=\/)/g
+
+async function replaceInFile (filename, replacements) {
+  const file = await open(filename, 'r+')
+  let content = await file.readFile('utf8')
+
+  let hasBeenModified = false
+  let exec
+  while (exec = VERSION_URL.exec(content)) {
+    // eslint-disable-next-line no-loop-func
+    const pkg = Object.keys(replacements).find(pkgName => content.slice(exec.index - pkgName.length, exec.index) === pkgName)
+    if (pkg && exec[0] !== replacements[pkg]) {
+      hasBeenModified = true
+      content = content.slice(0, exec.index) + replacements[pkg] + content.slice(VERSION_URL.lastIndex)
+    }
+  }
+
+  if (hasBeenModified) {
+    const { bytesWritten } = await file.write(content, 0, 'utf8')
+    await file.truncate(bytesWritten)
+    console.log(filename)
+  }
+
+  await file.close()
+}
+
+async function updateVersions (files, packageNames) {
+  const replacements = Object.fromEntries(packageNames.map(packageName => {
+    const { version } = JSON.parse(readFileSync(new URL(`./${packageName}/package.json`, PACKAGES_FOLDER), 'utf8'))
+    // uppy → /uppy/v
+    // @uppy/robodog → /uppy/robodog/v
+    const urlPart = `/${packageName.replace(/^@/, '')}/v`
+    return [urlPart, version]
+  }))
+
+  await Promise.all(files.map(f => replaceInFile(f, replacements)))
+}
+
+const files = await globby([
+  'README.md',
+  'BUNDLE-README.md',
+  'examples/**/*.html',
+  'packages/*/README.md',
+  'packages/@uppy/*/README.md',
+  'website/src/docs/**',
+  'website/src/examples/**',
+  'website/themes/uppy/layout/**',
+  '!**/node_modules/**',
+], {
+  gitignore: true,
+  onlyFiles: true,
+  cwd: fileURLToPath(ROOT),
+  absolute: true,
+})
+
+await updateVersions(files, [
+  'uppy',
+  '@uppy/robodog',
+  '@uppy/locales',
+])

+ 150 - 0
private/release/choose-semverness.js

@@ -0,0 +1,150 @@
+/* eslint-disable no-continue */
+
+import { createWriteStream, mkdirSync, readFileSync } from 'node:fs'
+import { spawnSync } from 'node:child_process'
+
+import prompts from 'prompts'
+
+const ROOT = new  URL('../../', import.meta.url)
+const PACKAGES_FOLDER = new URL('./packages/', ROOT)
+
+function getRobodogDependencies () {
+  const { dependencies } = JSON.parse(readFileSync(new URL('./@uppy/robodog/package.json', PACKAGES_FOLDER)))
+  return Object.keys(dependencies)
+}
+
+function maxSemverness (a, b) {
+  if (a === 'major' || b === 'major') return 'major'
+  if (a === 'premajor' || b === 'premajor') return 'premajor'
+  if (a === 'minor' || b === 'minor') return 'minor'
+  if (a === 'preminor' || b === 'preminor') return 'preminor'
+  if (a === 'prepatch' || b === 'prepatch') return 'prepatch'
+  if (a === 'prepatch' || b === 'prerelease') return 'prerelease'
+  return 'patch'
+}
+
+export default async function pickSemverness (
+  spawnOptions,
+  LAST_RELEASE_COMMIT,
+  releaseFileUrl,
+  packagesList,
+) {
+  mkdirSync(new URL('.', releaseFileUrl), { recursive: true })
+  const releaseFile = createWriteStream(releaseFileUrl)
+  releaseFile.write('releases:\n')
+
+  let uppySemverness
+  let robodogSemverness
+  const robodogDeps = getRobodogDependencies()
+
+  for await (const workspaceInfo of packagesList) {
+    const { location, name } = JSON.parse(workspaceInfo)
+    if (!name.startsWith('@uppy/')) continue
+    if (name === '@uppy/robodog') continue
+
+    const { stdout } = spawnSync(
+      'git',
+      [
+        '--no-pager',
+        'log',
+        '--format=- %s',
+        `${LAST_RELEASE_COMMIT}..`,
+        '--',
+        location,
+      ],
+      spawnOptions,
+    )
+    if (stdout.length === 0) {
+      console.log(`No commits since last release for ${name}, skipping.`)
+      continue
+    }
+    console.log(
+      `Here are the commits that landed on ${name} since previous release:\n\n${stdout}\n`,
+    )
+    console.log(
+      `Check the web UI at https://github.com/transloadit/uppy/tree/main/${encodeURI(
+        location,
+      )}.`,
+    )
+
+    const response = await prompts({
+      type: 'select',
+      name: 'value',
+      message: `What should be the semverness of next ${name} release?`,
+      choices: [
+        { title: 'Pre-release', value: 'prerelease' },
+        { title: 'Skip this package', value: '' },
+        { title: 'Patch', value: 'patch' },
+        { title: 'Minor', value: 'minor' },
+        { title: 'Major', value: 'major' },
+      ],
+      initial: 2,
+    })
+
+    if (!response.value) {
+      console.log('Skipping.')
+      continue
+    }
+
+    releaseFile.write(`  ${JSON.stringify(name)}: ${response.value}\n`)
+    uppySemverness = maxSemverness(uppySemverness, response.value)
+    if (robodogDeps.includes(name)) {
+      robodogSemverness = maxSemverness(robodogSemverness, response.value)
+    }
+  }
+
+  if (uppySemverness == null) throw new Error('No package to release, aborting.')
+
+  {
+    // Robodog
+    const location = 'packages/@uppy/robodog'
+    const { stdout } = spawnSync(
+      'git',
+      [
+        '--no-pager',
+        'log',
+        '--format=- %s',
+        `${LAST_RELEASE_COMMIT}..`,
+        '--',
+        location,
+      ],
+      spawnOptions,
+    )
+    if (stdout.length === 0) {
+      if (robodogSemverness == null) {
+        console.log(`No commits since last release for @uppy/robodog, skipping.`)
+      } else {
+        console.log(`No commits since last release for @uppy/robodog, releasing as ${robodogSemverness}.`)
+        releaseFile.write(`  "@uppy/robodog": ${robodogSemverness}\n`)
+      }
+    } else {
+      console.log(
+        `Here are the commits that landed on @uppy/robodog since previous release:\n\n${stdout}\n`,
+      )
+      console.log(
+        `Check the web UI at https://github.com/transloadit/uppy/tree/main/${encodeURI(
+          location,
+        )}.`,
+      )
+
+      const response = await prompts({
+        type: 'select',
+        name: 'value',
+        message: `What should be the semverness of next @uppy/robodog release?`,
+        choices: [
+          { title: 'Pre-release', value: 'prerelease' },
+          { title: 'Skip this package', value: '', disabled: robodogSemverness != null },
+          { title: 'Patch', value: 'patch', disabled: robodogSemverness === 'minor' || robodogSemverness === 'major' },
+          { title: 'Minor', value: 'minor', disabled: robodogSemverness === 'major' },
+          { title: 'Major', value: 'major' },
+        ],
+        initial: 2,
+      })
+
+      releaseFile.write(`  "@uppy/robodog": ${response.value}\n`)
+    }
+  }
+
+  releaseFile.write(`  "uppy": ${uppySemverness}\n`)
+  releaseFile.close()
+}

+ 25 - 0
private/release/commit-and-open-pr.js

@@ -0,0 +1,25 @@
+import { spawnSync } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+import prompts from 'prompts'
+import { REPO_OWNER, REPO_NAME } from './config.js'
+
+export default async function commit (spawnOptions, ...files) {
+  console.log(`Now is the time to do manual edits to ${files.join(',')}.`)
+  await prompts({
+    type: 'toggle',
+    name: 'value',
+    message: 'Ready to commit?',
+    initial: true,
+    active: 'yes',
+    inactive: 'yes',
+  })
+
+  spawnSync('git', ['add', ...files.map(url => fileURLToPath(url))], spawnOptions)
+  spawnSync('git', ['commit', '-n', '-m', 'Prepare next release'], { ...spawnOptions, stdio: 'inherit' })
+  const sha = spawnSync('git', ['rev-parse', 'HEAD'], spawnOptions).stdout.toString().trim()
+  const getRemoteCommamnd = `git remote -v | grep '${REPO_OWNER}/${REPO_NAME}' | awk '($3 == "(push)") { print $1; exit }'`
+  const remote = spawnSync('/bin/sh', ['-c', getRemoteCommamnd]).stdout.toString().trim()
+                 || `git@github.com:${REPO_OWNER}/${REPO_NAME}.git`
+
+  console.log(`Please run \`git push ${remote} ${sha}:refs/heads/release\`.`)
+}

+ 3 - 0
private/release/config.js

@@ -0,0 +1,3 @@
+export const REPO_OWNER = 'transloadit'
+export const REPO_NAME = 'uppy'
+export const TARGET_BRANCH = 'main'

+ 91 - 0
private/release/formatChangeLog.js

@@ -0,0 +1,91 @@
+import { createInterface } from 'node:readline'
+import { createWriteStream } from 'node:fs'
+import { spawn } from 'node:child_process'
+
+import prompts from 'prompts'
+
+const atUppyPackagePath = /^packages\/(@uppy\/[a-z0-9-]+)\//
+async function inferPackageForCommit (sha, spawnOptions) {
+  const cp = spawn('git', ['--no-pager', 'log', '-1', '--name-only', sha], spawnOptions)
+  const candidates = {}
+  for await (const path of createInterface({ input: cp.stdout })) {
+    const match = atUppyPackagePath.exec(path)
+    if (match != null) {
+      candidates[match[1]] ??= 0
+      candidates[match[1]]++
+    }
+  }
+  const maxVal = Math.max(...Object.values(candidates))
+  return {
+    inferredPackages: Number.isFinite(maxVal)
+      ? Object.entries(candidates).flatMap(
+        ([pkg, nbOfFiles]) => (nbOfFiles === maxVal || nbOfFiles === maxVal - 1 ? [pkg] : []),
+      ).join(',')
+      : 'meta',
+    candidates,
+  }
+}
+
+export default async function formatChangeLog (
+  spawnOptions,
+  LAST_RELEASE_COMMIT,
+  changeLogUrl,
+) {
+  const changeLogCommits = createWriteStream(changeLogUrl)
+
+  const gitLog = spawn('git', [
+    '--no-pager',
+    'log',
+    '--format="%H::%s::%an"',
+    `${LAST_RELEASE_COMMIT}..HEAD`,
+  ], spawnOptions)
+  const expectedFormat = /^"([a-f0-9]+)::(?:((?:@uppy\/[a-z0-9-]+(?:,@uppy\/[a-z0-9-]+)*)|meta|website):\s?)?(.+?)(\s\(#\d+\))?::(.+)"$/ // eslint-disable-line max-len
+  for await (const log of createInterface({ input: gitLog.stdout })) {
+    const [, sha, packageName, title, PR, authorName] = expectedFormat.exec(log)
+
+    const formattedCommitTitle = {
+      packageName,
+      title,
+      authorInfo: PR ? `${authorName} / #${PR.slice(3, -1)}` : authorName,
+    }
+
+    if (!packageName) {
+      console.log(
+        `No package info found in commit title: ${sha} (https://github.com/transloadit/uppy/commit/${sha})`,
+      )
+      console.log(log)
+      const { inferredPackages, candidates } = await inferPackageForCommit(sha, spawnOptions)
+      const { useInferred } = await prompts({
+        type: 'confirm',
+        name: 'useInferred',
+        message: `Use ${inferredPackages} (inferred from the files it touches)?`,
+        initial: true,
+      })
+
+      if (useInferred) {
+        formattedCommitTitle.packageName = inferredPackages
+      } else {
+        const response = await prompts({
+          type: 'autocompleteMultiselect',
+          name: 'value',
+          message: 'Which package(s) does this commit belong to?',
+          min: 1,
+          choices: [
+            { title: 'Meta', value: 'meta' },
+            ...Object.entries(candidates)
+              .sort((a, b) => a[1] > b[1])
+              .map(([value]) => ({ title: value, value })),
+          ],
+        })
+        if (!Array.isArray(response.value)) throw new Error('Aborting release')
+        formattedCommitTitle.packageName = response.value.join(',')
+      }
+    }
+
+    changeLogCommits.write(
+      `- ${formattedCommitTitle.packageName}: ${formattedCommitTitle.title} (${formattedCommitTitle.authorInfo})\n`,
+    )
+  }
+
+  changeLogCommits.close()
+}

+ 108 - 0
private/release/getUpToDateRefsFromGitHub.js

@@ -0,0 +1,108 @@
+import fetch from 'node-fetch'
+
+import { spawnSync } from 'node:child_process'
+import prompts from 'prompts'
+import { TARGET_BRANCH, REPO_NAME, REPO_OWNER } from './config.js'
+
+async function apiCall (endpoint, errorMessage) {
+  const response = await fetch(
+    `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}${endpoint}`,
+  )
+  if (response.ok) {
+    return response.json()
+  }
+  console.warn(response)
+  throw new Error(errorMessage)
+}
+
+export async function getRemoteHEAD () {
+  return (
+    await apiCall(
+      `/git/ref/heads/${TARGET_BRANCH}`,
+      'Cannot get remote HEAD, check your internet connection.',
+    )
+  ).object.sha
+}
+
+async function getLatestReleaseSHA () {
+  const { tag_name } = await apiCall(
+    `/releases/latest`,
+    'Cannot get latest release from GitHub, check your internet connection.',
+  )
+  console.log(`Last release was ${tag_name}.`)
+  return (
+    await apiCall(
+      `/git/ref/tags/${encodeURIComponent(tag_name)}`,
+      `Failed to fetch information for release ${JSON.stringify(tag_name)}`,
+    )
+  ).object.sha
+}
+
+async function getLocalHEAD () {
+  return spawnSync('git', ['rev-parse', 'HEAD']).stdout.toString().trim()
+}
+
+export function rewindGitHistory (spawnOptions, sha) {
+  return spawnSync('git', ['reset', sha, '--hard'], spawnOptions).status === 0
+}
+
+export async function validateGitStatus (spawnOptions) {
+  const latestRelease = getLatestReleaseSHA() // run in parallel to speed things up
+  const [REMOTE_HEAD, LOCAL_HEAD] = await Promise.all([getRemoteHEAD(), getLocalHEAD()])
+
+  const { status, stderr } = spawnSync(
+    'git',
+    ['diff', '--exit-code', '--quiet', REMOTE_HEAD, '--', '.'],
+    spawnOptions,
+  )
+  if (status !== 0) {
+    console.error(stderr.toString())
+    console.log(
+      `git repository is not clean and/or not in sync with ${REPO_OWNER}/${REPO_NAME}`,
+    )
+    if (spawnSync(
+      'git',
+      ['diff', '--exit-code', '--quiet', LOCAL_HEAD, '--', '.'],
+      spawnOptions,
+    ).status !== 0) {
+      const { value } = await prompts({
+        type: 'confirm',
+        name: 'value',
+        message:
+          'Do you want to hard reset your local repository (all uncommitted changes will be lost)?',
+      })
+      if (!value) {
+        throw new Error(
+          'Please ensure manually that your local repository is clean and up to date.',
+        )
+      }
+    }
+
+    if (stderr.indexOf('bad object') !== -1) {
+      // eslint-disable-next-line no-shadow
+      const { status, stdout, stderr } = spawnSync(
+        'git',
+        [
+          'fetch',
+          `https://github.com/${REPO_OWNER}/${REPO_NAME}.git`,
+          TARGET_BRANCH,
+        ],
+        spawnOptions,
+      )
+
+      if (status) {
+        console.log(stdout.toString())
+        console.error(stderr.toString())
+        throw new Error('Failed to fetch, please ensure manually that your local repository is up to date')
+      }
+    }
+
+    if (!rewindGitHistory(spawnOptions, REMOTE_HEAD)) {
+      throw new Error(
+        'Failed to reset, please ensure manually that your local repository is clean and up to date.',
+      )
+    }
+  }
+
+  return [await latestRelease, LOCAL_HEAD]
+}

+ 28 - 0
private/release/interactive.js

@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+
+import pickSemverness from './choose-semverness.js'
+import commit from './commit-and-open-pr.js'
+import formatChangeLog from './formatChangeLog.js'
+import { validateGitStatus, rewindGitHistory } from './getUpToDateRefsFromGitHub.js'
+
+const ROOT = new URL('../../', import.meta.url)
+const spawnOptions = { cwd: fileURLToPath(ROOT) }
+
+const deferredReleaseFile = new URL('./.yarn/versions/next.yml', ROOT)
+const temporaryChangeLog = new URL('./CHANGELOG.next.md', ROOT)
+
+console.log('Validating local repo status and get previous release info...')
+const [LAST_RELEASE_COMMIT, LOCAL_HEAD] = await validateGitStatus(spawnOptions)
+try {
+  console.log('Local git repository is ready, starting release process...')
+  await pickSemverness(spawnOptions, LAST_RELEASE_COMMIT, deferredReleaseFile, process.env.PACKAGES.split(' '))
+  console.log('Working on the changelog...')
+  await formatChangeLog(spawnOptions, LAST_RELEASE_COMMIT, temporaryChangeLog)
+  console.log('Final step...')
+  await commit(spawnOptions, deferredReleaseFile, temporaryChangeLog)
+} finally {
+  console.log('Rewinding git history...')
+  await rewindGitHistory(spawnOptions, LOCAL_HEAD)
+}

+ 16 - 0
private/release/package.json

@@ -0,0 +1,16 @@
+{
+	"name": "@uppy-build/release",
+	"version": "0.0.0",
+	"private": true,
+	"type": "module",
+	"devDependencies": {
+		"globby": "^12.0.2",
+		"node-fetch": "^3.1.0",
+		"prompts": "^2.4.2"
+	},
+	"scripts": {
+		"interactive": "node ./interactive.js",
+		"update-changelogs": "node ./updateChangelogs.js",
+		"update-version-URLs": "node ./afterVersionBump.js"
+	}
+}

+ 110 - 0
private/release/updateChangelogs.js

@@ -0,0 +1,110 @@
+#!/usr/bin/env node
+
+import { createReadStream, promises as fs } from 'node:fs'
+import { createInterface } from 'node:readline'
+import process from 'node:process'
+
+const ROOT = new URL('../../', import.meta.url)
+const PACKAGES_FOLDER = new URL('./packages/', ROOT)
+
+const releasedDate = new Date().toISOString().slice(0, 10)
+
+const releases = JSON.parse(
+  await fs.readFile(new URL(process.argv[2], ROOT), 'utf-8'),
+)
+const uppyRelease = releases.find(({ ident }) => ident === 'uppy')
+
+const changelog = await fs.open(new URL('./CHANGELOG.md', ROOT), 'r+')
+
+const changelogContent = await changelog.readFile()
+
+const mostRecentReleaseHeading = changelogContent.indexOf('\n## ')
+
+function* makeTable (versions) {
+  const pkgNameMaxLength = Math.max('Package'.length, ...versions.map(pkg => pkg.ident.length))
+  const pkgVersionMaxLength = Math.max('Version'.length, ...versions.map(pkg => pkg.newVersion.length))
+  const makeRow = (...cells) => `| ${cells.map((cell, i) => cell[i % 2 ? 'padStart' : 'padEnd'](i % 2 ? pkgVersionMaxLength : pkgNameMaxLength)).join(' | ')} |`
+
+  yield makeRow('Package', 'Version', 'Package', 'Version')
+  yield makeRow(...Array.from({ length:4 }, (_, i) => '-'.repeat(i % 2 ? pkgVersionMaxLength : pkgNameMaxLength)))
+
+  const mid = Math.ceil(versions.length / 2)
+  for (let i = 0; i < mid; i++) {
+    const left = versions[i] || { ident: '', newVersion: '' }
+    const right = versions[i + mid] || { ident: '', newVersion: '' }
+    yield makeRow(left.ident, left.newVersion, right.ident, right.newVersion)
+  }
+}
+
+/**
+ * Opens the changelog of a given package, creating it if it doesn't exist.
+ *
+ * @param {string} pkg Package name
+ * @returns {Promise<fs.FileHandle>}
+ */
+async function updateSubPackageChangelog (pkg, lines, subsetOfLines) {
+  const packageReleaseInfo = releases.find(({ ident }) => ident === pkg)
+  if (packageReleaseInfo == null) {
+    console.warn(pkg, 'is not being released')
+    return null
+  }
+  const { newVersion } = packageReleaseInfo
+  const url = new URL(`./${pkg}/CHANGELOG.md`, PACKAGES_FOLDER)
+  const heading = Buffer.from(`# ${pkg}\n`)
+  let fh
+  let oldContent
+  try {
+    fh = await fs.open(url, 'r+') // this will throw if the file doesn't exist
+    oldContent = await fh.readFile()
+  } catch (e) {
+    if (e.code !== 'ENOENT') {
+      throw e
+    }
+    // Creates the file if it doesn't exist yet.
+    fh = await fs.open(url, 'wx')
+    await fh.writeFile(heading)
+  }
+  const { bytesWritten } = await fh.write(`
+## ${newVersion}
+
+Released: ${releasedDate}
+Included in: Uppy v${uppyRelease.newVersion}
+
+${subsetOfLines.map(index => lines[index]).join('\n')}
+`, heading.byteLength)
+  if (oldContent != null) {
+    await fh.write(oldContent, heading.byteLength, undefined, bytesWritten + heading.byteLength)
+  }
+  console.log(`packages/${pkg}/CHANGELOG.md`) // outputing the relative path of the file to git add it.
+  return fh.close()
+}
+
+const subPackagesChangelogs = {}
+const lines = []
+for await (const line of createInterface({
+  input: createReadStream(new URL('./CHANGELOG.next.md', ROOT)),
+})) {
+  const index = lines.push(line) - 1
+  for (const pkg of line.slice(2, line.indexOf(':')).split(',')) {
+    subPackagesChangelogs[pkg] ??= []
+    subPackagesChangelogs[pkg].push(index)
+  }
+}
+
+await changelog.write(`
+## ${uppyRelease.newVersion}
+
+Released: ${releasedDate}
+
+${Array.from(makeTable(releases)).join('\n')}
+
+${lines.join('\n')}
+
+${changelogContent.slice(mostRecentReleaseHeading)}`, mostRecentReleaseHeading)
+console.log('CHANGELOG.md') // outputing the relative path of the file to git add it.
+await changelog.close()
+
+await Promise.all(
+  Object.entries(subPackagesChangelogs)
+    .map(([pkg, subsetOfLines]) => updateSubPackageChangelog(pkg, lines, subsetOfLines)),
+)

+ 1 - 0
private/remark-lint-uppy/package.json

@@ -1,5 +1,6 @@
 {
   "name": "remark-lint-uppy",
+  "version": "0.0.0",
   "main": "index.js",
   "dependencies": {
     "mdast-comment-marker": "^2.1.0",

+ 33 - 33
website/src/_template/contributing.md

@@ -4,14 +4,25 @@
 
 Fork the repository into your own account first. See the [GitHub Help](https://help.github.com/articles/fork-a-repo/) article for instructions.
 
-After you have successfully forked the repo, clone and install the project:
+After you have successfully forked the repository, clone it locally.
 
-```bash
-git clone git@github.com:YOUR_USERNAME/uppy.git
+```sh
+git clone https://github.com/transloadit/uppy.git
 cd uppy
-yarn install
 ```
 
+We are using [Corepack][] to manage version of [Yarn][]. Corepack comes pre-installed with Node.js >=16.x, or can be installed through `npm`. You can run `corepack enable` to install a `yarn` executable in your `$PATH`, or prefix all yarn commands with `corepack yarn`.
+
+```sh
+corepack -v || npm i -g corepack
+yarn -v || corepack enable
+yarn install || corepack yarn install
+```
+
+[Corepack]: https://nodejs.org/api/corepack.html
+
+[Yarn]: https://yarnpkg.com/
+
 Our website’s examples section is also our playground, please read the [Local Previews](#Local-previews) section to get up and running.
 
 ### Requiring files
@@ -27,10 +38,9 @@ Unit tests are using Jest and can be run with:
 yarn run test:unit
 ```
 
-For end-to-end tests, we use [Webdriverio](http://webdriver.io). For it to run locally, you need to install a Selenium standalone server. Follow [the Webdriverio guide](http://webdriver.io/guide.html) to do so. You can also install a Selenium standalone server from NPM:
+For end-to-end tests, we use [Webdriverio](http://webdriver.io). For it to run locally, you need to install a Selenium standalone server. Follow [the Webdriverio guide](https://webdriver.io/docs/selenium-standalone-service) to do so. You can also install a Selenium standalone server from NPM:
 
 ```bash
-# or yarn add selenium-standalone -g
 npm install selenium-standalone -g
 selenium-standalone install
 ```
@@ -53,7 +63,7 @@ By default, `test:endtoend:local` uses Firefox. You can use a different browser,
 yarn run test:endtoend:local -- -b chrome
 ```
 
-> Note: The `--` is important, it tells `yarn` that the remaining arguments should be interpreted by the script itself, not by `yarn`.
+> Note: The `--` is important, it tells yarn that the remaining arguments should be interpreted by the script itself, not by yarn.
 
 You can run in several browsers by passing several `-b` flags:
 
@@ -135,27 +145,17 @@ yarn install
 yarn start
 ```
 
-Releases are managed by [Lerna](https://github.com/lerna/lerna). We do some cleanup and compile work around releases too. Use the npm release script:
-
-```bash
-yarn run release
-```
-
-If you have two-factor authentication enabled on your account, Lerna will ask for a one-time password. You may stumble upon a known issue with the CLI where the OTP prompt may be obscured by a publishing progress bar. If Lerna appears to freeze as it starts publishing, chances are it’s waiting for the password. Try typing in your OTP and hitting enter.
-
-Other things to keep in mind during release:
-
-* When doing a major release >= 1.0, of the `@uppy/core` package, the `peerDependency` of the plugin packages needs to be updated first. Eg when updating from 1.y.z to 2.0.0, the peerDependency of each should be `"@uppy/core": "^2.0.0"` before doing `yarn release`.
-* When adding a new package, add the following key to its package.json:
-  ```json
-  "publishConfig": { "access": "public" }
-  ```
-  Else, npm will try and fail to publish a _private_ package, because the `@uppy` scope on npm does not support that.
-
-After a release, the demos on transloadit.com should also be updated. After updating, check that some things work locally:
+Releases are managed by GitHub Actions, here’s an overview of the process to release a new Uppy version:
 
-* the demos in the demo section work (try one that uses an import robot, and one that you need to upload to)
-* the demos on the homepage work and can import from Google Drive, Instagram, Dropbox, etc.
+* Run `yarn release` on your local machine.
+* Follow the instructions and select what packages to release.
+* Before committing, check if the generated files look good.
+* Push to the Transloadit repository using the command given by the tool. Do not open a PR yourself, the GitHub Actions will create one and assign you to it.
+* Wait for all the GitHub Actions checks to pass. If one fails, try to figure out why. Do not go ahead without consulting the rest of the team.
+* Review the PR thoroughly, and if everything looks good to you, approve the PR. Do not merge it manually!
+* After the PR is automatically merged, the demos on transloadit.com should also be updated. Check that some things work locally:
+  * the demos in the demo section work (try one that uses an import robot, and one that you need to upload to)
+  * the demos on the homepage work and can import from Google Drive, Instagram, Dropbox, etc.
 
 If you don’t have access to the transloadit.com source code ping @arturi or @goto-bus-stop and we’ll pick it up. :sparkles:
 
@@ -318,15 +318,15 @@ Your `package.json` should resemble something like this:
 {
   "name": "@uppy/framework",
   "dependencies": {
-    "@uppy/dashboard": "file:../dashboard",
-    "@uppy/drag-drop": "file:../drag-drop",
-    "@uppy/progress-bar": "file:../progress-bar",
-    "@uppy/status-bar": "file:../status-bar",
-    "@uppy/utils": "file:../utils",
+    "@uppy/dashboard": "workspace:^",
+    "@uppy/drag-drop": "workspace:^",
+    "@uppy/progress-bar": "workspace:^",
+    "@uppy/status-bar": "workspace:^",
+    "@uppy/utils": "workspace:^",
     "prop-types": "^15.6.1"
   },
   "peerDependencies": {
-    "@uppy/core": "^2.0.0"
+    "@uppy/core": "workspace:^"
   },
   "publishConfig": {
     "access": "public"

+ 2 - 2
website/src/docs/companion.md

@@ -307,7 +307,7 @@ const options = {
 
 2. **secret(recommended)** - A secret string which Companion uses to generate authorization tokens.
 
-3. **uploadUrls(recommended)** - An allowlist (array) of strings (exact URLs) or regular expressions. If specified, Companion will only accept uploads to these URLs. This is needed to make sure a Companion instance is only allowed to upload to your servers. **Omitting this leaves your system open to potential [SSRF](https://en.wikipedia.org/wiki/Server-side\_request\_forgery) attacks, and may throw an error in future `@uppy/companion` releases.**
+3. **uploadUrls(recommended)** - An allowlist (array) of strings (exact URLs) or regular expressions. If specified, Companion will only accept uploads to these URLs. This is needed to make sure a Companion instance is only allowed to upload to your servers. **Omitting this leaves your system open to potential [SSRF](https://en.wikipedia.org/wiki/Server-side_request_forgery) attacks, and may throw an error in future `@uppy/companion` releases.**
 
 4. **redisUrl(optional)** - URL to running Redis server. If this is set, the state of uploads would be stored temporarily. This helps for resumed uploads after a browser crash from the client. The stored upload would be sent back to the client on reconnection.
 
@@ -455,7 +455,7 @@ To work well with Companion, the **module** must be a class with the following m
    `token` - authorization token (retrieved from oauth process) to send along with your request
    * `directory` - the id/name of the directory from which data is to be retrieved. This may be ignored if it doesn’t apply to your provider
    * `query` - expressjs query params object received by the server (in case some data you need in there).
-2. `async download ({ token, id, query })` - Downloads a particular file from the provider. Returns an object with a single property `{ stream }` - a [`stream.Readable`](https://nodejs.org/api/stream.html#stream\_class\_stream\_readable), which will be read from and uploaded to the destination. To prevent memory leaks, make sure you release your stream if you reject this method with an error.
+2. `async download ({ token, id, query })` - Downloads a particular file from the provider. Returns an object with a single property `{ stream }` - a [`stream.Readable`](https://nodejs.org/api/stream.html#stream_class_stream_readable), which will be read from and uploaded to the destination. To prevent memory leaks, make sure you release your stream if you reject this method with an error.
    * `token` - authorization token (retrieved from oauth process) to send along with your request.
    * `id` - ID of the file being downloaded.
    * `query` - expressjs query params object received by the server (in case some data you need in there).

Різницю між файлами не показано, бо вона завелика
+ 17 - 878
yarn.lock


Деякі файли не було показано, через те що забагато файлів було змінено