Merge pull request #22 from KofTwentyTwo/feature/atomic-release-versions

fix: atomic release versions, immutable image tags, conditional cd_path
This commit was merged in pull request #22.
This commit is contained in:
james.maes authored and GitHub committed 2026-10-11 14:48:10 -05:00
commit 00f349cb0c
27 files changed
+752 -9

No files matched your search

+12 -1
View File
@@ -416,7 +416,18 @@ checkout
--> sast-scan (parallel)
```
For `release-candidate` workflows, `docker-build-push` runs after all quality checks pass, followed by `sbom`, `update-cd-repo` (targeting staging), and `github-release` (marked as pre-release). For `production` workflows, `docker-build-push` runs directly after `build-and-test` (no quality gates -- those were enforced on the release branch), followed by `update-cd-repo` (targeting prod) and `github-release` (full release). The `github-release` job auto-creates an annotated git tag and GitHub Release using the version calculated by GitVersion.
For `release-candidate` workflows, `docker-build-push` runs after all quality checks pass, followed by `sbom`, `update-cd-repo` (targeting staging), and `github-release` (marked as pre-release). For `production` workflows, `docker-build-push` runs directly after `build-and-test` (no quality gates -- those were enforced on the release branch), followed by `update-cd-repo` (targeting prod) and `github-release` (full release). The `github-release` job creates the GitHub Release for the version tag reserved during `build-and-test` (see Release version integrity).
### Release version integrity
Production versions on `main` are unique per commit, even when merges land minutes apart:
- **Atomic reservation** -- `build-and-test` on `main` reserves `vX.Y.Z` by creating the git tag through the GitHub API before the version is baked into artifacts. Tag creation is create-if-absent, so concurrent pipelines serialize on it: if another commit already owns the tag, the build takes the next patch version. A rerun of the same commit reuses its tag. Existing tags are never moved. Requires the GitHub context on `build-and-test` (present in the generated `production` workflows).
- **Immutable image tags** -- images carry an `org.opencontainers.image.revision` label. `docker-build-push` refuses to push a version tag (main or supplemental) that already exists for a different commit, and fails closed if the registry cannot be inspected. Environment tags such as `develop` stay mutable.
- **Ownership-checked GitOps bump** -- `update-cd-repo` verifies that `vX.Y.Z` points at the pipeline's commit before writing the version to the CD repository.
- **Release targets the built commit** -- `github-release` targets `CIRCLE_SHA1`, not the moving `main` head, and refuses if the tag points elsewhere.
`cd_path` is rendered into generated pipelines only when `cd.path` is set, so manifests that pin an older `orb_version` keep validating.
## Release Workflow
+9
View File
@@ -0,0 +1,9 @@
description: >
Atomically reserve the release version on main by creating the v<version>
git tag through the GitHub API. If another commit already owns the tag
(concurrent merges), the next patch version is reserved instead. Existing
tags are never moved. No-op on non-main branches and pre-release versions.
steps:
- run:
name: Reserve release version (atomic git tag)
command: << include(scripts/allocate_version.sh) >>
+34
View File
@@ -0,0 +1,34 @@
description: >
Refuse to overwrite immutable version tags. Fails when the main image or any
supplemental image already exists at this version for a different commit
(org.opencontainers.image.revision label). Reruns from the same commit pass.
parameters:
image_name:
type: string
description: Docker image name (without registry prefix).
registry:
type: string
default: ghcr.io/KofTwentyTwo
description: Container registry URL.
supplemental_images:
type: string
default: "[]"
description: JSON array of supplemental image definitions.
ghcr_token_env:
type: env_var_name
default: GHCR_TOKEN
description: Environment variable containing the GHCR token.
ghcr_user_env:
type: env_var_name
default: GHCR_USERNAME
description: Environment variable containing the GHCR username.
steps:
- run:
name: Guard immutable version tags
environment:
IMAGE_NAME: << parameters.image_name >>
REGISTRY: << parameters.registry >>
SUPPLEMENTAL_IMAGES_JSON: << parameters.supplemental_images >>
GHCR_TOKEN_VAR: << parameters.ghcr_token_env >>
GHCR_USER_VAR: << parameters.ghcr_user_env >>
command: << include(scripts/guard_image_tags.sh) >>
+4
View File
@@ -53,6 +53,10 @@ steps:
- run:
name: Restore version metadata
command: <<include(scripts/restore_version_metadata.sh)>>
- guard_image_tags:
image_name: << parameters.image_name >>
registry: << parameters.registry >>
supplemental_images: << parameters.supplemental_images >>
- generate_dockerfile
- docker_build:
image_name: << parameters.image_name >>
+1
View File
@@ -25,6 +25,7 @@ steps:
- restore_gradle_cache
- calculate_version
- export_version_vars
- allocate_version
- gradle_build
- gradle_test
- run:
+1
View File
@@ -25,6 +25,7 @@ steps:
- restore_maven_cache
- calculate_version
- export_version_vars
- allocate_version
- mvn_set_version
- mvn_build
- mvn_test
+1
View File
@@ -73,6 +73,7 @@ steps:
test_setup: << parameters.test_setup >>
- calculate_version
- export_version_vars
- allocate_version
- run:
name: Persist version metadata
command: <<include(scripts/persist_version_metadata.sh)>>
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
set -euo pipefail
# Source shared helpers
MUNITOR_HELPERS="${MUNITOR_HELPERS:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/munitor_helpers.sh}"
# shellcheck source=munitor_helpers.sh
if [[ -f "${MUNITOR_HELPERS}" ]]; then source "${MUNITOR_HELPERS}"
elif ! type munitor_header &>/dev/null; then
munitor_header() { echo "=== Munitor: ${1:-unknown} ==="; }
munitor_check_tool() { command -v "$1" &>/dev/null || { echo "ERROR: $1 not found"; exit 1; }; }
munitor_download_with_retry() { curl -fsSL --retry 3 "$1" -o "$2"; }
fi
munitor_header "allocate_version"
# Atomically reserve the release version for this commit.
#
# GitVersion derives the next version from the tags it can see, so two main
# merges that build concurrently both compute the same version. The reservation
# is the git tag itself: GitHub creates refs/tags/v<version> only if it does not
# exist, which serializes concurrent pipelines without a lock service.
# - created -> this commit owns the version
# - exists on this commit -> rerun of the same pipeline; reuse it
# - exists on another -> taken; try the next patch version
# An existing tag is never moved or deleted.
VERSION="${PROJECT_VERSION:?PROJECT_VERSION not set -- run export_version_vars first}"
BRANCH="${CIRCLE_BRANCH:-}"
if [[ "${BRANCH}" != "main" ]]; then
echo "Branch '${BRANCH:-none}' is not main; version ${VERSION} needs no reservation."
exit 0
fi
if [[ ! "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "Version ${VERSION} is not a release version; no reservation needed."
exit 0
fi
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
SHA="${CIRCLE_SHA1:?CIRCLE_SHA1 not set -- cannot reserve a version without the commit SHA}"
REPO="${MUNITOR_SOURCE_REPO:-${CIRCLE_PROJECT_USERNAME:-}/${CIRCLE_PROJECT_REPONAME:-}}"
MAX_ATTEMPTS="${MUNITOR_VERSION_ALLOC_ATTEMPTS:-25}"
if [[ "${REPO}" == "/" || "${REPO}" == */ || "${REPO}" == /* ]]; then
echo "ERROR: Cannot determine the source repository (CIRCLE_PROJECT_USERNAME/CIRCLE_PROJECT_REPONAME)."
exit 1
fi
if [[ -z "${MUNITOR_GH_API_CMD:-}" && -z "${GITHUB_TOKEN:-}" ]]; then
echo "ERROR: GITHUB_TOKEN is not set; release versions on main must be reserved as git tags."
echo "Add the GitHub context to the build-and-test job."
exit 1
fi
# gh_api METHOD PATH [JSON_BODY]
# Prints the response body. Returns 0 on 2xx, 2 on 404, 3 on 409/422, 1 otherwise.
# MUNITOR_GH_API_CMD substitutes the transport (used by tests).
gh_api() {
if [[ -n "${MUNITOR_GH_API_CMD:-}" ]]; then
"${MUNITOR_GH_API_CMD}" "$@"
return
fi
local method="$1" path="$2" body="${3:-}"
local out code
out=$(mktemp)
local args=(-sS -o "${out}" -w '%{http_code}' -X "${method}"
-H "Authorization: Bearer ${GITHUB_TOKEN}"
-H "Accept: application/vnd.github+json"
-H "X-GitHub-Api-Version: 2022-11-28")
if [[ -n "${body}" ]]; then
args+=(-H "Content-Type: application/json" -d "${body}")
fi
if ! code=$(curl "${args[@]}" "https://api.github.com/repos/${REPO}/${path}"); then
cat "${out}"
rm -f "${out}"
return 1
fi
cat "${out}"
rm -f "${out}"
case "${code}" in
2??) return 0 ;;
404) return 2 ;;
409|422) return 3 ;;
*) echo "GitHub API ${method} ${path} returned HTTP ${code}" >&2; return 1 ;;
esac
}
# tag_commit TAG -> prints the commit SHA the tag points at (dereferences annotated tags)
tag_commit() {
local tag="$1" ref obj_type obj_sha rc=0
ref=$(gh_api GET "git/ref/tags/${tag}") || rc=$?
if [[ ${rc} -ne 0 ]]; then
echo "ERROR: Could not read tag ${tag} (rc=${rc}): ${ref}" >&2
return 1
fi
obj_type=$(echo "${ref}" | jq -r '.object.type')
obj_sha=$(echo "${ref}" | jq -r '.object.sha')
if [[ "${obj_type}" == "tag" ]]; then
rc=0
ref=$(gh_api GET "git/tags/${obj_sha}") || rc=$?
if [[ ${rc} -ne 0 ]]; then
echo "ERROR: Could not dereference annotated tag ${tag} (rc=${rc})" >&2
return 1
fi
obj_sha=$(echo "${ref}" | jq -r '.object.sha')
fi
echo "${obj_sha}"
}
ALLOCATED=""
for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do
CANDIDATE="${MAJOR}.${MINOR}.${PATCH}"
TAG="v${CANDIDATE}"
rc=0
response=$(gh_api POST "git/refs" "{\"ref\":\"refs/tags/${TAG}\",\"sha\":\"${SHA}\"}") || rc=$?
if [[ ${rc} -eq 0 ]]; then
echo "Reserved ${TAG} for ${SHA}."
ALLOCATED="${CANDIDATE}"
break
fi
if [[ ${rc} -ne 3 ]]; then
echo "ERROR: Failed to create tag ${TAG} (rc=${rc}): ${response}"
exit 1
fi
OWNER=$(tag_commit "${TAG}")
if [[ "${OWNER}" == "${SHA}" ]]; then
echo "${TAG} already points at ${SHA} (pipeline rerun); reusing it."
ALLOCATED="${CANDIDATE}"
break
fi
echo "${TAG} is already taken by ${OWNER}; trying the next patch version."
PATCH=$((PATCH + 1))
done
if [[ -z "${ALLOCATED}" ]]; then
echo "ERROR: Could not reserve a release version after ${MAX_ATTEMPTS} attempts (last tried ${TAG})."
exit 1
fi
if [[ "${ALLOCATED}" != "${VERSION}" ]]; then
echo "GitVersion computed ${VERSION}, but it belongs to another commit; this build is ${ALLOCATED}."
fi
{
echo "export PROJECT_VERSION='${ALLOCATED}'"
echo "export GITVERSION_PATCH='${PATCH}'"
echo "export MUNITOR_RELEASE_TAG='v${ALLOCATED}'"
} >> "${BASH_ENV}"
echo "Release version: ${ALLOCATED} (tag v${ALLOCATED} -> ${SHA})"
+5 -1
View File
@@ -70,7 +70,11 @@ for i in $(seq 0 $((COUNT - 1))); do
# Build
echo "Building ${FULL_TAG} ..."
docker build -f "${DOCKERFILE}" -t "${FULL_TAG}" .
BUILD_ARGS=(-f "${DOCKERFILE}" -t "${FULL_TAG}")
if [[ -n "${CIRCLE_SHA1:-}" ]]; then
BUILD_ARGS+=(--label "org.opencontainers.image.revision=${CIRCLE_SHA1}")
fi
docker build "${BUILD_ARGS[@]}" .
if [[ -n "${ENV_TAG}" ]]; then
ENV_FULL_TAG="${REGISTRY}/${DERIVED_NAME}:${ENV_TAG}"
+5
View File
@@ -27,6 +27,11 @@ BUILD_CMD=(docker build -f "${DOCKERFILE}" -t "${FULL_IMAGE}:${VERSION}")
if [[ -n "${ENV_TAG}" ]]; then
BUILD_CMD+=(-t "${FULL_IMAGE}:${ENV_TAG}")
fi
if [[ -n "${CIRCLE_SHA1:-}" ]]; then
# Provenance label: guard_image_tags and update_cd_repo use it to prove which
# commit a version tag was built from.
BUILD_CMD+=(--label "org.opencontainers.image.revision=${CIRCLE_SHA1}")
fi
BUILD_CMD+=(--build-arg "VERSION=${VERSION}" --build-arg "GIT_COMMIT_SHA=${CIRCLE_SHA1:-}" .)
"${BUILD_CMD[@]}"
+9 -1
View File
@@ -206,6 +206,14 @@ extract_munitor_vars() {
MUNITOR_CD_NON_PROD="false"
fi
# cd_path is only emitted when set, so manifests without cd.path still render
# pipelines that validate against orb versions predating the parameter.
if [[ -n "${MUNITOR_CD_PATH}" ]]; then
MUNITOR_CD_EXPLICIT_PATH="true"
else
MUNITOR_CD_EXPLICIT_PATH="false"
fi
if [[ -n "${MUNITOR_CONTEXT_NVD}" ]]; then
MUNITOR_NVD="true"
else
@@ -232,7 +240,7 @@ extract_munitor_vars() {
export MUNITOR_CI_EMAIL MUNITOR_CI_NAME MUNITOR_NPM_DEFAULT_SCOPE MUNITOR_ORB_SLUG
export MUNITOR_HEALTH_PATH MUNITOR_HEALTH_PORT MUNITOR_HEALTH_DB MUNITOR_HEALTH_MIGRATIONS MUNITOR_HEALTH_SMOKE_PATHS
export MUNITOR_SUPPLEMENTAL MUNITOR_SUPPLEMENTAL_IMAGES_JSON
export MUNITOR_SONAR MUNITOR_CD MUNITOR_CD_NON_PROD MUNITOR_NVD
export MUNITOR_SONAR MUNITOR_CD MUNITOR_CD_NON_PROD MUNITOR_NVD MUNITOR_CD_EXPLICIT_PATH
}
# Build the envsubst variable list
+1 -1
View File
@@ -201,7 +201,7 @@ process_conditionals() {
cp "${input}" "${tmpfile}"
# Process each conditional flag
for flag in E2E SBOM SONAR NPM_AUTH SERVICES TEST_SETUP CUSTOM_TEST COVERAGE_CMD GITHUB_RELEASE SAST CD CD_NON_PROD OWASP NVD SUPPLEMENTAL; do
for flag in E2E SBOM SONAR NPM_AUTH SERVICES TEST_SETUP CUSTOM_TEST COVERAGE_CMD GITHUB_RELEASE SAST CD CD_NON_PROD CD_EXPLICIT_PATH OWASP NVD SUPPLEMENTAL; do
local var_name="MUNITOR_${flag}"
local value="${!var_name:-false}"
local flag_file
+13 -1
View File
@@ -35,7 +35,9 @@ if [[ "${VERSION}" =~ -(RC|alpha|beta|SNAPSHOT) ]]; then
TARGET="${CIRCLE_BRANCH:-main}"
else
PRERELEASE_FLAG=""
TARGET="main"
# Target the commit this pipeline built, never the moving branch head: a
# later merge must not receive this version's tag.
TARGET="${CIRCLE_SHA1:-main}"
fi
echo "Target: ${TARGET}"
@@ -44,6 +46,16 @@ echo "Pre-release: ${PRERELEASE_FLAG:-no}"
# Create tag + GitHub Release via API (skip if already exists)
# Uses gh release create which handles both tag and release in one call,
# bypassing CircleCI's read-only SSH deploy key entirely.
# The release tag is normally reserved by allocate_version. If it exists it
# must point at this commit; an existing tag is never moved.
if [[ -n "${CIRCLE_SHA1:-}" ]] && TAG_SHA=$(gh api "repos/{owner}/{repo}/commits/${TAG}" --jq '.sha' 2>/dev/null); then
if [[ "${TAG_SHA}" != "${CIRCLE_SHA1}" ]]; then
echo "ERROR: Tag ${TAG} already points at ${TAG_SHA}, not ${CIRCLE_SHA1}. Refusing to release."
exit 1
fi
echo "Tag ${TAG} points at ${CIRCLE_SHA1}."
fi
if gh release view "${TAG}" >/dev/null 2>&1; then
echo "Release ${TAG} already exists, skipping."
else
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
# Source shared helpers
MUNITOR_HELPERS="${MUNITOR_HELPERS:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/munitor_helpers.sh}"
# shellcheck source=munitor_helpers.sh
if [[ -f "${MUNITOR_HELPERS}" ]]; then source "${MUNITOR_HELPERS}"
elif ! type munitor_header &>/dev/null; then
munitor_header() { echo "=== Munitor: ${1:-unknown} ==="; }
munitor_check_tool() { command -v "$1" &>/dev/null || { echo "ERROR: $1 not found"; exit 1; }; }
munitor_download_with_retry() { curl -fsSL --retry 3 "$1" -o "$2"; }
fi
munitor_header "guard_image_tags"
# Version tags are immutable. Before anything is pushed, verify that every
# version tag this job will publish is either absent from the registry or was
# built from this same commit (a rerun). A tag built from a different commit
# means two pipelines claimed the same version; overwriting it would silently
# change what an already-deployed tag points at, so fail instead.
# Mutable environment tags (develop, staging) are not checked.
munitor_check_tool docker --version
munitor_check_tool jq --version
IMAGE="${IMAGE_NAME:?IMAGE_NAME not set}"
REGISTRY="${REGISTRY:-ghcr.io/KofTwentyTwo}"
VERSION="${PROJECT_VERSION:?PROJECT_VERSION not set -- run export_version_vars first}"
SHA="${CIRCLE_SHA1:?CIRCLE_SHA1 not set}"
SUPP_JSON="${SUPPLEMENTAL_IMAGES_JSON:-[]}"
REVISION_LABEL="org.opencontainers.image.revision"
if [[ -z "${MUNITOR_SKIP_REGISTRY_LOGIN:-}" ]]; then
TOKEN="${!GHCR_TOKEN_VAR:-}"
USER="${!GHCR_USER_VAR:-}"
if [[ -z "${TOKEN}" || -z "${USER}" ]]; then
echo "ERROR: GHCR credentials not set (\$${GHCR_TOKEN_VAR:-GHCR_TOKEN}, \$${GHCR_USER_VAR:-GHCR_USERNAME})"
exit 1
fi
echo "${TOKEN}" | docker login "${REGISTRY%%/*}" -u "${USER}" --password-stdin
fi
REFS=("${REGISTRY}/${IMAGE}:${VERSION}")
if [[ "${SUPP_JSON}" != "[]" && "${SUPP_JSON}" != "null" && -n "${SUPP_JSON}" ]]; then
while IFS= read -r name; do
name="${name%$'\r'}"
REFS+=("${REGISTRY}/${IMAGE}-${name}:${VERSION}")
done < <(echo "${SUPP_JSON}" | jq -r '.[].name')
fi
CONFLICTS=0
for ref in "${REFS[@]}"; do
err=$(mktemp)
rc=0
config=$(docker buildx imagetools inspect "${ref}" --format '{{json .Image}}' 2>"${err}") || rc=$?
if [[ ${rc} -ne 0 ]]; then
if grep -qiE 'not found|manifest unknown|MANIFEST_UNKNOWN|no such manifest' "${err}"; then
echo "OK: ${ref} does not exist yet."
rm -f "${err}"
continue
fi
echo "ERROR: Could not inspect ${ref}; refusing to push without proving the tag is free:"
cat "${err}"
rm -f "${err}"
exit 1
fi
rm -f "${err}"
# Single-platform images return one config; multi-platform images return a map of configs.
revision=$(echo "${config}" | jq -r --arg l "${REVISION_LABEL}" \
'[.. | objects | select(has("Labels")) | .Labels // {} | .[$l] // empty] | first // ""')
if [[ "${revision}" == "${SHA}" ]]; then
echo "OK: ${ref} already exists for this commit (rerun); it will be republished from ${SHA}."
else
echo "ERROR: ${ref} already exists for a different commit (${revision:-unknown, no ${REVISION_LABEL} label})."
CONFLICTS=$((CONFLICTS + 1))
fi
done
if [[ ${CONFLICTS} -gt 0 ]]; then
echo "Refusing to overwrite ${CONFLICTS} immutable version tag(s). Version ${VERSION} was claimed by another build;"
echo "rerun from a fresh pipeline so allocate_version reserves a new version."
exit 1
fi
echo "All ${#REFS[@]} version tag(s) are free for ${SHA}."
+34 -2
View File
@@ -303,6 +303,14 @@ extract_munitor_vars() {
MUNITOR_CD_NON_PROD="false"
fi
# cd_path is only emitted when set, so manifests without cd.path still render
# pipelines that validate against orb versions predating the parameter.
if [[ -n "${MUNITOR_CD_PATH}" ]]; then
MUNITOR_CD_EXPLICIT_PATH="true"
else
MUNITOR_CD_EXPLICIT_PATH="false"
fi
if [[ -n "${MUNITOR_CONTEXT_NVD}" ]]; then
MUNITOR_NVD="true"
else
@@ -329,7 +337,7 @@ extract_munitor_vars() {
export MUNITOR_CI_EMAIL MUNITOR_CI_NAME MUNITOR_NPM_DEFAULT_SCOPE MUNITOR_ORB_SLUG
export MUNITOR_HEALTH_PATH MUNITOR_HEALTH_PORT MUNITOR_HEALTH_DB MUNITOR_HEALTH_MIGRATIONS MUNITOR_HEALTH_SMOKE_PATHS
export MUNITOR_SUPPLEMENTAL MUNITOR_SUPPLEMENTAL_IMAGES_JSON
export MUNITOR_SONAR MUNITOR_CD MUNITOR_CD_NON_PROD MUNITOR_NVD
export MUNITOR_SONAR MUNITOR_CD MUNITOR_CD_NON_PROD MUNITOR_NVD MUNITOR_CD_EXPLICIT_PATH
}
# Build the envsubst variable list
@@ -572,7 +580,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_RELEASE}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -636,7 +646,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_PROD}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -896,7 +908,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_RELEASE}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -960,7 +974,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_PROD}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -1248,7 +1264,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_RELEASE}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -1325,7 +1343,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_PROD}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -1613,7 +1633,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_RELEASE}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -1690,7 +1712,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_PROD}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -2621,7 +2645,9 @@ cat > /tmp/munitor/templates/partials/java-webapp-deploy.yml.tpl << 'MUNITOR_PAR
environment: __CD_ENVIRONMENT__
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -2771,7 +2797,9 @@ cat > /tmp/munitor/templates/partials/gradle-webapp-deploy.yml.tpl << 'MUNITOR_P
environment: __CD_ENVIRONMENT__
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -2934,7 +2962,9 @@ cat > /tmp/munitor/templates/partials/node-api-deploy.yml.tpl << 'MUNITOR_PARTIA
environment: __CD_ENVIRONMENT__
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -3097,7 +3127,9 @@ cat > /tmp/munitor/templates/partials/node-webapp-deploy.yml.tpl << 'MUNITOR_PAR
environment: __CD_ENVIRONMENT__
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -3321,7 +3353,7 @@ process_conditionals() {
cp "${input}" "${tmpfile}"
# Process each conditional flag
for flag in E2E SBOM SONAR NPM_AUTH SERVICES TEST_SETUP CUSTOM_TEST COVERAGE_CMD GITHUB_RELEASE SAST CD CD_NON_PROD OWASP NVD SUPPLEMENTAL; do
for flag in E2E SBOM SONAR NPM_AUTH SERVICES TEST_SETUP CUSTOM_TEST COVERAGE_CMD GITHUB_RELEASE SAST CD CD_NON_PROD CD_EXPLICIT_PATH OWASP NVD SUPPLEMENTAL; do
local var_name="MUNITOR_${flag}"
local value="${!var_name:-false}"
local flag_file
+9 -1
View File
@@ -303,6 +303,14 @@ extract_munitor_vars() {
MUNITOR_CD_NON_PROD="false"
fi
# cd_path is only emitted when set, so manifests without cd.path still render
# pipelines that validate against orb versions predating the parameter.
if [[ -n "${MUNITOR_CD_PATH}" ]]; then
MUNITOR_CD_EXPLICIT_PATH="true"
else
MUNITOR_CD_EXPLICIT_PATH="false"
fi
if [[ -n "${MUNITOR_CONTEXT_NVD}" ]]; then
MUNITOR_NVD="true"
else
@@ -329,7 +337,7 @@ extract_munitor_vars() {
export MUNITOR_CI_EMAIL MUNITOR_CI_NAME MUNITOR_NPM_DEFAULT_SCOPE MUNITOR_ORB_SLUG
export MUNITOR_HEALTH_PATH MUNITOR_HEALTH_PORT MUNITOR_HEALTH_DB MUNITOR_HEALTH_MIGRATIONS MUNITOR_HEALTH_SMOKE_PATHS
export MUNITOR_SUPPLEMENTAL MUNITOR_SUPPLEMENTAL_IMAGES_JSON
export MUNITOR_SONAR MUNITOR_CD MUNITOR_CD_NON_PROD MUNITOR_NVD
export MUNITOR_SONAR MUNITOR_CD MUNITOR_CD_NON_PROD MUNITOR_NVD MUNITOR_CD_EXPLICIT_PATH
}
# Build the envsubst variable list
@@ -230,7 +230,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_RELEASE}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -294,7 +296,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_PROD}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -230,7 +230,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_RELEASE}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -294,7 +296,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_PROD}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
+4
View File
@@ -258,7 +258,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_RELEASE}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -335,7 +337,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_PROD}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -258,7 +258,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_RELEASE}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -335,7 +337,9 @@ workflows:
environment: ${MUNITOR_CD_ENV_PROD}
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -131,7 +131,9 @@
environment: __CD_ENVIRONMENT__
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -131,7 +131,9 @@
environment: __CD_ENVIRONMENT__
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -144,7 +144,9 @@
environment: __CD_ENVIRONMENT__
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
@@ -144,7 +144,9 @@
environment: __CD_ENVIRONMENT__
cd_format: ${MUNITOR_CD_FORMAT}
cd_image_name: ${MUNITOR_DOCKER_REGISTRY}/${MUNITOR_IMAGE_NAME}
##IF_CD_EXPLICIT_PATH##
cd_path: ${MUNITOR_CD_PATH}
##ENDIF_CD_EXPLICIT_PATH##
##IF_SUPPLEMENTAL##
supplemental_images: '${MUNITOR_SUPPLEMENTAL_IMAGES_JSON}'
##ENDIF_SUPPLEMENTAL##
+26 -1
View File
@@ -35,6 +35,31 @@ if [[ -z "${GITHUB_TOKEN:-}" ]]; then
exit 1
fi
# Release versions are owned by exactly one commit (allocate_version reserves the
# v<version> tag atomically). Refuse to point the GitOps repo at a version that
# belongs to a different commit: the image tag would not contain this build.
if [[ "${CIRCLE_BRANCH:-}" == "main" && "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && -z "${MUNITOR_SKIP_TAG_OWNERSHIP_CHECK:-}" ]]; then
SOURCE_REPO="${MUNITOR_SOURCE_REPO:-${CIRCLE_PROJECT_USERNAME:-}/${CIRCLE_PROJECT_REPONAME:-}}"
SOURCE_SHA="${CIRCLE_SHA1:?CIRCLE_SHA1 not set}"
github_get() {
curl -fsS -H "Authorization: Bearer ${GITHUB_TOKEN}" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${SOURCE_REPO}/$1"
}
if ! TAG_REF=$(github_get "git/ref/tags/v${VERSION}"); then
echo "ERROR: Release tag v${VERSION} does not exist in ${SOURCE_REPO}; the version was never reserved."
exit 1
fi
TAG_SHA=$(echo "${TAG_REF}" | jq -r '.object.sha')
if [[ "$(echo "${TAG_REF}" | jq -r '.object.type')" == "tag" ]]; then
TAG_SHA=$(github_get "git/tags/${TAG_SHA}" | jq -r '.object.sha')
fi
if [[ "${TAG_SHA}" != "${SOURCE_SHA}" ]]; then
echo "ERROR: Version ${VERSION} belongs to commit ${TAG_SHA}, not ${SOURCE_SHA}."
echo "Refusing to bump the GitOps repo: the image tag ${VERSION} was not built from this commit."
exit 1
fi
echo "Verified v${VERSION} -> ${SOURCE_SHA}."
fi
echo "Updating CD repo: ${REPO} (format=${FORMAT}, version=${VERSION})"
# Clone the CD repo using explicit token auth
@@ -151,7 +176,7 @@ if git diff --cached --quiet; then
exit 0
fi
git commit -m "chore: update ${IMAGE_NAME:-image} to ${VERSION}"
git commit -m "chore: update ${IMAGE_NAME:-image} to ${VERSION}" -m "Source: ${CIRCLE_PROJECT_REPONAME:-unknown}@${CIRCLE_SHA1:-unknown}"
# Independent pipelines may update a shared GitOps repository concurrently.
for attempt in 1 2 3; do
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# Tests for allocate_version.sh: atomic release version reservation via git tags.
#
# The GitHub refs API is replaced by a fake backed by a directory. Creating a
# ref uses a hard link from a fully-written temp file, which is atomic and fails
# if the target exists -- the same create-if-absent semantics GitHub provides
# for POST /git/refs (422 "Reference already exists").
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SCRIPT="${PROJECT_DIR}/src/scripts/allocate_version.sh"
PASS=0
FAIL=0
WORK=$(mktemp -d)
trap 'rm -rf "${WORK}"' EXIT
pass() { echo "PASS"; PASS=$((PASS + 1)); }
fail() { echo "FAIL${1:+ ($1)}"; FAIL=$((FAIL + 1)); }
# Fake GitHub API: gh_api METHOD PATH [BODY]; state lives in ${FAKE_GH_DIR}/tags/<tag>
FAKE_API="${WORK}/fake_gh_api.sh"
cat > "${FAKE_API}" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
method="$1" path="$2" body="${3:-}"
dir="${FAKE_GH_DIR:?}/tags"
mkdir -p "${dir}"
if [[ -n "${FAKE_GH_FAIL:-}" ]]; then
echo '{"message":"Server Error"}'
exit 1
fi
case "${method} ${path}" in
"POST git/refs")
tag=$(echo "${body}" | jq -r '.ref' | sed 's#^refs/tags/##')
sha=$(echo "${body}" | jq -r '.sha')
# Widen the race window so concurrent allocators genuinely interleave.
sleep "0.0$((RANDOM % 9))"
tmp=$(mktemp "${dir}/.tmp.XXXXXX")
echo "${sha}" > "${tmp}"
if ln "${tmp}" "${dir}/${tag}" 2>/dev/null; then
rm -f "${tmp}"
echo "{\"ref\":\"refs/tags/${tag}\",\"object\":{\"type\":\"commit\",\"sha\":\"${sha}\"}}"
exit 0
fi
rm -f "${tmp}"
echo '{"message":"Reference already exists"}'
exit 3
;;
"GET git/ref/tags/"*)
tag="${path#git/ref/tags/}"
if [[ -f "${dir}/${tag}" ]]; then
sha=$(cat "${dir}/${tag}")
if [[ -f "${dir}/${tag}.annotated" ]]; then
echo "{\"object\":{\"type\":\"tag\",\"sha\":\"tagobj-${sha}\"}}"
else
echo "{\"object\":{\"type\":\"commit\",\"sha\":\"${sha}\"}}"
fi
exit 0
fi
echo '{"message":"Not Found"}'
exit 2
;;
"GET git/tags/tagobj-"*)
echo "{\"object\":{\"type\":\"commit\",\"sha\":\"${path#git/tags/tagobj-}\"}}"
exit 0
;;
esac
echo "unexpected call: ${method} ${path}" >&2
exit 1
EOF
chmod +x "${FAKE_API}"
# run_alloc STATE_DIR SHA VERSION [BRANCH] -> prints allocated PROJECT_VERSION (from BASH_ENV) or "-"
run_alloc() {
local state="$1" sha="$2" version="$3" branch="${4:-main}"
local env_file
env_file=$(mktemp "${WORK}/env.XXXXXX")
FAKE_GH_DIR="${state}" MUNITOR_GH_API_CMD="${FAKE_API}" BASH_ENV="${env_file}" \
PROJECT_VERSION="${version}" CIRCLE_BRANCH="${branch}" CIRCLE_SHA1="${sha}" \
CIRCLE_PROJECT_USERNAME="KofTwentyTwo" CIRCLE_PROJECT_REPONAME="Example" \
bash "${SCRIPT}" > "${env_file}.log" 2>&1 || { echo "ERR"; return 0; }
local v
v=$(grep "^export PROJECT_VERSION=" "${env_file}" | tail -1 | sed "s/^export PROJECT_VERSION='\(.*\)'$/\1/")
echo "${v:--}"
}
echo "=== allocate_version.sh Tests ==="
echo -n " TEST: reserves the computed version when free... "
S="${WORK}/s1"
got=$(run_alloc "${S}" sha-a 0.1.24)
if [[ "${got}" == "0.1.24" && "$(cat "${S}/tags/v0.1.24")" == "sha-a" ]]; then pass; else fail "got ${got}"; fi
echo -n " TEST: rerun on the same commit reuses its version... "
got=$(run_alloc "${S}" sha-a 0.1.24)
if [[ "${got}" == "0.1.24" && "$(cat "${S}/tags/v0.1.24")" == "sha-a" ]]; then pass; else fail "got ${got}"; fi
echo -n " TEST: collision with another commit takes the next patch (the PR #65/#66 case)... "
got=$(run_alloc "${S}" sha-b 0.1.24)
if [[ "${got}" == "0.1.25" && "$(cat "${S}/tags/v0.1.24")" == "sha-a" && "$(cat "${S}/tags/v0.1.25")" == "sha-b" ]]; then
pass
else
fail "got ${got}"
fi
echo -n " TEST: existing tags are never moved... "
if [[ "$(cat "${S}/tags/v0.1.24")" == "sha-a" ]]; then pass; else fail "v0.1.24 moved"; fi
echo -n " TEST: annotated tag on the same commit is recognized as a rerun... "
S2="${WORK}/s2"; mkdir -p "${S2}/tags"; echo sha-x > "${S2}/tags/v1.0.0"; touch "${S2}/tags/v1.0.0.annotated"
got=$(run_alloc "${S2}" sha-x 1.0.0)
if [[ "${got}" == "1.0.0" ]]; then pass; else fail "got ${got}"; fi
echo -n " TEST: skips several already-taken versions... "
S3="${WORK}/s3"; mkdir -p "${S3}/tags"
for p in 5 6 7; do echo "other-${p}" > "${S3}/tags/v2.3.${p}"; done
got=$(run_alloc "${S3}" sha-y 2.3.5)
if [[ "${got}" == "2.3.8" ]]; then pass; else fail "got ${got}"; fi
echo -n " TEST: non-main branches are not reserved... "
S4="${WORK}/s4"
got=$(run_alloc "${S4}" sha-z 0.2.0-SNAPSHOT.abc develop)
if [[ "${got}" == "-" && ! -e "${S4}/tags/v0.2.0-SNAPSHOT.abc" ]]; then pass; else fail "got ${got}"; fi
echo -n " TEST: pre-release versions on main are not reserved... "
got=$(run_alloc "${S4}" sha-z 0.2.0-RC.1 main)
if [[ "${got}" == "-" ]]; then pass; else fail "got ${got}"; fi
echo -n " TEST: API failure fails the build instead of guessing... "
S5="${WORK}/s5"
got=$(FAKE_GH_FAIL=1 run_alloc "${S5}" sha-q 0.1.0)
if [[ "${got}" == "ERR" ]]; then pass; else fail "got ${got}"; fi
echo -n " TEST: missing GITHUB_TOKEN fails on main... "
rc=0
env -u GITHUB_TOKEN BASH_ENV=/dev/null PROJECT_VERSION=0.1.0 CIRCLE_BRANCH=main CIRCLE_SHA1=abc \
CIRCLE_PROJECT_USERNAME=o CIRCLE_PROJECT_REPONAME=r bash "${SCRIPT}" >/dev/null 2>&1 || rc=$?
if [[ ${rc} -ne 0 ]]; then pass; else fail "expected failure"; fi
# Concurrent simulation: N pipelines for N different merge commits all computed
# the same version from GitVersion and race to reserve it.
N=8
echo -n " TEST: ${N} concurrent pipelines get ${N} distinct, contiguous versions... "
SC="${WORK}/concurrent"; mkdir -p "${SC}/tags"
pids=()
for i in $(seq 1 "${N}"); do
( run_alloc "${SC}" "merge-${i}" 0.1.24 > "${WORK}/result.${i}" ) &
pids+=($!)
done
for pid in "${pids[@]}"; do wait "${pid}"; done
results=$(for i in $(seq 1 "${N}"); do cat "${WORK}/result.${i}"; done | sort -t. -k3,3n)
expected=$(for p in $(seq 24 $((24 + N - 1))); do echo "0.1.${p}"; done)
ok=true
[[ "${results}" == "${expected}" ]] || ok=false
# Every tag maps to exactly the commit that claims that version.
for i in $(seq 1 "${N}"); do
v=$(cat "${WORK}/result.${i}")
[[ -f "${SC}/tags/v${v}" && "$(cat "${SC}/tags/v${v}")" == "merge-${i}" ]] || ok=false
done
if ${ok}; then pass; else fail "results: $(echo "${results}" | tr '\n' ' ')"; fi
echo -n " TEST: concurrent reruns of the same commits keep their versions... "
pids=()
for i in $(seq 1 "${N}"); do
( run_alloc "${SC}" "merge-${i}" 0.1.24 > "${WORK}/rerun.${i}" ) &
pids+=($!)
done
for pid in "${pids[@]}"; do wait "${pid}"; done
ok=true
for i in $(seq 1 "${N}"); do
[[ "$(cat "${WORK}/rerun.${i}")" == "$(cat "${WORK}/result.${i}")" ]] || ok=false
done
tag_count=$(find "${SC}/tags" -name 'v*' | wc -l | tr -d ' ')
[[ "${tag_count}" == "${N}" ]] || ok=false
if ${ok}; then pass; else fail "reruns changed versions or created extra tags (${tag_count})"; fi
echo ""
echo "Results: ${PASS} passed, ${FAIL} failed"
[[ ${FAIL} -eq 0 ]]
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# Tests for the release guards: immutable image tags and conditional cd_path emission.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SRC_SCRIPTS="${PROJECT_DIR}/src/scripts"
PASS=0
FAIL=0
WORK=$(mktemp -d)
trap 'rm -rf "${WORK}"' EXIT
pass() { echo "PASS"; PASS=$((PASS + 1)); }
fail() { echo "FAIL${1:+ ($1)}"; FAIL=$((FAIL + 1)); }
# Fake docker: `docker buildx imagetools inspect REF --format ...` reads
# ${FAKE_REGISTRY}/<ref with / and : replaced by _> holding the revision label.
mkdir -p "${WORK}/bin"
cat > "${WORK}/bin/docker" <<'EOF'
#!/usr/bin/env bash
if [[ "$1" == "--version" ]]; then echo "Docker version 99"; exit 0; fi
if [[ "$1 $2 $3" == "buildx imagetools inspect" ]]; then
ref="$4"
if [[ -n "${FAKE_REGISTRY_ERROR:-}" ]]; then echo "unauthorized: authentication required" >&2; exit 1; fi
key=$(echo "${ref}" | tr '/:' '__')
f="${FAKE_REGISTRY}/${key}"
if [[ ! -f "${f}" ]]; then echo "ERROR: ${ref}: not found" >&2; exit 1; fi
rev=$(cat "${f}")
if [[ -z "${rev}" ]]; then echo '{"config":{"Labels":{}}}'; exit 0; fi
echo "{\"architecture\":\"amd64\",\"config\":{\"Labels\":{\"org.opencontainers.image.revision\":\"${rev}\"}}}"
exit 0
fi
echo "unexpected docker call: $*" >&2
exit 1
EOF
chmod +x "${WORK}/bin/docker"
put_image() { mkdir -p "${FAKE_REGISTRY}"; echo "$2" > "${FAKE_REGISTRY}/$(echo "$1" | tr '/:' '__')"; }
run_guard() {
PATH="${WORK}/bin:${PATH}" MUNITOR_SKIP_REGISTRY_LOGIN=1 FAKE_REGISTRY="${FAKE_REGISTRY}" \
IMAGE_NAME=website-backend REGISTRY=ghcr.io/koftwentytwo PROJECT_VERSION="$1" CIRCLE_SHA1="$2" \
SUPPLEMENTAL_IMAGES_JSON="${3:-[]}" bash "${SRC_SCRIPTS}/guard_image_tags.sh" >"${WORK}/guard.log" 2>&1
}
echo "=== guard_image_tags.sh Tests ==="
SUPP='[{"name":"migrations","dockerfile":"docker/Dockerfile.migrations"}]'
FAKE_REGISTRY="${WORK}/reg1"
echo -n " TEST: passes when the version tag does not exist... "
if run_guard 0.1.32 sha-new "${SUPP}"; then pass; else fail "$(cat "${WORK}/guard.log")"; fi
echo -n " TEST: passes on rerun when the tag was built from the same commit... "
put_image ghcr.io/koftwentytwo/website-backend:0.1.32 sha-new
put_image ghcr.io/koftwentytwo/website-backend-migrations:0.1.32 sha-new
if run_guard 0.1.32 sha-new "${SUPP}"; then pass; else fail "$(cat "${WORK}/guard.log")"; fi
echo -n " TEST: refuses to overwrite a version tag built from another commit (0.1.24 incident)... "
if run_guard 0.1.32 sha-other "${SUPP}"; then fail "guard allowed overwrite"; else pass; fi
echo -n " TEST: refuses when only a supplemental image collides... "
FAKE_REGISTRY="${WORK}/reg2"
put_image ghcr.io/koftwentytwo/website-backend-migrations:0.2.0 sha-old
if run_guard 0.2.0 sha-new "${SUPP}"; then fail "guard allowed supplemental overwrite"; else pass; fi
echo -n " TEST: refuses when an existing tag has no provenance label... "
FAKE_REGISTRY="${WORK}/reg3"
put_image ghcr.io/koftwentytwo/website-backend:0.3.0 ""
if run_guard 0.3.0 sha-new; then fail "unlabeled image overwritten"; else pass; fi
echo -n " TEST: registry errors fail closed... "
if FAKE_REGISTRY_ERROR=1 run_guard 0.4.0 sha-new; then fail "error treated as free"; else pass; fi
echo -n " TEST: docker_build labels images with the commit revision... "
if grep -q 'org.opencontainers.image.revision=${CIRCLE_SHA1}' "${SRC_SCRIPTS}/docker_build.sh" &&
grep -q 'org.opencontainers.image.revision=${CIRCLE_SHA1}' "${SRC_SCRIPTS}/build_push_supplemental.sh"; then
pass
else
fail "revision label missing"
fi
echo -n " TEST: docker_build_push guards tags before building and pushing... "
JOB="${PROJECT_DIR}/src/jobs/docker_build_push.yml"
g=$(grep -n 'guard_image_tags' "${JOB}" | head -1 | cut -d: -f1)
p=$(grep -n 'docker_push_ghcr' "${JOB}" | head -1 | cut -d: -f1)
if [[ -n "${g}" && -n "${p}" && ${g} -lt ${p} ]]; then pass; else fail "guard not before push"; fi
echo -n " TEST: build jobs reserve the version before persisting it... "
ok=true
for j in mvn gradle node; do
f="${PROJECT_DIR}/src/jobs/${j}_build_and_test.yml"
a=$(grep -n 'allocate_version' "${f}" | head -1 | cut -d: -f1)
e=$(grep -n 'export_version_vars' "${f}" | head -1 | cut -d: -f1)
m=$(grep -n 'persist_version_metadata' "${f}" | head -1 | cut -d: -f1)
[[ -n "${a}" && ${a} -gt ${e} && ${a} -lt ${m} ]] || ok=false
done
if ${ok}; then pass; else fail "allocate_version misplaced"; fi
echo -n " TEST: github_release targets the built commit, not the branch head... "
if grep -q 'TARGET="${CIRCLE_SHA1:-main}"' "${SRC_SCRIPTS}/github_release.sh" &&
grep -q 'Refusing to release' "${SRC_SCRIPTS}/github_release.sh"; then
pass
else
fail
fi
echo -n " TEST: update_cd_repo verifies the release tag belongs to this commit... "
if grep -q 'Refusing to bump the GitOps repo' "${SRC_SCRIPTS}/update_cd_repo.sh"; then pass; else fail; fi
echo ""
echo "=== cd_path emission (orb version compatibility) ==="
render() {
local fixture="$1" out="$2"
MUNITOR_CONFIG="${fixture}" MUNITOR_OUTPUT_FILE="${out}" \
bash "${SRC_SCRIPTS}/generate_config.sh" >/dev/null 2>&1 || true
}
echo -n " TEST: manifest without cd.path emits no cd_path (older orbs reject it)... "
render "${SCRIPT_DIR}/fixtures/java-webapp.munitor.yml" "${WORK}/java.yml"
if [[ -s "${WORK}/java.yml" ]] && grep -q 'munitor/update_cd_repo' "${WORK}/java.yml" && ! grep -q 'cd_path' "${WORK}/java.yml"; then
pass
else
fail "cd_path present or render failed"
fi
echo -n " TEST: manifest with cd.path still emits cd_path... "
render "${SCRIPT_DIR}/fixtures/node-webapp-consolidated.munitor.yml" "${WORK}/node.yml"
if grep -q 'cd_path: tenants/investinginchester/website/kustomization.yaml' "${WORK}/node.yml"; then pass; else fail; fi
echo ""
echo "Results: ${PASS} passed, ${FAIL} failed"
[[ ${FAIL} -eq 0 ]]