diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index aed480e3c417..fdf7fe65a967 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -177,6 +177,15 @@ jobs:
           name: npm-package
           path: ./package.tar.gz
 
+      - name: Publish npm package with PR number and commit SHA
+        run: yarn publish:npm
+        env:
+          ENVIRONMENT: "development"
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+          NPM_TAG: ${{ github.event.number }}
+          PR_NUMBER_AND_COMMIT_SHA: ${{ github.event.number }}-${{ github.event.pull_request.head.sha }}
+
   # TODO: cache building yarn --production
   # possibly 2m30s of savings(?)
   # this requires refactoring our release scripts
diff --git a/.github/workflows/npm-brew.yaml b/.github/workflows/npm-brew.yaml
index fbc276a5b1dc..1bb54281bcab 100644
--- a/.github/workflows/npm-brew.yaml
+++ b/.github/workflows/npm-brew.yaml
@@ -16,11 +16,13 @@ jobs:
     steps:
       - uses: actions/checkout@v2
 
-      - name: Run ./ci/steps/publish-npm.sh
-        run: ./ci/steps/publish-npm.sh
+      - name: Publish npm package with PR number and commit SHA
+        run: yarn publish:npm
         env:
+          ENVIRONMENT: "production"
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+          NPM_TAG: "latest"
 
   homebrew:
     # The newest version of code-server needs to be available on npm when this runs
