diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d30de7de1b3f9dd0bb5f05682779d570c66f3f4a
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,7 @@
+lint:shell-scripts:
+  stage: test
+  image: koalaman/shellcheck-alpine:latest
+  before_script:
+    - shellcheck -V
+  script:
+    - find scripts -name "*.sh" -exec shellcheck {} \;
diff --git a/README.md b/README.md
index d776dfc8f3ef8b1989ab5e2d25ba41b4e3a616b3..afb1d264d6eb4096c0e61c89342350c94614af10 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,39 @@
-# Deploy plugin to WP.org
+# WP.org Plugin Deploy
 
+Deploy plugin updates to WordPress.org's plugin SVN. Modeled on [10up's GitHub action](https://github.com/10up/actions-wordpress/blob/598b1572d5024340f09d7efc083a65ebff3bcdef/dotorg-plugin-deploy/entrypoint.sh) of the same intent.
+
+## Configuration
+
+### `.gitlab-ci.yml`
+
+Add the following to the plugin's `.gitlab-ci.yml`:
+
+```yaml
+PluginSVN:
+  stage: deploy
+  image: containers.ethitter.com:443/docker/images/php:7.3
+  before_script:
+    - apt-get update
+    - apt-get install -y rsync
+  script: ./bin/deploy.sh
+  when: on_success
+```
+
+While unnecessary, if you'd rather save the time of testing the deploy, append the following to the CI job's configuration:
+
+```yaml
+only:
+  - master
+```
+
+The above is a time-save only; the build script exits before the `svn commit` stage if the merge isn't into `master`. 
+
+### CI Environment Variables
+
+Set the following environment variables in the GitLab project's configuration:
+
+* `WP_ORG_PASSWORD`
+* `WP_ORG_PASSWORD`
+* `PLUGIN_SLUG` - plugin's name on WordPress.org
+* `PLUGIN_VERSION` - version to tag
+* `WP_ORG_RELEASE_REF` - commit ref (branch or tag) to use for release 
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
new file mode 100644
index 0000000000000000000000000000000000000000..41b276eb16091eb81674645fc0082d982ac139e1
--- /dev/null
+++ b/scripts/deploy.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+
+# Note that this does not use pipefail
+# because if the grep later doesn't match any deleted files,
+# which is likely the majority case,
+# it does not exit with a 0, and I only care about the final exit.
+set -eo
+
+# Ensure SVN username and password are set
+# IMPORTANT: while secrets are encrypted and not viewable in the GitHub UI,
+# they are by necessity provided as plaintext in the context of the Action,
+# so do not echo or use debug mode unless you want your secrets exposed!
+if [[ -z "$CI" ]]; then
+	echo "Script is only to be run by GitLab CI" 1>&2
+	exit 1
+fi
+
+if [[ -z "$WP_ORG_USERNAME" ]]; then
+	echo "WordPress.org username not set" 1>&2
+	exit 1
+fi
+
+if [[ -z "$WP_ORG_PASSWORD" ]]; then
+	echo "WordPress.org password not set" 1>&2
+	exit 1
+fi
+
+if [[ -z "$PLUGIN_SLUG" ]]; then
+	echo "Plugin's SVN slug is not set" 1>&2
+	exit 1
+fi
+
+if [[ -z "$PLUGIN_VERSION" ]]; then
+	echo "Plugin's version is not set" 1>&2
+	exit 1
+fi
+
+echo "ℹ︎ PLUGIN_SLUG is $PLUGIN_SLUG"
+echo "ℹ︎ PLUGIN_VERSION is $PLUGIN_VERSION"
+
+SVN_URL="https://plugins.svn.wordpress.org/${PLUGIN_SLUG}/"
+SVN_DIR="$CI_BUILDS_DIR/svn-${PLUGIN_SLUG}"
+TMP_DIR="$CI_BUILDS_DIR/git-archive"
+
+# Checkout just trunk for efficiency
+# Tagging will be handled on the SVN level
+echo "➤ Checking out .org repository..."
+svn checkout --depth immediates "$SVN_URL" "$SVN_DIR"
+cd "$SVN_DIR"
+svn update --set-depth infinity trunk
+
+# Ensure we are in the $CI_PROJECT_DIR directory, just in case
+echo "➤ Copying files..."
+cd "$CI_PROJECT_DIR"
+
+git config --global user.email "git-contrib+ci@ethitter.com"
+git config --global user.name "Erick Hitter (GitLab CI)"
+
+# If there's no .gitattributes file, write a default one into place
+if [[ ! -e "$CI_PROJECT_DIR/.gitattributes" ]]; then
+	cat > "$CI_PROJECT_DIR/.gitattributes" <<-EOL
+	/.gitattributes export-ignore
+	/.gitignore export-ignore
+	/.github export-ignore
+	EOL
+
+	# The .gitattributes file has to be committed to be used
+	# Just don't push it to the origin repo :)
+	git add .gitattributes && git commit -m "Add .gitattributes file"
+fi
+
+# This will exclude everything in the .gitattributes file with the export-ignore flag
+mkdir "$TMP_DIR"
+git archive HEAD | tar x --directory="$TMP_DIR"
+
+cd "$SVN_DIR"
+
+# Copy from clean copy to /trunk
+# The --delete flag will delete anything in destination that no longer exists in source
+rsync -r "$TMP_DIR/" trunk/ --delete
+
+# Add everything and commit to SVN
+# The force flag ensures we recurse into subdirectories even if they are already added
+# Suppress stdout in favor of svn status later for readability
+echo "➤ Preparing files..."
+svn add . --force > /dev/null
+
+# SVN delete all deleted files
+# Also suppress stdout here
+svn status | grep '^\!' | sed 's/! *//' | xargs -I% svn rm % > /dev/null
+
+# Copy tag locally to make this a single commit
+echo "➤ Copying tag..."
+svn cp "trunk" "tags/$PLUGIN_VERSION"
+
+svn status
+
+# Stop here unless this is a merge into master.
+if [[ -z "$CI_COMMIT_REF_NAME" || -z "$WP_ORG_RELEASE_REF" || "$CI_COMMIT_REF_NAME" != "$WP_ORG_RELEASE_REF" ]]; then
+	echo "𝘅︎ EXITING before commit step as this is the '${CI_COMMIT_REF_NAME}' ref, not the '${WP_ORG_RELEASE_TAG}' ref." 1>&2
+	exit 0
+fi
+
+echo "➤ Committing files..."
+svn commit -m "Update to version ${PLUGIN_VERSION} from GitLab (${CI_PROJECT_URL}; ${CI_JOB_URL})" --no-auth-cache --non-interactive  --username "$WP_ORG_USERNAME" --password "$WP_ORG_PASSWORD"
+
+echo "✓ Plugin deployed!"