From 57cb7a2aca33661d682ec9a626b1460233266e01 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 11 Oct 2026 13:56:58 -0500 Subject: [PATCH] fix: atomic release versions and immutable image tags Two main merges built minutes apart both computed the same GitVersion version. The later pipeline overwrote the image tag in GHCR, github-release tagged whatever main pointed at (--target main), and the GitOps bump was a no-op, so production ran an image without the later merge (Website-Backend 0.1.24 and 0.1.32). - allocate_version: on main, reserve vX.Y.Z by creating the git tag through the GitHub refs API (create-if-absent). On conflict with another commit, take the next patch; reruns of the same commit reuse their tag. Existing tags are never moved. Runs in mvn/gradle/node build-and-test before the version is baked into artifacts. - guard_image_tags: refuse to push a main or supplemental version tag that already exists for a different commit (org.opencontainers.image.revision label, now set on every build); fail closed on registry errors. - update_cd_repo: verify vX.Y.Z belongs to this commit before bumping the GitOps repo; record the source commit in the GitOps commit message. - github_release: target CIRCLE_SHA1 instead of main and refuse when the tag points elsewhere. - Generator: render cd_path only when cd.path is set, so manifests pinned to orb versions predating the parameter (website develop branches, Concilium, makers4) stop failing with "Unexpected argument(s): cd_path". Tests: tests/test_allocate_version.sh (incl. 8 concurrent allocators against an atomic fake refs API) and tests/test_release_guards.sh. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +- src/commands/allocate_version.yml | 9 + src/commands/guard_image_tags.yml | 34 ++++ src/jobs/docker_build_push.yml | 4 + src/jobs/gradle_build_and_test.yml | 1 + src/jobs/mvn_build_and_test.yml | 1 + src/jobs/node_build_and_test.yml | 1 + src/scripts/allocate_version.sh | 161 ++++++++++++++++ src/scripts/build_push_supplemental.sh | 6 +- src/scripts/docker_build.sh | 5 + src/scripts/extract_munitor_vars.sh | 10 +- src/scripts/generate_config.sh | 2 +- src/scripts/github_release.sh | 14 +- src/scripts/guard_image_tags.sh | 87 +++++++++ src/scripts/packed_generate_config.sh | 36 +++- src/scripts/packed_generate_dockerfile.sh | 10 +- src/scripts/templates/gradle-webapp.yml.tpl | 4 + src/scripts/templates/java-webapp.yml.tpl | 4 + src/scripts/templates/node-api.yml.tpl | 4 + src/scripts/templates/node-webapp.yml.tpl | 4 + .../partials/gradle-webapp-deploy.yml.tpl | 2 + .../partials/java-webapp-deploy.yml.tpl | 2 + .../partials/node-api-deploy.yml.tpl | 2 + .../partials/node-webapp-deploy.yml.tpl | 2 + src/scripts/update_cd_repo.sh | 27 ++- tests/test_allocate_version.sh | 182 ++++++++++++++++++ tests/test_release_guards.sh | 134 +++++++++++++ 27 files changed, 752 insertions(+), 9 deletions(-) create mode 100644 src/commands/allocate_version.yml create mode 100644 src/commands/guard_image_tags.yml create mode 100644 src/scripts/allocate_version.sh create mode 100644 src/scripts/guard_image_tags.sh create mode 100644 tests/test_allocate_version.sh create mode 100644 tests/test_release_guards.sh diff --git a/README.md b/README.md index 7a6c4b1..46fcc76 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/commands/allocate_version.yml b/src/commands/allocate_version.yml new file mode 100644 index 0000000..7aa2b69 --- /dev/null +++ b/src/commands/allocate_version.yml @@ -0,0 +1,9 @@ +description: > + Atomically reserve the release version on main by creating the v + 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) >> diff --git a/src/commands/guard_image_tags.yml b/src/commands/guard_image_tags.yml new file mode 100644 index 0000000..2e18291 --- /dev/null +++ b/src/commands/guard_image_tags.yml @@ -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) >> diff --git a/src/jobs/docker_build_push.yml b/src/jobs/docker_build_push.yml index 1fd0e9c..91b51a8 100644 --- a/src/jobs/docker_build_push.yml +++ b/src/jobs/docker_build_push.yml @@ -53,6 +53,10 @@ steps: - run: name: Restore version metadata command: <> + - 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 >> diff --git a/src/jobs/gradle_build_and_test.yml b/src/jobs/gradle_build_and_test.yml index cb51d22..bdbffff 100644 --- a/src/jobs/gradle_build_and_test.yml +++ b/src/jobs/gradle_build_and_test.yml @@ -25,6 +25,7 @@ steps: - restore_gradle_cache - calculate_version - export_version_vars + - allocate_version - gradle_build - gradle_test - run: diff --git a/src/jobs/mvn_build_and_test.yml b/src/jobs/mvn_build_and_test.yml index f990f3f..cfc7d80 100644 --- a/src/jobs/mvn_build_and_test.yml +++ b/src/jobs/mvn_build_and_test.yml @@ -25,6 +25,7 @@ steps: - restore_maven_cache - calculate_version - export_version_vars + - allocate_version - mvn_set_version - mvn_build - mvn_test diff --git a/src/jobs/node_build_and_test.yml b/src/jobs/node_build_and_test.yml index ee85fb6..540b555 100644 --- a/src/jobs/node_build_and_test.yml +++ b/src/jobs/node_build_and_test.yml @@ -73,6 +73,7 @@ steps: test_setup: << parameters.test_setup >> - calculate_version - export_version_vars + - allocate_version - run: name: Persist version metadata command: <> diff --git a/src/scripts/allocate_version.sh b/src/scripts/allocate_version.sh new file mode 100644 index 0000000..02d4dec --- /dev/null +++ b/src/scripts/allocate_version.sh @@ -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 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})" diff --git a/src/scripts/build_push_supplemental.sh b/src/scripts/build_push_supplemental.sh index b188245..eddc8b8 100644 --- a/src/scripts/build_push_supplemental.sh +++ b/src/scripts/build_push_supplemental.sh @@ -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}" diff --git a/src/scripts/docker_build.sh b/src/scripts/docker_build.sh index 906761d..bee2465 100755 --- a/src/scripts/docker_build.sh +++ b/src/scripts/docker_build.sh @@ -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[@]}" diff --git a/src/scripts/extract_munitor_vars.sh b/src/scripts/extract_munitor_vars.sh index c625bd4..6741848 100644 --- a/src/scripts/extract_munitor_vars.sh +++ b/src/scripts/extract_munitor_vars.sh @@ -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 diff --git a/src/scripts/generate_config.sh b/src/scripts/generate_config.sh index 7a3a54c..21100df 100755 --- a/src/scripts/generate_config.sh +++ b/src/scripts/generate_config.sh @@ -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 diff --git a/src/scripts/github_release.sh b/src/scripts/github_release.sh index fb68101..d1372cf 100755 --- a/src/scripts/github_release.sh +++ b/src/scripts/github_release.sh @@ -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 diff --git a/src/scripts/guard_image_tags.sh b/src/scripts/guard_image_tags.sh new file mode 100644 index 0000000..291953a --- /dev/null +++ b/src/scripts/guard_image_tags.sh @@ -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}." diff --git a/src/scripts/packed_generate_config.sh b/src/scripts/packed_generate_config.sh index aca5231..d4a83f3 100755 --- a/src/scripts/packed_generate_config.sh +++ b/src/scripts/packed_generate_config.sh @@ -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 diff --git a/src/scripts/packed_generate_dockerfile.sh b/src/scripts/packed_generate_dockerfile.sh index cc9c5b2..941ff78 100755 --- a/src/scripts/packed_generate_dockerfile.sh +++ b/src/scripts/packed_generate_dockerfile.sh @@ -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 diff --git a/src/scripts/templates/gradle-webapp.yml.tpl b/src/scripts/templates/gradle-webapp.yml.tpl index 18e2f8c..c652f1c 100644 --- a/src/scripts/templates/gradle-webapp.yml.tpl +++ b/src/scripts/templates/gradle-webapp.yml.tpl @@ -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## diff --git a/src/scripts/templates/java-webapp.yml.tpl b/src/scripts/templates/java-webapp.yml.tpl index 468a846..afe26c1 100644 --- a/src/scripts/templates/java-webapp.yml.tpl +++ b/src/scripts/templates/java-webapp.yml.tpl @@ -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## diff --git a/src/scripts/templates/node-api.yml.tpl b/src/scripts/templates/node-api.yml.tpl index 08f129a..ce49cef 100644 --- a/src/scripts/templates/node-api.yml.tpl +++ b/src/scripts/templates/node-api.yml.tpl @@ -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## diff --git a/src/scripts/templates/node-webapp.yml.tpl b/src/scripts/templates/node-webapp.yml.tpl index 5a63fc0..805dae6 100644 --- a/src/scripts/templates/node-webapp.yml.tpl +++ b/src/scripts/templates/node-webapp.yml.tpl @@ -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## diff --git a/src/scripts/templates/partials/gradle-webapp-deploy.yml.tpl b/src/scripts/templates/partials/gradle-webapp-deploy.yml.tpl index 23d6894..53fbd9f 100644 --- a/src/scripts/templates/partials/gradle-webapp-deploy.yml.tpl +++ b/src/scripts/templates/partials/gradle-webapp-deploy.yml.tpl @@ -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## diff --git a/src/scripts/templates/partials/java-webapp-deploy.yml.tpl b/src/scripts/templates/partials/java-webapp-deploy.yml.tpl index 7a0c15f..1010d10 100644 --- a/src/scripts/templates/partials/java-webapp-deploy.yml.tpl +++ b/src/scripts/templates/partials/java-webapp-deploy.yml.tpl @@ -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## diff --git a/src/scripts/templates/partials/node-api-deploy.yml.tpl b/src/scripts/templates/partials/node-api-deploy.yml.tpl index bf41481..6a8dcdc 100644 --- a/src/scripts/templates/partials/node-api-deploy.yml.tpl +++ b/src/scripts/templates/partials/node-api-deploy.yml.tpl @@ -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## diff --git a/src/scripts/templates/partials/node-webapp-deploy.yml.tpl b/src/scripts/templates/partials/node-webapp-deploy.yml.tpl index bf41481..6a8dcdc 100644 --- a/src/scripts/templates/partials/node-webapp-deploy.yml.tpl +++ b/src/scripts/templates/partials/node-webapp-deploy.yml.tpl @@ -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## diff --git a/src/scripts/update_cd_repo.sh b/src/scripts/update_cd_repo.sh index e9fac8d..a7e68ec 100755 --- a/src/scripts/update_cd_repo.sh +++ b/src/scripts/update_cd_repo.sh @@ -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 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 diff --git a/tests/test_allocate_version.sh b/tests/test_allocate_version.sh new file mode 100644 index 0000000..3ecf01a --- /dev/null +++ b/tests/test_allocate_version.sh @@ -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/ +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 ]] diff --git a/tests/test_release_guards.sh b/tests/test_release_guards.sh new file mode 100644 index 0000000..80aeb04 --- /dev/null +++ b/tests/test_release_guards.sh @@ -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}/ 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 ]] -- 2.54.0