diff --git a/.github/workflows/npm-dev.yaml b/.github/workflows/npm-dev.yaml
new file mode 100644
index 000000000000..b466c15a77da
--- /dev/null
+++ b/.github/workflows/npm-dev.yaml
@@ -0,0 +1,27 @@
+name: Publish on npm and tag with "beta"
+
+on:
+  # Shows the manual trigger in GitHub UI
+  # helpful as a back-up in case the GitHub Actions Workflow fails
+  workflow_dispatch:
+
+  push:
+    branches:
+      - main
+
+jobs:
+  # NOTE: this job requires curl, jq and yarn
+  # All of them are included in ubuntu-latest.
+  npm:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+
+      - name: Run ./ci/steps/publish-npm.sh
+        run: yarn publish:npm
+        env:
+          ENVIRONMENT: "staging"
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+          NPM_TAG: "beta"
+          PR_NUMBER_AND_COMMIT_SHA: ${{ github.event.number }}-${{ github.event.pull_request.head.sha }}
diff --git a/ci/steps/publish-npm.sh b/ci/steps/publish-npm.sh
index 0b3d8bd02c3a..a4636db5a36e 100755
--- a/ci/steps/publish-npm.sh
+++ b/ci/steps/publish-npm.sh
@@ -4,15 +4,60 @@ set -euo pipefail
 main() {
   cd "$(dirname "$0")/../.."
   source ./ci/lib.sh
+  source ./ci/steps/steps-lib.sh
 
-  # npm view won't exit with non-zero so we have to check the output.
-  local hasVersion
-  hasVersion=$(npm view "code-server@$VERSION" version)
-  if [[ $hasVersion == "$VERSION" ]]; then
-    echo "$VERSION is already published"
-    return
+  ## Authentication tokens
+  # Needed to publish on NPM
+  if ! is_env_var_set "NPM_TOKEN"; then
+    echo "NPM_TOKEN is not set. Cannot publish to npm without credentials."
+    exit 1
+  fi
+
+  # NOTE@jsjoeio - only needed if we use the download_artifact
+  # because we talk to the GitHub API.
+  # Needed to use GitHub API
+  if ! is_env_var_set "GITHUB_TOKEN"; then
+    echo "GITHUB_TOKEN is not set. Cannot download npm release artifact without GitHub credentials."
+    exit 1
+  fi
+
+  ## Environment
+  # This string is used to determine how we should tag the npm release.
+  # Environment can be one of three choices:
+  # "development" - this means we tag with the PR number, allowing
+  # a developer to install this version with `yarn add code-server@<pr-number>`
+  # "staging" - this means we tag with `beta`, allowing
+  # a developer to install this version with `yarn add code-server@beta`
+  # "production" - this means we tag with `latest` (default), allowing
+  # a developer to install this version with `yarn add code-server@latest`
+  if ! is_env_var_set "ENVIRONMENT"; then
+    echo "ENVIRONMENT is not set. Cannot determine npm tag without ENVIRONMENT."
+    exit 1
+  fi
+
+  ## Publishing Information
+  # All the variables below are used to determine how we should publish
+  # the npm package. We also use this information for bumping the version.
+  # This is because npm won't publish your package unless it's a new version.
+  # i.e. for development, we bump the version to <current version>-<pr number>-<commit sha>
+  # example: "version": "4.0.1-4769-ad7b23cfe6ffd72914e34781ef7721b129a23040"
+  # We need the current package.json VERSION
+  if ! is_env_var_set "VERSION"; then
+    echo "VERSION is not set. Cannot publish to npm without VERSION."
+    exit 1
+  fi
+
+  # We need TAG to know what to publish under on npm
+  # Options are "latest", "beta", or "<pr number >"
+  # See Environment comments above to know when each is used.
+  if ! is_env_var_set "NPM_TAG"; then
+    echo "NPM_TAG is not set. This is needed for tagging the npm release."
+    exit 1
   fi
 
+  echo "using tag: $NPM_TAG"
+
+  # This allows us to publish to npm in CI workflows
   if [[ ${CI-} ]]; then
     echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
   fi
@@ -24,7 +69,45 @@ main() {
   # Ignore symlink when publishing npm package
   # See: https://github.com/cdr/code-server/pull/3935
   echo "node_modules.asar" > release/.npmignore
-  yarn publish --non-interactive release
+
+  # NOTES:@jsjoeio
+  # We only need to run npm version for "development" and "staging".
+  # This is because our release:prep script automatically bumps the version
+  # in the package.json and we commit it as part of the release PR.
+  if [[ "$ENVIRONMENT" == "production" ]]; then
+    NPM_VERSION="$VERSION"
+  else
+    echo "Not a production environment"
+    echo "Found environment: $ENVIRONMENT"
+    echo "Manually bumping npm version..."
+
+    if ! is_env_var_set "PR_NUMBER_AND_COMMIT_SHA"; then
+      echo "PR_NUMBER_AND_COMMIT_SHA is not set. This is needed for setting the npm version in non-production environments."
+      exit 1
+    fi
+
+    # We modify the version in the package.json
+    # to be the current version + the PR number + commit SHA
+    # Example: "version": "4.0.1-4769-ad7b23cfe6ffd72914e34781ef7721b129a23040"
+    NPM_VERSION="$VERSION-$PR_NUMBER_AND_COMMIT_SHA"
+    pushd release
+    # NOTE:@jsjoeio
+    # I originally tried to use `yarn version` but ran into issues and abandoned it.
+    npm version "$NPM_VERSION"
+    popd
+  fi
+
+  # We need to make sure we haven't already published the version.
+  # This is because npm view won't exit with non-zero so we have
+  # to check the output.
+  local hasVersion
+  hasVersion=$(npm view "code-server@$NPM_VERSION" version)
+  if [[ $hasVersion == "$NPM_VERSION" ]]; then
+    echo "$NPM_VERSION is already published"
+    return
+  fi
+
+  yarn publish --non-interactive release --tag "$NPM_TAG"
 }
 
 main "$@"
diff --git a/package.json b/package.json
index bb9ca607373f..595a34657b41 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
     "test:scripts": "./ci/dev/test-scripts.sh",
     "package": "./ci/build/build-packages.sh",
     "postinstall": "./ci/dev/postinstall.sh",
+    "publish:npm": "./ci/steps/publish-npm.sh",
     "_audit": "./ci/dev/audit.sh",
     "fmt": "./ci/dev/fmt.sh",
     "lint": "./ci/dev/lint.sh",
diff --git a/test/unit/node/settings.test.ts b/test/unit/node/settings.test.ts
index 5931c7994bd9..68bf51784fbd 100644
--- a/test/unit/node/settings.test.ts
+++ b/test/unit/node/settings.test.ts
@@ -14,7 +14,7 @@ describe("settings", () => {
     testDir = await tmpdir(testName)
   })
   describe("with invalid JSON in settings file", () => {
-    let mockSettingsFile = "coder.json"
+    const mockSettingsFile = "coder.json"
     let pathToMockSettingsFile = ""
 
     beforeEach(async () => {
@@ -33,7 +33,7 @@ describe("settings", () => {
     })
   })
   describe("with invalid settings file path", () => {
-    let mockSettingsFile = "nonExistent.json"
+    const mockSettingsFile = "nonExistent.json"
     let pathToMockSettingsFile = ""
 
     beforeEach(async () => {