diff --git a/.github/workflows/build-test-deploy.yml b/.github/workflows/build-test-deploy.yml
index a1ba91bf4..ae7f556ae 100644
--- a/.github/workflows/build-test-deploy.yml
+++ b/.github/workflows/build-test-deploy.yml
@@ -671,6 +671,7 @@ jobs:
VORTEX_DEPLOY_BRANCH: ${{ env.DEPLOY_BRANCH || github.head_ref || github.ref_name }}
VORTEX_DEPLOY_PR: ${{ env.DEPLOY_PR_NUMBER || github.event.number }}
VORTEX_DEPLOY_PR_HEAD: ${{ env.DEPLOY_PR_HEAD_SHA || github.event.pull_request.head.sha }}
+ VORTEX_DEPLOY_PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
VORTEX_DEPLOY_ARTIFACT_SRC: /tmp/workspace/code
VORTEX_DEPLOY_ARTIFACT_ROOT: ${{ github.workspace }}
VORTEX_DEPLOY_ARTIFACT_GIT_REMOTE: ${{ vars.VORTEX_DEPLOY_ARTIFACT_GIT_REMOTE }}
@@ -681,6 +682,7 @@ jobs:
VORTEX_DEPLOY_ALLOW_SKIP: ${{ vars.VORTEX_DEPLOY_ALLOW_SKIP }}
VORTEX_DEPLOY_SKIP_PRS: ${{ vars.VORTEX_DEPLOY_SKIP_PRS }}
VORTEX_DEPLOY_SKIP_BRANCHES: ${{ vars.VORTEX_DEPLOY_SKIP_BRANCHES }}
+ VORTEX_DEPLOY_ALLOW_LABEL: ${{ vars.VORTEX_DEPLOY_ALLOW_LABEL }}
VORTEX_DEPLOY_ACTION: ${{ inputs.override_db && 'deploy_override_db' || '' }}
timeout-minutes: 30
#;> DEPLOYMENT
diff --git a/.vortex/docs/.utils/variables/extra/ci.variables.sh b/.vortex/docs/.utils/variables/extra/ci.variables.sh
index bdee75805..b7fc9b62a 100755
--- a/.vortex/docs/.utils/variables/extra/ci.variables.sh
+++ b/.vortex/docs/.utils/variables/extra/ci.variables.sh
@@ -15,6 +15,9 @@ VORTEX_DEPLOY_SKIP_PRS=
# Branch names to skip deployment for (single value or comma-separated list).
VORTEX_DEPLOY_SKIP_BRANCHES=
+# Label that authorizes a pull request deployment. When set, a PR is deployed only if it carries this label.
+VORTEX_DEPLOY_ALLOW_LABEL=
+
# Proceed with container image deployment after it was exported.
VORTEX_EXPORT_DB_CONTAINER_REGISTRY_DEPLOY_PROCEED=
diff --git a/.vortex/docs/content/deployment/README.mdx b/.vortex/docs/content/deployment/README.mdx
index baca951dd..d65dd62bc 100644
--- a/.vortex/docs/content/deployment/README.mdx
+++ b/.vortex/docs/content/deployment/README.mdx
@@ -126,6 +126,38 @@ VORTEX_DEPLOY_SKIP_PRS=42,123
VORTEX_DEPLOY_SKIP_BRANCHES=feature/test,hotfix/urgent
```
+## Gating deployments on a PR label
+
+Skipping is subtractive - everything deploys unless you exclude it. The label
+gate is the opposite: it makes a pull request deployment **opt-in**, so a PR
+reaches an environment only when it is explicitly labeled for it. This is
+useful for controlling the number and cost of per-PR environments, or for
+keeping work-in-progress pull requests out of environments.
+
+Set `$VORTEX_DEPLOY_ALLOW_LABEL` to the name of the label that authorizes
+deployment:
+
+```shell
+VORTEX_DEPLOY_ALLOW_LABEL=deploy
+```
+
+With this set, a pull request is deployed only if it carries the `deploy`
+label; pull requests without it are skipped. When the variable is empty or
+unset (the default), the gate is inactive and deployments behave as before.
+
+The gate applies only to pull request builds. Branch and tag deployments have
+no associated pull request label and are never gated.
+
+The pull request's labels are read from `$VORTEX_DEPLOY_PR_LABELS`, a
+comma-separated list that the CI provider populates from the pull request
+event. On GitHub Actions this is wired up automatically. On CircleCI the
+labels are not available natively, so populate `$VORTEX_DEPLOY_PR_LABELS`
+yourself (for example, from the GitHub API) to use the gate.
+
+The label gate and the skip lists can be combined. A skip-list match is
+evaluated first and always wins, so a pull request listed in
+`$VORTEX_DEPLOY_SKIP_PRS` is skipped even when it carries the gate label.
+
## See also
| Topic | Description |
diff --git a/.vortex/docs/content/development/variables.mdx b/.vortex/docs/content/development/variables.mdx
index cbbd8e15a..f98a7b542 100644
--- a/.vortex/docs/content/development/variables.mdx
+++ b/.vortex/docs/content/development/variables.mdx
@@ -189,6 +189,7 @@ The list below is automatically generated with [Shellvar](https://github.com/ale
| `VORTEX_DB_IMAGE` | Name of the database container image to use.
See https://github.com/drevops/mariadb-drupal-data to seed your DB image. | `UNDEFINED` | `.env` |
| `VORTEX_DB_IMAGE_BASE` | Name of the database fall-back container image to use.
If the image specified in [`$VORTEX_DB_IMAGE`](#vortex_db_image) does not exist and base image was provided - it will be used as a "clean slate" for the database. | `UNDEFINED` | `.env` |
| `VORTEX_DEBUG` | Set to `1` to print debug information in Vortex scripts. | `UNDEFINED` | `.env.local.example` |
+| `VORTEX_DEPLOY_ALLOW_LABEL` | Label that authorizes a pull request deployment. When set, a PR is deployed only if it carries this label. | `UNDEFINED` | `CI config` |
| `VORTEX_DEPLOY_ALLOW_SKIP` | Flag to allow skipping of a deployment using additional flags. | `UNDEFINED` | `CI config` |
| `VORTEX_DEPLOY_SKIP` | Skip all deployments. | `UNDEFINED` | `CI config` |
| `VORTEX_DEPLOY_SKIP_BRANCHES` | Branch names to skip deployment for (single value or comma-separated list). | `UNDEFINED` | `CI config` |
diff --git a/.vortex/installer/tests/Fixtures/handler_process/_baseline/.github/workflows/build-test-deploy.yml b/.vortex/installer/tests/Fixtures/handler_process/_baseline/.github/workflows/build-test-deploy.yml
index f8aef8806..af7e487d7 100644
--- a/.vortex/installer/tests/Fixtures/handler_process/_baseline/.github/workflows/build-test-deploy.yml
+++ b/.vortex/installer/tests/Fixtures/handler_process/_baseline/.github/workflows/build-test-deploy.yml
@@ -598,6 +598,7 @@ jobs:
VORTEX_DEPLOY_BRANCH: ${{ env.DEPLOY_BRANCH || github.head_ref || github.ref_name }}
VORTEX_DEPLOY_PR: ${{ env.DEPLOY_PR_NUMBER || github.event.number }}
VORTEX_DEPLOY_PR_HEAD: ${{ env.DEPLOY_PR_HEAD_SHA || github.event.pull_request.head.sha }}
+ VORTEX_DEPLOY_PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
VORTEX_DEPLOY_ARTIFACT_SRC: /tmp/workspace/code
VORTEX_DEPLOY_ARTIFACT_ROOT: ${{ github.workspace }}
VORTEX_DEPLOY_ARTIFACT_GIT_REMOTE: ${{ vars.VORTEX_DEPLOY_ARTIFACT_GIT_REMOTE }}
@@ -608,5 +609,6 @@ jobs:
VORTEX_DEPLOY_ALLOW_SKIP: ${{ vars.VORTEX_DEPLOY_ALLOW_SKIP }}
VORTEX_DEPLOY_SKIP_PRS: ${{ vars.VORTEX_DEPLOY_SKIP_PRS }}
VORTEX_DEPLOY_SKIP_BRANCHES: ${{ vars.VORTEX_DEPLOY_SKIP_BRANCHES }}
+ VORTEX_DEPLOY_ALLOW_LABEL: ${{ vars.VORTEX_DEPLOY_ALLOW_LABEL }}
VORTEX_DEPLOY_ACTION: ${{ inputs.override_db && 'deploy_override_db' || '' }}
timeout-minutes: 30
diff --git a/.vortex/installer/tests/Fixtures/handler_process/deploy_types_none_gha/.github/workflows/build-test-deploy.yml b/.vortex/installer/tests/Fixtures/handler_process/deploy_types_none_gha/.github/workflows/build-test-deploy.yml
index ef693734b..120a4426a 100644
--- a/.vortex/installer/tests/Fixtures/handler_process/deploy_types_none_gha/.github/workflows/build-test-deploy.yml
+++ b/.vortex/installer/tests/Fixtures/handler_process/deploy_types_none_gha/.github/workflows/build-test-deploy.yml
@@ -1,4 +1,4 @@
-@@ -515,98 +515,3 @@
+@@ -515,100 +515,3 @@
timeout-minutes: 120 # Cancel the action after 120 minutes, regardless of whether a connection has been established.
with:
detached: true
@@ -85,6 +85,7 @@
- VORTEX_DEPLOY_BRANCH: ${{ env.DEPLOY_BRANCH || github.head_ref || github.ref_name }}
- VORTEX_DEPLOY_PR: ${{ env.DEPLOY_PR_NUMBER || github.event.number }}
- VORTEX_DEPLOY_PR_HEAD: ${{ env.DEPLOY_PR_HEAD_SHA || github.event.pull_request.head.sha }}
+- VORTEX_DEPLOY_PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
- VORTEX_DEPLOY_ARTIFACT_SRC: /tmp/workspace/code
- VORTEX_DEPLOY_ARTIFACT_ROOT: ${{ github.workspace }}
- VORTEX_DEPLOY_ARTIFACT_GIT_REMOTE: ${{ vars.VORTEX_DEPLOY_ARTIFACT_GIT_REMOTE }}
@@ -95,5 +96,6 @@
- VORTEX_DEPLOY_ALLOW_SKIP: ${{ vars.VORTEX_DEPLOY_ALLOW_SKIP }}
- VORTEX_DEPLOY_SKIP_PRS: ${{ vars.VORTEX_DEPLOY_SKIP_PRS }}
- VORTEX_DEPLOY_SKIP_BRANCHES: ${{ vars.VORTEX_DEPLOY_SKIP_BRANCHES }}
+- VORTEX_DEPLOY_ALLOW_LABEL: ${{ vars.VORTEX_DEPLOY_ALLOW_LABEL }}
- VORTEX_DEPLOY_ACTION: ${{ inputs.override_db && 'deploy_override_db' || '' }}
- timeout-minutes: 30
diff --git a/.vortex/tooling/src/deploy b/.vortex/tooling/src/deploy
index 9b6268e06..6df068c15 100755
--- a/.vortex/tooling/src/deploy
+++ b/.vortex/tooling/src/deploy
@@ -49,6 +49,21 @@ VORTEX_DEPLOY_PR="${VORTEX_DEPLOY_PR:-}"
# Flag to allow skipping of a deployment using additional flags.
VORTEX_DEPLOY_ALLOW_SKIP="${VORTEX_DEPLOY_ALLOW_SKIP:-}"
+# Label that authorizes a pull request deployment.
+#
+# When set to a non-empty label name, a PR deployment proceeds only if the
+# pull request carries that label; PRs without it are skipped. When empty (the
+# default), the gate is inactive and deployments behave as before. Only applies
+# to PR builds (VORTEX_DEPLOY_PR set); branch and tag deployments are unaffected.
+VORTEX_DEPLOY_ALLOW_LABEL="${VORTEX_DEPLOY_ALLOW_LABEL:-}"
+
+# Comma-separated list of labels present on the deployed pull request.
+#
+# Populated by the CI provider from the pull request event (for example, in
+# GitHub Actions from "github.event.pull_request.labels"). Consulted by the
+# VORTEX_DEPLOY_ALLOW_LABEL gate.
+VORTEX_DEPLOY_PR_LABELS="${VORTEX_DEPLOY_PR_LABELS:-}"
+
# ------------------------------------------------------------------------------
# @formatter:off
@@ -105,6 +120,22 @@ if [ "${VORTEX_DEPLOY_ALLOW_SKIP:-}" = "1" ]; then
fi
fi
+if [ -n "${VORTEX_DEPLOY_ALLOW_LABEL}" ] && [ -n "${VORTEX_DEPLOY_PR}" ]; then
+ # Gate a pull request deployment on the presence of a label.
+ #
+ # The PR's labels are provided as a comma-separated list in
+ # $VORTEX_DEPLOY_PR_LABELS, populated by the CI provider from the PR event.
+ note "Found flag to gate deployment on the '${VORTEX_DEPLOY_ALLOW_LABEL}' label."
+
+ if ! echo ",${VORTEX_DEPLOY_PR_LABELS}," | grep -qF ",${VORTEX_DEPLOY_ALLOW_LABEL},"; then
+ note "PR ${VORTEX_DEPLOY_PR} does not carry the '${VORTEX_DEPLOY_ALLOW_LABEL}' label."
+ note "Skipping deployment ${VORTEX_DEPLOY_TYPES}."
+ exit 0
+ fi
+
+ note "PR ${VORTEX_DEPLOY_PR} carries the '${VORTEX_DEPLOY_ALLOW_LABEL}' label."
+fi
+
if [ -z "${VORTEX_DEPLOY_TYPES##*artifact*}" ]; then
[ "${VORTEX_DEPLOY_MODE}" = "tag" ] && export VORTEX_DEPLOY_ARTIFACT_DST_BRANCH="deployment/[tags:-]"
"${SCRIPT_DIR}/deploy-artifact"
diff --git a/.vortex/tooling/tests/unit/deploy.bats b/.vortex/tooling/tests/unit/deploy.bats
index dd755913b..2b7d6459c 100644
--- a/.vortex/tooling/tests/unit/deploy.bats
+++ b/.vortex/tooling/tests/unit/deploy.bats
@@ -306,3 +306,171 @@ load ../_helper.bash
popd >/dev/null
}
+
+@test "Label gate inactive when VORTEX_DEPLOY_ALLOW_LABEL is unset" {
+ pushd "${LOCAL_REPO_DIR}" >/dev/null || exit 1
+
+ export VORTEX_DEPLOY_TYPES="webhook"
+ export VORTEX_DEPLOY_WEBHOOK_URL="https://example.com"
+ export VORTEX_DEPLOY_PR="123"
+ export VORTEX_DEPLOY_PR_LABELS="bug,enhancement"
+
+ mock_deploy_webhook=$(mock_command ".vortex/tooling/src/deploy-webhook")
+ mock_set_output "${mock_deploy_webhook}" "Webhook deployment completed" 0
+
+ run .vortex/tooling/src/deploy
+ assert_success
+ assert_output_not_contains "Found flag to gate deployment"
+ assert_output_not_contains "Skipping deployment"
+
+ popd >/dev/null
+}
+
+@test "Deployment proceeds when PR carries the required label" {
+ pushd "${LOCAL_REPO_DIR}" >/dev/null || exit 1
+
+ export VORTEX_DEPLOY_TYPES="webhook"
+ export VORTEX_DEPLOY_WEBHOOK_URL="https://example.com"
+ export VORTEX_DEPLOY_PR="123"
+ export VORTEX_DEPLOY_ALLOW_LABEL="deploy"
+ export VORTEX_DEPLOY_PR_LABELS="deploy"
+
+ mock_deploy_webhook=$(mock_command ".vortex/tooling/src/deploy-webhook")
+ mock_set_output "${mock_deploy_webhook}" "Webhook deployment completed" 0
+
+ run .vortex/tooling/src/deploy
+ assert_success
+ assert_output_contains "Found flag to gate deployment on the 'deploy' label."
+ assert_output_contains "PR 123 carries the 'deploy' label."
+ assert_output_not_contains "Skipping deployment"
+
+ popd >/dev/null
+}
+
+@test "Deployment skipped when PR lacks the required label" {
+ pushd "${LOCAL_REPO_DIR}" >/dev/null || exit 1
+
+ export VORTEX_DEPLOY_TYPES="webhook"
+ export VORTEX_DEPLOY_PR="123"
+ export VORTEX_DEPLOY_ALLOW_LABEL="deploy"
+ export VORTEX_DEPLOY_PR_LABELS="bug,enhancement"
+
+ run .vortex/tooling/src/deploy
+ assert_success
+ assert_output_contains "Found flag to gate deployment on the 'deploy' label."
+ assert_output_contains "PR 123 does not carry the 'deploy' label."
+ assert_output_contains "Skipping deployment webhook."
+
+ popd >/dev/null
+}
+
+@test "Label gate does not apply to non-PR deployments" {
+ pushd "${LOCAL_REPO_DIR}" >/dev/null || exit 1
+
+ export VORTEX_DEPLOY_TYPES="webhook"
+ export VORTEX_DEPLOY_WEBHOOK_URL="https://example.com"
+ export VORTEX_DEPLOY_ALLOW_LABEL="deploy"
+ export VORTEX_DEPLOY_PR=""
+ export VORTEX_DEPLOY_BRANCH="main"
+
+ mock_deploy_webhook=$(mock_command ".vortex/tooling/src/deploy-webhook")
+ mock_set_output "${mock_deploy_webhook}" "Webhook deployment completed" 0
+
+ run .vortex/tooling/src/deploy
+ assert_success
+ assert_output_not_contains "Found flag to gate deployment"
+ assert_output_not_contains "Skipping deployment"
+
+ popd >/dev/null
+}
+
+@test "Deployment proceeds when required label is among several PR labels" {
+ pushd "${LOCAL_REPO_DIR}" >/dev/null || exit 1
+
+ export VORTEX_DEPLOY_TYPES="webhook"
+ export VORTEX_DEPLOY_WEBHOOK_URL="https://example.com"
+ export VORTEX_DEPLOY_PR="456"
+ export VORTEX_DEPLOY_ALLOW_LABEL="deploy"
+ export VORTEX_DEPLOY_PR_LABELS="bug,deploy,enhancement"
+
+ mock_deploy_webhook=$(mock_command ".vortex/tooling/src/deploy-webhook")
+ mock_set_output "${mock_deploy_webhook}" "Webhook deployment completed" 0
+
+ run .vortex/tooling/src/deploy
+ assert_success
+ assert_output_contains "PR 456 carries the 'deploy' label."
+ assert_output_not_contains "Skipping deployment"
+
+ popd >/dev/null
+}
+
+@test "Skip list takes precedence over the label gate" {
+ pushd "${LOCAL_REPO_DIR}" >/dev/null || exit 1
+
+ export VORTEX_DEPLOY_TYPES="webhook"
+ export VORTEX_DEPLOY_ALLOW_SKIP="1"
+ export VORTEX_DEPLOY_PR="123"
+ export VORTEX_DEPLOY_SKIP_PRS="123"
+ export VORTEX_DEPLOY_ALLOW_LABEL="deploy"
+ export VORTEX_DEPLOY_PR_LABELS="deploy"
+
+ run .vortex/tooling/src/deploy
+ assert_success
+ assert_output_contains "Found PR 123 in skip list."
+ assert_output_contains "Skipping deployment webhook."
+ assert_output_not_contains "Found flag to gate deployment"
+
+ popd >/dev/null
+}
+
+@test "Deployment skipped when PR has no labels and gate is enabled" {
+ pushd "${LOCAL_REPO_DIR}" >/dev/null || exit 1
+
+ export VORTEX_DEPLOY_TYPES="webhook"
+ export VORTEX_DEPLOY_PR="123"
+ export VORTEX_DEPLOY_ALLOW_LABEL="deploy"
+ export VORTEX_DEPLOY_PR_LABELS=""
+
+ run .vortex/tooling/src/deploy
+ assert_success
+ assert_output_contains "PR 123 does not carry the 'deploy' label."
+ assert_output_contains "Skipping deployment webhook."
+
+ popd >/dev/null
+}
+
+@test "Label gate requires an exact match, not a partial one" {
+ pushd "${LOCAL_REPO_DIR}" >/dev/null || exit 1
+
+ export VORTEX_DEPLOY_TYPES="webhook"
+ export VORTEX_DEPLOY_PR="123"
+ export VORTEX_DEPLOY_ALLOW_LABEL="deploy"
+ export VORTEX_DEPLOY_PR_LABELS="deploy-preview,needs-deploy"
+
+ run .vortex/tooling/src/deploy
+ assert_success
+ assert_output_contains "PR 123 does not carry the 'deploy' label."
+ assert_output_contains "Skipping deployment webhook."
+
+ popd >/dev/null
+}
+
+@test "Label gate matches a label that contains spaces" {
+ pushd "${LOCAL_REPO_DIR}" >/dev/null || exit 1
+
+ export VORTEX_DEPLOY_TYPES="webhook"
+ export VORTEX_DEPLOY_WEBHOOK_URL="https://example.com"
+ export VORTEX_DEPLOY_PR="123"
+ export VORTEX_DEPLOY_ALLOW_LABEL="deploy to env"
+ export VORTEX_DEPLOY_PR_LABELS="bug,deploy to env,enhancement"
+
+ mock_deploy_webhook=$(mock_command ".vortex/tooling/src/deploy-webhook")
+ mock_set_output "${mock_deploy_webhook}" "Webhook deployment completed" 0
+
+ run .vortex/tooling/src/deploy
+ assert_success
+ assert_output_contains "PR 123 carries the 'deploy to env' label."
+ assert_output_not_contains "Skipping deployment"
+
+ popd >/dev/null
+}