From 3afeaa1125b2cf37f934f5af7a8395e35906c817 Mon Sep 17 00:00:00 2001 From: SatyendraGollaFacets Date: Thu, 15 Oct 2026 17:06:10 +0530 Subject: [PATCH 1/3] feat(module-ci): add host-neutral module-ci-core.sh Extracts the raptor-driving logic from module-ci-action into a shared script both GitHub and GitLab call, driven by a generic env contract (MODE, CI_DIFF_RANGE, CI_HEAD_SHA, CI_OWNER_SHAS, PATH_PREFIX, raptor creds). No logic duplicated across hosts. Install-if-missing so it runs in CI and locally. shellcheck-clean. Verified live against facetsdemo: preview (validate+register), publish (upload+compat-declaration+publish), cleanup (ownership-checked delete), and failure gating all pass. Co-Authored-By: Claude Opus 4.8 --- module-ci-core.sh | 295 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100755 module-ci-core.sh diff --git a/module-ci-core.sh b/module-ci-core.sh new file mode 100755 index 0000000..c70c25b --- /dev/null +++ b/module-ci-core.sh @@ -0,0 +1,295 @@ +#!/usr/bin/env bash +# Facets Module CI — host-neutral core. +# +# Shared by the GitHub action (module-ci-action) and the GitLab template +# (module-ci-gitlab). Each host supplies thin glue that resolves the mode, +# the git diff range, and the ownership SHAs, then calls this script; the +# host posts the review comment this script writes to $CI_COMMENT_DIR. +# +# Generic env contract (host glue sets these — this script reads NOTHING +# host-specific like GITHUB_* or CI_MERGE_REQUEST_*): +# MODE preview | publish | cleanup | noop +# CI_DIFF_RANGE git range for changed files ("base..head" / "base...head"); +# empty => diff-tree against HEAD (new branch / unknown base) +# CI_HEAD_SHA commit a preview is pinned to (preview mode) +# CI_OWNER_SHAS newline-separated commit SHAs that own previews (cleanup); +# empty => cannot verify ownership, cleanup skipped +# PATH_PREFIX sub-path to modules/ (e.g. "infra/"); empty => repo root +# ALL_MODULES "true" => every module/output, not just changed ones +# APPLY_OUTPUT_TYPES "true" (default) => apply outputs/ before modules +# AUTO_CREATE_INTENTS "true" (default) => raptor --auto-create for new intents +# CONTROL_PLANE_URL / FACETS_USERNAME / FACETS_TOKEN raptor auth (read by raptor) +# RAPTOR_VERSION / RAPTOR_DOWNLOAD_URL / TERRAFORM_VERSION / TRIVY_VERSION +# CI_COMMENT_DIR dir to write preview_outputs.md / preview_modules.md +# (default: a temp dir, printed at the end) +set -uo pipefail + +MODE="${MODE:-noop}" +PATH_PREFIX="${PATH_PREFIX:-}" +ALL_MODULES="${ALL_MODULES:-false}" +APPLY_OUTPUT_TYPES="${APPLY_OUTPUT_TYPES:-true}" +AUTO_CREATE_INTENTS="${AUTO_CREATE_INTENTS:-true}" +RAPTOR_VERSION="${RAPTOR_VERSION:-latest}" +RAPTOR_DOWNLOAD_URL="${RAPTOR_DOWNLOAD_URL:-}" +TERRAFORM_VERSION="${TERRAFORM_VERSION:-1.5.7}" +TRIVY_VERSION="${TRIVY_VERSION:-0.72.0}" +WORK="$(mktemp -d)" +CI_COMMENT_DIR="${CI_COMMENT_DIR:-$WORK}" +FAILURES="${WORK}/ci_failures" +mkdir -p "$CI_COMMENT_DIR" +# Keep raptor's Trivy scan out of terraform-init'd dependency trees (issue #13). +export TRIVY_SKIP_DIRS="${TRIVY_SKIP_DIRS:-**/.terraform,**/.terraform/**}" + +log() { printf '%s\n' "$*"; } +fail() { printf '%s\n' "$1" >> "$FAILURES"; } + +# --- shared helpers (identical to the GitHub action's inline copies) ---------- + +# Read a top-level scalar key (intent/flavor/version) from a module's facets.yaml. +meta() { + sed -n -E "s/^$2:[[:space:]]*(.*)$/\1/p" "$1/facets.yaml" | head -n1 \ + | sed -E "s/[[:space:]]*#.*$//; s/^[\"']//; s/[\"'][[:space:]]*$//; s/[[:space:]]*$//" +} + +# Changed files for this event, via the host-formed CI_DIFF_RANGE. Empty range +# (new branch / unreachable base) falls back to the pushed commit's own tree so +# git diff never errors and silently skips every module. +changed_files() { + if [ -n "${CI_DIFF_RANGE:-}" ]; then + git diff --name-only "${CI_DIFF_RANGE}" + else + git diff-tree --no-commit-id --name-only -r HEAD + fi +} + +# Map a changed file to the nearest ANCESTOR dir holding a facets.yaml, so a +# change deep inside a module (templates/foo.tpl) still maps to the module root. +find_module_dir() { + local d + d="$(dirname "$1")" + while [ -n "$d" ] && [ "$d" != "." ] && [ "$d" != "/" ]; do + if [ -f "$d/facets.yaml" ]; then printf '%s\n' "$d"; return 0; fi + d="$(dirname "$d")" + done + if [ -f "facets.yaml" ]; then printf '%s\n' "."; fi +} + +is_definition() { + case "${1,,}" in + *.yaml|*.yml|*.json) return 0 ;; + *) return 1 ;; + esac +} + +prefix() { + local p="$PATH_PREFIX" + if [ -n "$p" ] && [ "${p%/}" = "$p" ]; then p="${p}/"; fi + printf '%s' "$p" +} + +install_tools() { + # Install only what's missing — a fresh CI runner installs all three; a dev + # machine that already has them is a no-op. Modes that don't run raptor's + # module validation (cleanup/noop) don't need terraform/trivy. + local p; p="$(prefix)" + if ! command -v raptor >/dev/null 2>&1; then + local url + if [ -n "$RAPTOR_DOWNLOAD_URL" ]; then url="$RAPTOR_DOWNLOAD_URL" + elif [ -z "$RAPTOR_VERSION" ] || [ "$RAPTOR_VERSION" = "latest" ]; then + url="https://github.com/Facets-cloud/raptor-releases/releases/latest/download/raptor-linux-amd64" + else + url="https://github.com/Facets-cloud/raptor-releases/releases/download/${RAPTOR_VERSION}/raptor-linux-amd64" + fi + log "Installing raptor from ${url}" + curl -fsSL -o /tmp/raptor "$url" && chmod +x /tmp/raptor && sudo mv /tmp/raptor /usr/local/bin/raptor + else + log "raptor already present: $(command -v raptor)" + fi + if [ "$MODE" = "preview" ] || [ "$MODE" = "publish" ]; then + if ! command -v terraform >/dev/null 2>&1; then + log "Installing Terraform ${TERRAFORM_VERSION}..." + curl -fsSL -o /tmp/tf.zip "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" \ + && (cd /tmp && unzip -o tf.zip terraform) && sudo mv /tmp/terraform /usr/local/bin/terraform + fi + if ! command -v trivy >/dev/null 2>&1; then + log "Installing Trivy ${TRIVY_VERSION}..." + curl -fsSL -o /tmp/trivy.tgz "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \ + && (cd /tmp && tar -xzf trivy.tgz trivy) && sudo mv /tmp/trivy /usr/local/bin/trivy + fi + fi +} + +# --- target detection --------------------------------------------------------- + +detect_module_dirs() { + local root; root="$(prefix)modules" + local candidates="" dirs="" d f md + if [ "$ALL_MODULES" = "true" ]; then + [ -d "$root" ] && candidates="$(find "$root" -type f -name facets.yaml -exec dirname {} \; | sort -u)" + else + while IFS= read -r f; do + [ -z "$f" ] && continue + md="$(find_module_dir "$f" || true)" + [ -n "$md" ] && candidates="${candidates} ${md}" + done <<< "$(changed_files || true)" + fi + for d in $candidates; do + case "$d/" in "$root"/*) ;; *) continue ;; esac + [ -f "$d/facets.yaml" ] && dirs="$dirs $d" + done + # shellcheck disable=SC2086 # intentional re-split of the space-separated list + printf '%s\n' $dirs | sed '/^$/d' | sort -u | tr '\n' ' ' | sed 's/[[:space:]]*$//' +} + +detect_output_files() { + local root; root="$(prefix)outputs" + local files="" f + if [ "$ALL_MODULES" = "true" ]; then + [ -d "$root" ] && files="$(find "$root" -type f \( -name '*.yaml' -o -name '*.yml' -o -name '*.json' \) | sort)" + else + while IFS= read -r f; do + [ -z "$f" ] && continue + case "$f/" in "$root"/*) ;; *) continue ;; esac + [ -f "$f" ] || continue # a deleted output is never a target + is_definition "$f" || continue + files="${files} ${f}" + done <<< "$(changed_files || true)" + fi + # shellcheck disable=SC2086 # intentional re-split of the space-separated list + printf '%s\n' $files | sed '/^$/d' | sort -u | tr '\n' ' ' | sed 's/[[:space:]]*$//' +} + +# --- output types (applied BEFORE modules; a module refs its type by name) ---- + +apply_output_types() { + local files="$1" section="${CI_COMMENT_DIR}/preview_outputs.md" + [ -n "$files" ] || return 0 + local mode_flag="" + [ "$MODE" = "preview" ] && mode_flag="--if-absent" + { echo "### Facets output types"; echo; echo "| Definition | Type | Result | Reported changes |"; echo "|---|---|---|---|"; } > "$section" + local file out result ref notes + for file in $files; do + log "::group::Output type ${file}" + if out="$(raptor create output-type -f "$file" $mode_flag 2>&1)"; then + printf '%s\n' "$out" + result="$(printf '%s\n' "$out" | sed -n "s/^✓ Output type '.*' \(.*\)\$/\1/p" | tail -n1)"; [ -n "$result" ] || result="applied" + else + printf '%s\n' "$out"; log "::error::Output type apply failed for ${file}"; fail "${file} (output-type)"; result="failed" + fi + ref="$(printf '%s\n' "$out" | sed -n "s/^✓ Output type '\(@[^']*\)'.*\$/\1/p" | tail -n1)" + notes="$(printf '%s\n' "$out" | sed -n 's/^ ⚠️ //p' | sed 's/|/\\|/g' | paste -sd ';' - | sed 's/;/; /g')"; [ -n "$notes" ] || notes="—" + echo "| \`${file}\` | \`${ref:-?}\` | ${result} | ${notes} |" >> "$section" + log "::endgroup::" + done + { echo; echo "_On a pull request an output type is only created when the control plane does not have it. An existing one is left untouched — any change listed against it lands when this PR merges._"; } >> "$section" +} + +# --- modules ------------------------------------------------------------------ + +auto_create_flag() { [ "${AUTO_CREATE_INTENTS:-true}" = "true" ] && printf '%s' "--auto-create"; } + +preview_modules() { + local dirs="$1" comment="${CI_COMMENT_DIR}/preview_modules.md" ac; ac="$(auto_create_flag)" + [ -n "$dirs" ] || return 0 + { echo "### Facets module preview"; echo; echo "This branch now owns the single **preview slot** for each module below (concurrent MRs on the same module: last push wins)."; echo; echo "| Module | Type | Flavor | Version | Result |"; echo "|---|---|---|---|---|"; } > "$comment" + local dir type flavor version ref result ok + for dir in $dirs; do + log "::group::Preview ${dir}" + type="$(meta "$dir" intent)"; flavor="$(meta "$dir" flavor)"; version="$(meta "$dir" version)"; ref="${type}/${flavor}/${version}"; result="previewed"; ok=1 + if [ -z "$type" ] || [ -z "$flavor" ] || [ -z "$version" ]; then log "::error::Could not read intent/flavor/version from ${dir}/facets.yaml"; fail "${dir} (metadata)"; result="bad facets.yaml"; ok=0; fi + if [ "$ok" = 1 ] && ! raptor create iac-module -f "$dir" --dry-run; then log "::error::Validation failed for ${ref}"; fail "${ref} (validate)"; result="validation failed"; ok=0; fi + # shellcheck disable=SC2086 # $ac is a deliberate empty-or-one-flag word + if [ "$ok" = 1 ] && ! raptor create iac-module -f "$dir" $ac --feature-branch --git-ref "${CI_HEAD_SHA:-}"; then log "::error::Preview registration failed for ${ref}"; fail "${ref} (preview)"; result="preview failed"; ok=0; fi + echo "| \`${dir}\` | ${type:-?} | ${flavor:-?} | ${version:-?} | ${result} |" >> "$comment" + log "::endgroup::" + done + { echo; echo "_Previews are unpublishable feature-branch registrations pinned to the source commit. They are cleaned up when this request is closed (ownership-checked)._"; } >> "$comment" +} + +publish_modules() { + local dirs="$1" ac; ac="$(auto_create_flag)" + [ -n "$dirs" ] || return 0 + local dir type flavor version ref modules_json declaration pub_any pub_target + for dir in $dirs; do + log "::group::Publish ${dir}" + type="$(meta "$dir" intent)"; flavor="$(meta "$dir" flavor)"; version="$(meta "$dir" version)" + if [ -z "$type" ] || [ -z "$flavor" ] || [ -z "$version" ]; then log "::error::Could not read intent/flavor/version from ${dir}/facets.yaml"; fail "${dir} (metadata)"; log "::endgroup::"; continue; fi + ref="${type}/${flavor}/${version}" + log "Uploading ${ref}..." + # shellcheck disable=SC2086 # $ac is a deliberate empty-or-one-flag word + if ! raptor create iac-module -f "$dir" $ac; then log "::error::Upload failed for ${ref}"; fail "${ref} (upload)"; log "::endgroup::"; continue; fi + if ! modules_json="$(raptor get iac-module -o json 2>/dev/null)"; then log "::error::Could not list modules for ${ref}"; fail "${ref} (publish)"; log "::endgroup::"; continue; fi + _family() { printf '%s' "$modules_json" | jq -r --arg t "$type" --arg f "$flavor" --arg v "$1" '(if type=="array" then . else [] end) | map(select(.intent==$t and .flavor==$f and .stage=="PUBLISHED" and ($v=="" or .version==$v))) | length' 2>/dev/null || echo ""; } + pub_any="$(_family "")"; pub_target="$(_family "$version")" + if [ -z "$pub_any" ] || [ -z "$pub_target" ]; then log "::error::Could not read published versions of ${type}/${flavor} for ${ref}"; fail "${ref} (publish)"; log "::endgroup::"; continue; fi + declaration="" + if [ "$pub_target" != "0" ]; then declaration="--backward-compatible yes"; log "${version} already published: declaring a compatible update." + elif [ "$pub_any" != "0" ]; then declaration="--versionupgrade"; log "${version} is a new contract version: declaring a version upgrade." + else log "First publication of ${type}/${flavor}: no declaration needed."; fi + log "Publishing ${ref}..." + # shellcheck disable=SC2086 # $declaration is a deliberate empty-or-flag word list + if ! raptor publish iac-module "$ref" $declaration; then log "::error::Publish failed for ${ref}"; fail "${ref} (publish)"; log "::endgroup::"; continue; fi + log "Published ${ref}"; log "::endgroup::" + done +} + +cleanup_previews() { + local dirs="$1" + [ -n "$dirs" ] || return 0 + if [ -z "${CI_OWNER_SHAS:-}" ]; then log "::warning::No ownership SHAs provided; preview ownership can't be verified. Skipping cleanup."; return 0; fi + local modules_json list_ok + if modules_json="$(raptor get iac-module -o json 2>/dev/null)"; then list_ok=1; else list_ok=0; modules_json='[]'; log "::warning::Could not list modules; cleanup will skip every module."; fi + _owner_sha() { printf '%s' "$modules_json" | jq -r --arg t "$1" --arg f "$2" --arg v "$3" '(if type=="array" then . else [] end) | map(select(.intent==$t and .flavor==$f and .version==$v)) as $rows | ($rows | map(select(.stage=="PREVIEW"))[0]) as $p | ($rows | map(select(.stage=="PUBLISHED" and ((.previewModuleId // "") != "")))[0]) as $pub | if $p then ($p.gitRef // "") elif $pub then ($pub.previewGitRef // "") else "" end' 2>/dev/null || true; } + _rows() { printf '%s' "$modules_json" | jq -r --arg t "$1" --arg f "$2" --arg v "$3" '(if type=="array" then . else [] end) | map(select(.intent==$t and .flavor==$f and .version==$v)) | length' 2>/dev/null || echo 0; } + local dir type flavor version ref owner owned sha fresh + for dir in $dirs; do + log "::group::Cleanup ${dir}" + type="$(meta "$dir" intent)"; flavor="$(meta "$dir" flavor)"; version="$(meta "$dir" version)" + if [ -z "$type" ] || [ -z "$flavor" ] || [ -z "$version" ]; then log "::warning::Could not read facets.yaml for ${dir}; skipping."; log "::endgroup::"; continue; fi + ref="${type}/${flavor}/${version}" + owner="$(_owner_sha "$type" "$flavor" "$version")" + if [ -z "$owner" ] || [ "$owner" = "null" ]; then + if [ "$list_ok" = 1 ] && [ "$(_rows "$type" "$flavor" "$version")" != "0" ]; then log "::warning::${ref} is in the registry but no owning commit resolved; skipping."; else log "No preview slot recorded for ${ref}; nothing to clean up."; fi + log "::endgroup::"; continue + fi + owned="false"; while IFS= read -r sha; do [ -z "$sha" ] && continue; [ "$sha" = "$owner" ] && { owned="true"; break; }; done <<< "${CI_OWNER_SHAS}" + if [ "$owned" != "true" ]; then log "Preview for ${ref} is owned by another branch (commit=${owner}); leaving it."; log "::endgroup::"; continue; fi + fresh="$(_owner_sha_fresh "$type" "$flavor" "$version")" + if [ "$fresh" != "$owner" ]; then log "::warning::Preview slot for ${ref} changed between check and delete (was ${owner}, now ${fresh:-none}); skipping."; log "::endgroup::"; continue; fi + log "Deleting preview for ${ref} (owned by this request, commit=${owner})..." + if ! raptor delete iac-module "$ref" --stage PREVIEW --yes; then log "::error::Failed to delete preview for ${ref}"; fail "${ref} (cleanup)"; fi + log "::endgroup::" + done +} +# TOCTOU re-check: re-fetch the live slot owner right before delete. +_owner_sha_fresh() { printf '%s' "$(raptor get iac-module -o json 2>/dev/null || echo '[]')" | jq -r --arg t "$1" --arg f "$2" --arg v "$3" '(if type=="array" then . else [] end) | map(select(.intent==$t and .flavor==$f and .version==$v)) as $rows | ($rows | map(select(.stage=="PREVIEW"))[0]) as $p | ($rows | map(select(.stage=="PUBLISHED" and ((.previewModuleId // "") != "")))[0]) as $pub | if $p then ($p.gitRef // "") elif $pub then ($pub.previewGitRef // "") else "" end' 2>/dev/null || true; } + +report_gate() { + log "=== Facets Module CI summary (mode: ${MODE}) ===" + if [ -f "$FAILURES" ] && [ -s "$FAILURES" ]; then + log "The following modules failed:"; sed 's/^/ - /' "$FAILURES"; return 1 + fi + log "All processed modules succeeded (or there was nothing to do)."; return 0 +} + +# --- main --------------------------------------------------------------------- + +if [ "$MODE" = "noop" ]; then log "Mode is noop; nothing to do."; exit 0; fi +install_tools + +DIRS="$(detect_module_dirs)" +log "Target module dirs: ${DIRS:-}" +case "$MODE" in + preview|publish) + if [ "${APPLY_OUTPUT_TYPES}" = "true" ]; then + OUT="$(detect_output_files)"; log "Target output defs: ${OUT:-}" + apply_output_types "$OUT" + fi + if [ "$MODE" = "preview" ]; then preview_modules "$DIRS"; else publish_modules "$DIRS"; fi + ;; + cleanup) cleanup_previews "$DIRS" ;; + *) log "::error::Unknown MODE '${MODE}'"; exit 1 ;; +esac + +log "Comment markdown in: ${CI_COMMENT_DIR}" +report_gate From b386d500227e8ca254889f405cdf43999495aec1 Mon Sep 17 00:00:00 2001 From: SatyendraGollaFacets Date: Fri, 16 Oct 2026 11:08:56 +0530 Subject: [PATCH 2/3] refactor(module-ci-action): call shared module-ci-core.sh The 776-line inlined action is now thin glue: resolve mode + map the GitHub event to the generic env contract, resolve PR commit SHAs for cleanup, run the shared core, and post the PR preview comment (if: always() so a gated failure still comments). All raptor-driving logic lives in module-ci-core.sh. action.yml parses + every embedded script is shellcheck-clean (the repo's CI bar). Co-Authored-By: Claude Opus 4.8 --- module-ci-action/action.yml | 746 ++++-------------------------------- 1 file changed, 71 insertions(+), 675 deletions(-) diff --git a/module-ci-action/action.yml b/module-ci-action/action.yml index 6baf181..dea4cd8 100644 --- a/module-ci-action/action.yml +++ b/module-ci-action/action.yml @@ -13,43 +13,43 @@ inputs: description: "Facets API token (provisioned as the FACETS_TOKEN secret)." required: true github_token: - description: "GitHub token. Optional; enables the PR preview comment (preview mode) and reading the PR's commit SHAs for ownership-checked cleanup (cleanup mode). When absent, those features are skipped." + description: "GitHub token. Optional; enables the PR preview comment and reading the PR's commit SHAs for ownership-checked cleanup. When absent, those features are skipped." required: false default: "" raptor_version: - description: "raptor CLI version to install: 'latest' or an exact release tag (e.g. v1.2.3). Ignored when raptor-download-url is set." + description: "raptor CLI version to install: 'latest' or an exact release tag. Ignored when raptor-download-url is set." required: false default: "latest" raptor-download-url: - description: "Exact URL to download the raptor linux-amd64 binary from, bypassing the default Facets-cloud/raptor-releases location. When set, raptor_version is ignored. Escape hatch for testing/pre-release builds and enterprise mirrors." + description: "Exact URL to download the raptor linux-amd64 binary from, bypassing the default location." required: false default: "" terraform-version: - description: "Terraform version to install for raptor's module validation (terraform fmt/validate). Downloaded from releases.hashicorp.com. Not installed in cleanup mode." + description: "Terraform version for raptor's module validation." required: false default: "1.5.7" trivy-version: - description: "Trivy version to install for raptor's module security scan. Downloaded from github.com/aquasecurity/trivy releases (no 'v' prefix). Not installed in cleanup mode." + description: "Trivy version for raptor's module security scan (no 'v' prefix)." required: false default: "0.72.0" all-modules: - description: "When 'true', operate on every module under modules/ and every output type under outputs/, instead of only the ones changed by the triggering event." + description: "When 'true', operate on every module and output type, not only the ones changed by the triggering event." required: false default: "false" apply-output-types: - description: "When 'true' (default), apply the output type definitions under outputs/ before touching any module. A module names its output type by reference and the Control Plane refuses a module whose type it does not already hold, so the order matters. Set to 'false' only when the output types are managed outside this repository." + description: "When 'true' (default), apply the output type definitions under outputs/ before any module." required: false default: "true" mode: - description: "One of auto|preview|publish|cleanup. 'auto' (default) derives the mode from the event: pull_request opened/synchronize/reopened -> preview; push -> publish; pull_request closed -> cleanup." + description: "One of auto|preview|publish|cleanup. 'auto' derives the mode from the event." required: false default: "auto" path-prefix: - description: "Sub-path to the modules/ tree, relative to the repo root (e.g. 'infra/' when modules live at infra/modules/...). Empty means modules/ is at the repo root." + description: "Sub-path to the modules/ tree, relative to the repo root. Empty means modules/ is at the repo root." required: false default: "" auto-create-intents: - description: "When 'true' (default), pass --auto-create to raptor so a module whose intent does not yet exist on the Control Plane creates it. Without this, the first module of a new intent fails with 404 'Intent not found' and a modules repo cannot bootstrap a fresh Control Plane. NOTE: --auto-create also rewrites an EXISTING intent's metadata from this module's intentDetails, and an intent is shared by all its flavors — so when two flavors disagree, the last one published wins. Set to 'false' to keep intent metadata frozen, at the cost of failing on any new intent." + description: "When 'true' (default), pass --auto-create so a module whose intent does not yet exist creates it." required: false default: "true" @@ -61,716 +61,112 @@ runs: with: fetch-depth: 0 - - name: Install raptor CLI - shell: bash - run: | - set -euo pipefail - VERSION="${{ inputs.raptor_version }}" - DOWNLOAD_URL="${{ inputs.raptor-download-url }}" - if [ -n "$DOWNLOAD_URL" ]; then - # Explicit override: fetch the binary from exactly this URL (raptor_version ignored). - URL="$DOWNLOAD_URL" - elif [ -z "$VERSION" ] || [ "$VERSION" = "latest" ]; then - URL="https://github.com/Facets-cloud/raptor-releases/releases/latest/download/raptor-linux-amd64" - else - URL="https://github.com/Facets-cloud/raptor-releases/releases/download/${VERSION}/raptor-linux-amd64" - fi - echo "Installing raptor from ${URL}" - curl -fsSL -o raptor "$URL" - chmod +x raptor - sudo mv raptor /usr/local/bin/raptor - raptor version 2>/dev/null || raptor --version 2>/dev/null || true - - - name: Resolve CI mode + - name: Resolve CI mode and map event to the generic contract shell: bash env: INPUT_MODE: ${{ inputs.mode }} EVENT_NAME: ${{ github.event_name }} EVENT_ACTION: ${{ github.event.action }} PR_MERGED: ${{ github.event.pull_request.merged }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PUSH_BEFORE: ${{ github.event.before }} + PUSH_AFTER: ${{ github.sha }} run: | set -euo pipefail - # NOTE: raptor auth (CONTROL_PLANE_URL / FACETS_USERNAME / FACETS_TOKEN) is - # NOT exported here. It is set as a step-scoped env: block on each step that - # invokes raptor, so the token never leaks into other steps of the caller's job. MODE="${INPUT_MODE:-auto}" if [ "$MODE" = "auto" ] || [ -z "$MODE" ]; then case "$EVENT_NAME" in - pull_request) - if [ "$EVENT_ACTION" = "closed" ]; then MODE="cleanup"; else MODE="preview"; fi - ;; - push) - MODE="publish" - ;; + pull_request) if [ "$EVENT_ACTION" = "closed" ]; then MODE="cleanup"; else MODE="preview"; fi ;; + push) MODE="publish" ;; pull_request_target) - echo "::error::pull_request_target is not supported — it checks out the BASE ref, so the changed-file diff is empty and git provenance is wrong. Use a 'pull_request' trigger instead." - exit 1 - ;; - *) - echo "::error::Cannot derive mode from event '${EVENT_NAME}'. Set the 'mode' input explicitly (preview|publish|cleanup)." - exit 1 - ;; + echo "::error::pull_request_target is not supported — it checks out the BASE ref, so the changed-file diff is empty and git provenance is wrong. Use a 'pull_request' trigger instead."; exit 1 ;; + *) echo "::error::Cannot derive mode from event '${EVENT_NAME}'. Set the 'mode' input explicitly."; exit 1 ;; esac fi - case "$MODE" in - preview|publish|cleanup) ;; - *) echo "::error::Invalid mode '${MODE}' (expected auto|preview|publish|cleanup)."; exit 1 ;; - esac - - # On a MERGED pull_request close, the concurrent push (publish) run - # re-uploads and publishes the preview at the merge commit, consuming the - # slot. Cleanup would then be redundant AND racy: its ownership check reads - # the old PR-head SHA while its delete resolves later, so a bad interleaving - # could delete the merge-SHA preview the publish run just created and kill - # the publish. Only clean up PRs closed WITHOUT merging. + case "$MODE" in preview|publish|cleanup) ;; *) echo "::error::Invalid mode '${MODE}'."; exit 1 ;; esac + # A merged-PR close is handled by the concurrent push (publish) run; cleanup would be redundant and racy. if [ "$MODE" = "cleanup" ] && [ "$PR_MERGED" = "true" ]; then - echo "PR merged — the publish run owns the preview slot; nothing to clean." - MODE="noop" - fi - - echo "Resolved mode: ${MODE} (event=${EVENT_NAME}, action=${EVENT_ACTION:-none})" - echo "MODE=${MODE}" >> "$GITHUB_ENV" - - - name: Install Terraform and Trivy - # Only preview/publish run raptor's module validation, which shells out to - # terraform and trivy. Cleanup (and the merged-PR no-op) need neither. - if: env.MODE == 'preview' || env.MODE == 'publish' - shell: bash - env: - TERRAFORM_VERSION: ${{ inputs.terraform-version }} - TRIVY_VERSION: ${{ inputs.trivy-version }} - run: | - set -euo pipefail - - # Terraform — raptor's module validation runs `terraform fmt`/`validate`, - # and ubuntu-latest runners ship no terraform (raptor hard-fails without it). - echo "Installing Terraform ${TERRAFORM_VERSION}..." - TF_ZIP="terraform_${TERRAFORM_VERSION}_linux_amd64.zip" - curl -fsSL -o "$TF_ZIP" "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/${TF_ZIP}" - unzip -o "$TF_ZIP" terraform - sudo mv terraform /usr/local/bin/terraform - rm -f "$TF_ZIP" - terraform version - - # Trivy — raptor's security scan uses it. Without it raptor only warns, but - # we want the scan enforced in CI. - echo "Installing Trivy ${TRIVY_VERSION}..." - TRIVY_TGZ="trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" - curl -fsSL -o "$TRIVY_TGZ" "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/${TRIVY_TGZ}" - tar -xzf "$TRIVY_TGZ" trivy - sudo mv trivy /usr/local/bin/trivy - rm -f "$TRIVY_TGZ" - trivy --version - - - name: Detect target module directories - shell: bash - env: - ALL_MODULES: ${{ inputs.all-modules }} - PATH_PREFIX: ${{ inputs.path-prefix }} - EVENT_NAME: ${{ github.event_name }} - PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} - PUSH_BEFORE: ${{ github.event.before }} - PUSH_AFTER: ${{ github.sha }} - run: | - set -uo pipefail - - PREFIX="${PATH_PREFIX}" - if [ -n "$PREFIX" ] && [ "${PREFIX%/}" = "$PREFIX" ]; then - PREFIX="${PREFIX}/" + echo "PR merged — the publish run owns the preview slot; nothing to clean."; MODE="noop" fi - MODULES_ROOT="${PREFIX}modules" - echo "Modules root: ${MODULES_ROOT}" - - collect_all() { - if [ -d "$MODULES_ROOT" ]; then - find "$MODULES_ROOT" -type f -name facets.yaml -exec dirname {} \; | sort -u + # Form the generic git diff range: push -> BEFORE..AFTER (empty when the before-SHA is + # absent/unreachable, so the core falls back to the pushed commit's own tree); PR -> BASE...HEAD. + RANGE="" + if [ "$EVENT_NAME" = "push" ]; then + if [ -n "$PUSH_BEFORE" ] && [ "$PUSH_BEFORE" != "0000000000000000000000000000000000000000" ] \ + && git rev-parse --verify --quiet "${PUSH_BEFORE}^{commit}" >/dev/null 2>&1; then + RANGE="${PUSH_BEFORE}..${PUSH_AFTER}" fi - } - - changed_files() { - if [ "$EVENT_NAME" = "push" ]; then - # Fall back to the pushed commit's own diff-tree when the before-SHA is - # absent, all-zeros (new branch), or unreachable (force-push / rebase) — - # otherwise `git diff` errors and every module is silently skipped. - if [ -z "$PUSH_BEFORE" ] || [ "$PUSH_BEFORE" = "0000000000000000000000000000000000000000" ] \ - || ! git rev-parse --verify --quiet "${PUSH_BEFORE}^{commit}" >/dev/null 2>&1; then - git diff-tree --no-commit-id --name-only -r "$PUSH_AFTER" - else - git diff --name-only "${PUSH_BEFORE}..${PUSH_AFTER}" - fi - else - # pull_request: base..HEAD. HEAD is the checkout merge commit; the - # three-dot form diffs against the merge base. - git diff --name-only "${PR_BASE_SHA}...HEAD" - fi - } - - # Map a changed file to the nearest ANCESTOR directory that holds a - # facets.yaml, so a change inside modules/x/y/1.0/templates/foo.tpl still - # maps to modules/x/y/1.0 (immediate-dirname mapping would drop it and ship - # the module unvalidated). Bounded by the repo root; empty when none found. - find_module_dir() { - local d - d="$(dirname "$1")" - while [ -n "$d" ] && [ "$d" != "." ] && [ "$d" != "/" ]; do - if [ -f "$d/facets.yaml" ]; then - printf '%s\n' "$d" - return 0 - fi - d="$(dirname "$d")" - done - if [ -f "facets.yaml" ]; then printf '%s\n' "."; fi - } - - if [ "$ALL_MODULES" = "true" ]; then - echo "all-modules=true: scanning ${MODULES_ROOT} for every facets.yaml" - CANDIDATES="$(collect_all || true)" - else - CANDIDATES="" - while IFS= read -r f; do - [ -z "$f" ] && continue - md="$(find_module_dir "$f" || true)" - [ -n "$md" ] && CANDIDATES="${CANDIDATES} ${md}" - done <<< "$(changed_files || true)" + elif [ "$EVENT_NAME" = "pull_request" ]; then + RANGE="${PR_BASE_SHA}...HEAD" fi + echo "Resolved mode: ${MODE} (event=${EVENT_NAME}, action=${EVENT_ACTION:-none})" + { + echo "MODE=${MODE}" + echo "CI_DIFF_RANGE=${RANGE}" + echo "CI_HEAD_SHA=${PR_HEAD_SHA}" + } >> "$GITHUB_ENV" - DIRS="" - for d in $CANDIDATES; do - # Keep only directories under modules/ that contain a facets.yaml. - case "$d/" in - "$MODULES_ROOT"/*) ;; - *) continue ;; - esac - if [ -f "$d/facets.yaml" ]; then - DIRS="$DIRS $d" - fi - done - # shellcheck disable=SC2086 # deliberately re-split the accumulated space-separated list - DIRS="$(printf '%s\n' $DIRS | sed '/^$/d' | sort -u | tr '\n' ' ' | sed 's/[[:space:]]*$//')" - - if [ -z "$DIRS" ]; then - echo "No target module directories detected." - else - echo "Target module directories:" - for d in $DIRS; do echo " - $d"; done - fi - echo "dirs=${DIRS}" >> "$GITHUB_ENV" - - - name: Detect target output type definitions - if: (env.MODE == 'preview' || env.MODE == 'publish') && inputs.apply-output-types == 'true' - shell: bash - env: - ALL_MODULES: ${{ inputs.all-modules }} - PATH_PREFIX: ${{ inputs.path-prefix }} - EVENT_NAME: ${{ github.event_name }} - PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} - PUSH_BEFORE: ${{ github.event.before }} - PUSH_AFTER: ${{ github.sha }} - run: | - set -uo pipefail - - PREFIX="${PATH_PREFIX}" - if [ -n "$PREFIX" ] && [ "${PREFIX%/}" = "$PREFIX" ]; then - PREFIX="${PREFIX}/" - fi - OUTPUTS_ROOT="${PREFIX}outputs" - echo "Outputs root: ${OUTPUTS_ROOT}" - - # Same change detection as the modules step — kept as its own copy rather than shared, - # because an output type is ONE FILE, not a directory: there is no facets.yaml to walk - # up to, and a changed file IS the unit of work. - changed_files() { - if [ "$EVENT_NAME" = "push" ]; then - if [ -z "$PUSH_BEFORE" ] || [ "$PUSH_BEFORE" = "0000000000000000000000000000000000000000" ] \ - || ! git rev-parse --verify --quiet "${PUSH_BEFORE}^{commit}" >/dev/null 2>&1; then - git diff-tree --no-commit-id --name-only -r "$PUSH_AFTER" - else - git diff --name-only "${PUSH_BEFORE}..${PUSH_AFTER}" - fi - else - git diff --name-only "${PR_BASE_SHA}...HEAD" - fi - } - - is_definition() { - case "${1,,}" in - *.yaml|*.yml|*.json) return 0 ;; - *) return 1 ;; - esac - } - - FILES="" - if [ "$ALL_MODULES" = "true" ]; then - echo "all-modules=true: scanning ${OUTPUTS_ROOT} for every definition" - if [ -d "$OUTPUTS_ROOT" ]; then - FILES="$(find "$OUTPUTS_ROOT" -type f \( -name '*.yaml' -o -name '*.yml' -o -name '*.json' \) | sort)" - fi - else - while IFS= read -r f; do - [ -z "$f" ] && continue - case "$f/" in - "$OUTPUTS_ROOT"/*) ;; - *) continue ;; - esac - # A deleted file is intentionally NOT a target: CI never deletes an output type. - # See the README — an output type is global and unversioned, so removing its file - # leaves the type in place rather than breaking every module that reads it. - [ -f "$f" ] || continue - is_definition "$f" || continue - FILES="${FILES} ${f}" - done <<< "$(changed_files || true)" - fi - - # shellcheck disable=SC2086 # deliberately re-split the accumulated space-separated list - FILES="$(printf '%s\n' $FILES | sed '/^$/d' | sort -u | tr '\n' ' ' | sed 's/[[:space:]]*$//')" - - if [ -z "$FILES" ]; then - echo "No target output type definitions detected." - else - echo "Target output type definitions:" - for f in $FILES; do echo " - $f"; done - fi - echo "output_files=${FILES}" >> "$GITHUB_ENV" - - - name: Apply output types - if: (env.MODE == 'preview' || env.MODE == 'publish') && env.output_files != '' + - name: Resolve preview ownership SHAs (cleanup) + if: env.MODE == 'cleanup' && inputs.github_token != '' shell: bash env: - CONTROL_PLANE_URL: ${{ inputs.control_plane_url }} - FACETS_USERNAME: ${{ inputs.username }} - FACETS_TOKEN: ${{ inputs.token }} + GH_TOKEN: ${{ inputs.github_token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -uo pipefail - FAILURES="${RUNNER_TEMP}/ci_failures" - SECTION="${RUNNER_TEMP}/preview_outputs.md" - - # An output type is GLOBAL and UNVERSIONED — the Control Plane keys it on name+namespace - # only, with no stage and no version, and a write replaces it for every module at once. - # So the two modes differ in kind, not in degree: - # - # preview (pull request): --if-absent. A type the Control Plane has never seen has no - # consumers, so creating it is safe AND necessary — without it, a pull request that - # adds a module together with its new output type cannot validate its own module - # (the upload 404s with "Output @ns/name not found"). A type that already exists is - # only reported, never written: there is no preview slot to hold a change and no - # rollback if it is wrong. - # - # publish (push): the full apply. raptor still refuses a change that removes or retypes - # a field while some module uses the type; that is a real gate, not advice. - MODE_FLAG="" - if [ "$MODE" = "preview" ]; then - MODE_FLAG="--if-absent" - fi - - { - echo "### Facets output types" - echo "" - echo "| Definition | Type | Result | Reported changes |" - echo "|---|---|---|---|" - } > "$SECTION" - - # shellcheck disable=SC2086,SC2154 # $output_files is an intentional space-separated list injected via $GITHUB_ENV - for file in $output_files; do - echo "::group::Output type ${file}" - # Capture raptor's own outcome rather than restating the flag. The whole - # point of the comment is to answer "which of these did this PR create, - # and which already exist and will therefore CHANGE on merge?" — a - # per-file constant cannot answer it. - # shellcheck disable=SC2086 # $MODE_FLAG is a deliberate empty-or-one-flag word - if OUTPUT="$(raptor create output-type -f "$file" $MODE_FLAG 2>&1)"; then - printf '%s\n' "$OUTPUT" - RESULT="$(printf '%s\n' "$OUTPUT" | sed -n "s/^✓ Output type '.*' \(.*\)\$/\1/p" | tail -n1)" - [ -n "$RESULT" ] || RESULT="applied" - else - printf '%s\n' "$OUTPUT" - echo "::error::Output type apply failed for ${file}" - echo "${file} (output-type)" >> "$FAILURES" - RESULT="failed" - fi - - # The reference raptor resolved, and the compatibility findings it - # printed. A row that says "skipped (already exists)" with findings is - # the one a reviewer has to look at: those changes land on merge. - REF="$(printf '%s\n' "$OUTPUT" | sed -n "s/^✓ Output type '\(@[^']*\)'.*\$/\1/p" | tail -n1)" - # Join on "; " and escape "|", which would otherwise split the table cell. - NOTES="$(printf '%s\n' "$OUTPUT" | sed -n 's/^ ⚠️ //p' | sed 's/|/\\|/g' | paste -sd ';' - | sed 's/;/; /g')" - [ -n "$NOTES" ] || NOTES="—" - echo "| \`${file}\` | \`${REF:-?}\` | ${RESULT} | ${NOTES} |" >> "$SECTION" - echo "::endgroup::" - done - + SHAS="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits" --paginate --jq '.[].sha' 2>/dev/null || true)" + # Multi-line value: GITHUB_ENV heredoc form. { - echo "" - echo "_On a pull request an output type is only created when the control plane does not have it. A row that already exists is left untouched — any change listed against it lands when this PR merges, because an output type has no version and no preview stage._" - } >> "$SECTION" + echo "CI_OWNER_SHAS<<__EOF__" + printf '%s\n' "$SHAS" + echo "__EOF__" + } >> "$GITHUB_ENV" - - name: Preview modules (pull request) - if: env.MODE == 'preview' && env.dirs != '' + - name: Run Facets Module CI core shell: bash env: CONTROL_PLANE_URL: ${{ inputs.control_plane_url }} FACETS_USERNAME: ${{ inputs.username }} FACETS_TOKEN: ${{ inputs.token }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PATH_PREFIX: ${{ inputs.path-prefix }} + ALL_MODULES: ${{ inputs.all-modules }} + APPLY_OUTPUT_TYPES: ${{ inputs.apply-output-types }} AUTO_CREATE_INTENTS: ${{ inputs.auto-create-intents }} - # Trivy runs inside raptor's module validation, AFTER terraform init has - # vendored remote deps into .terraform/. Trivy reads flags from TRIVY_* - # env vars; skip the vendored tree so findings in dependency code the - # module author never wrote can't fail CI (issue #13). - TRIVY_SKIP_DIRS: "**/.terraform,**/.terraform/**" - run: | - set -uo pipefail - FAILURES="${RUNNER_TEMP}/ci_failures" - COMMENT="${RUNNER_TEMP}/preview_modules.md" - - # Read a top-level scalar key (intent/flavor/version) from a module's - # facets.yaml. Top-level keys have no leading whitespace; surrounding - # quotes and trailing comments are stripped. - meta() { - sed -n -E "s/^$2:[[:space:]]*(.*)$/\1/p" "$1/facets.yaml" | head -n1 \ - | sed -E "s/[[:space:]]*#.*$//; s/^[\"']//; s/[\"'][[:space:]]*$//; s/[[:space:]]*$//" - } - - { - echo "### Facets module preview" - echo "" - echo "This branch now owns the single **preview slot** for each module below (concurrent PRs on the same module: last push wins)." - echo "" - echo "| Module | Type | Flavor | Version | Result |" - echo "|---|---|---|---|---|" - } > "$COMMENT" - - # --auto-create makes raptor register the module's intent when the Control - # Plane has never seen that kind. Without it the upload 404s ("Intent - # not found"), which is what a modules repo hits the first time it - # carries a new intent — nothing else creates one: resource-type-mapping - # rejects unknown intents outright. The flag is NOT passed to --dry-run, - # which never touches the intent. - AUTO_CREATE="" - if [ "${AUTO_CREATE_INTENTS:-true}" = "true" ]; then - AUTO_CREATE="--auto-create" - fi - - # shellcheck disable=SC2086,SC2154 # $dirs is an intentional space-separated list injected via $GITHUB_ENV - for dir in $dirs; do - echo "::group::Preview ${dir}" - TYPE="$(meta "$dir" intent)"; FLAVOR="$(meta "$dir" flavor)"; VERSION="$(meta "$dir" version)" - REF="${TYPE}/${FLAVOR}/${VERSION}" - RESULT="previewed" - OK=1 - - if [ -z "$TYPE" ] || [ -z "$FLAVOR" ] || [ -z "$VERSION" ]; then - echo "::error::Could not read intent/flavor/version from ${dir}/facets.yaml" - echo "${dir} (metadata)" >> "$FAILURES" - RESULT="bad facets.yaml"; OK=0 - fi - - if [ "$OK" = "1" ]; then - echo "Validating ${REF} (dry-run)..." - if ! raptor create iac-module -f "$dir" --dry-run; then - echo "::error::Validation failed for ${REF}" - echo "${REF} (validate)" >> "$FAILURES" - RESULT="validation failed"; OK=0 - fi - fi - - if [ "$OK" = "1" ]; then - echo "Registering feature-branch preview for ${REF} at ${PR_HEAD_SHA}..." - # shellcheck disable=SC2086 # $AUTO_CREATE is a deliberate empty-or-one-flag word - if ! raptor create iac-module -f "$dir" $AUTO_CREATE --feature-branch --git-ref "$PR_HEAD_SHA"; then - echo "::error::Preview registration failed for ${REF}" - echo "${REF} (preview)" >> "$FAILURES" - RESULT="preview failed"; OK=0 - fi - fi - - echo "| \`${dir}\` | ${TYPE:-?} | ${FLAVOR:-?} | ${VERSION:-?} | ${RESULT} |" >> "$COMMENT" - echo "::endgroup::" - done - - { - echo "" - echo "_Previews are unpublishable feature-branch registrations pinned to the PR head commit. They are cleaned up when this PR is closed (ownership-checked)._" - } >> "$COMMENT" + RAPTOR_VERSION: ${{ inputs.raptor_version }} + RAPTOR_DOWNLOAD_URL: ${{ inputs.raptor-download-url }} + TERRAFORM_VERSION: ${{ inputs.terraform-version }} + TRIVY_VERSION: ${{ inputs.trivy-version }} + CI_COMMENT_DIR: ${{ runner.temp }} + run: bash "${{ github.action_path }}/../module-ci-core.sh" - name: Upsert preview PR comment - if: env.MODE == 'preview' && (env.dirs != '' || env.output_files != '') && inputs.github_token != '' + # always(): the core gates (fails) on a bad module, but the preview comment must still post + # so the PR shows what was (attempted to be) previewed. + if: ${{ always() && env.MODE == 'preview' && inputs.github_token != '' }} shell: bash env: GH_TOKEN: ${{ inputs.github_token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} + CI_COMMENT_DIR: ${{ runner.temp }} run: | set -uo pipefail - COMMENT="${RUNNER_TEMP}/preview_comment.md" - MODULES_SECTION="${RUNNER_TEMP}/preview_modules.md" - OUTPUTS_SECTION="${RUNNER_TEMP}/preview_outputs.md" - - # Assemble the body here rather than in the producing steps: a PR can touch output types - # only, modules only, or both, and the marker must sit in the final body exactly once. - echo "" > "$COMMENT" - if [ -f "$OUTPUTS_SECTION" ]; then - cat "$OUTPUTS_SECTION" >> "$COMMENT" - echo "" >> "$COMMENT" - fi - if [ -f "$MODULES_SECTION" ]; then - cat "$MODULES_SECTION" >> "$COMMENT" - fi - + BODY="${RUNNER_TEMP}/preview_comment.md" + MODULES_SECTION="${CI_COMMENT_DIR}/preview_modules.md" + OUTPUTS_SECTION="${CI_COMMENT_DIR}/preview_outputs.md" + echo "" > "$BODY" + [ -f "$OUTPUTS_SECTION" ] && { cat "$OUTPUTS_SECTION" >> "$BODY"; echo "" >> "$BODY"; } + [ -f "$MODULES_SECTION" ] && cat "$MODULES_SECTION" >> "$BODY" if [ ! -f "$MODULES_SECTION" ] && [ ! -f "$OUTPUTS_SECTION" ]; then - echo "No preview comment body found; nothing to post." - exit 0 + echo "No preview comment body found; nothing to post."; exit 0 fi MARKER="facets-module-ci:preview" EXISTING="$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate \ --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" 2>/dev/null | head -n1 || true)" if [ -n "$EXISTING" ]; then - echo "Updating existing preview comment (${EXISTING})" - gh api -X PATCH "repos/${REPO}/issues/comments/${EXISTING}" -F body=@"$COMMENT" >/dev/null + gh api -X PATCH "repos/${REPO}/issues/comments/${EXISTING}" -F body=@"$BODY" >/dev/null else - echo "Creating preview comment on PR #${PR_NUMBER}" - gh api -X POST "repos/${REPO}/issues/${PR_NUMBER}/comments" -F body=@"$COMMENT" >/dev/null - fi - - - name: Publish modules (push) - if: env.MODE == 'publish' && env.dirs != '' - shell: bash - env: - CONTROL_PLANE_URL: ${{ inputs.control_plane_url }} - FACETS_USERNAME: ${{ inputs.username }} - FACETS_TOKEN: ${{ inputs.token }} - AUTO_CREATE_INTENTS: ${{ inputs.auto-create-intents }} - # Same rationale as the preview step: keep raptor's Trivy scan out of - # terraform-init'd dependencies under .terraform/ (issue #13). - TRIVY_SKIP_DIRS: "**/.terraform,**/.terraform/**" - run: | - set -uo pipefail - FAILURES="${RUNNER_TEMP}/ci_failures" - - meta() { - sed -n -E "s/^$2:[[:space:]]*(.*)$/\1/p" "$1/facets.yaml" | head -n1 \ - | sed -E "s/[[:space:]]*#.*$//; s/^[\"']//; s/[\"'][[:space:]]*$//; s/[[:space:]]*$//" - } - - # --auto-create makes raptor register the module's intent when the Control - # Plane has never seen that kind. Without it the upload 404s ("Intent - # not found"), which is what a modules repo hits the first time it - # carries a new intent — nothing else creates one: resource-type-mapping - # rejects unknown intents outright. The flag is NOT passed to --dry-run, - # which never touches the intent. - AUTO_CREATE="" - if [ "${AUTO_CREATE_INTENTS:-true}" = "true" ]; then - AUTO_CREATE="--auto-create" - fi - - # shellcheck disable=SC2086,SC2154 # $dirs is an intentional space-separated list injected via $GITHUB_ENV - for dir in $dirs; do - echo "::group::Publish ${dir}" - TYPE="$(meta "$dir" intent)"; FLAVOR="$(meta "$dir" flavor)"; VERSION="$(meta "$dir" version)" - if [ -z "$TYPE" ] || [ -z "$FLAVOR" ] || [ -z "$VERSION" ]; then - echo "::error::Could not read intent/flavor/version from ${dir}/facets.yaml" - echo "${dir} (metadata)" >> "$FAILURES" - echo "::endgroup::"; continue - fi - REF="${TYPE}/${FLAVOR}/${VERSION}" - - # Upload as PREVIEW. Git provenance is auto-detected from the work tree - # (HEAD == the pushed commit on a push build). - echo "Uploading ${REF}..." - # shellcheck disable=SC2086 # $AUTO_CREATE is a deliberate empty-or-one-flag word - if ! raptor create iac-module -f "$dir" $AUTO_CREATE; then - echo "::error::Upload failed for ${REF}" - echo "${REF} (upload)" >> "$FAILURES" - echo "::endgroup::"; continue - fi - - # raptor refuses to publish over an already-published version without an - # explicit compatibility declaration, and refuses a NEW version without - # --versionupgrade. A bare publish therefore fails for every module a - # repository has already shipped. - # - # In a repo-first flow the author HAS declared, in git: editing - # modules/{intent}/{flavor}/0.2 in place says "compatible"; adding a 0.3 - # directory says "breaking". Read that choice back off the registry and - # pass the matching flag. Version ORDERING stays raptor's job — a bump - # that is not actually greater is rejected there, and must be. - if ! MODULES_JSON="$(raptor get iac-module -o json 2>/dev/null)"; then - echo "::error::Could not list modules to determine the compatibility declaration for ${REF}" - echo "${REF} (publish)" >> "$FAILURES" - echo "::endgroup::"; continue - fi - family() { - printf '%s' "$MODULES_JSON" | jq -r --arg t "$TYPE" --arg f "$FLAVOR" --arg v "$1" \ - '(if type=="array" then . else [] end) - | map(select(.intent==$t and .flavor==$f and .stage=="PUBLISHED" - and ($v=="" or .version==$v))) - | length' 2>/dev/null || echo "" - } - PUBLISHED_ANY="$(family "")" - PUBLISHED_TARGET="$(family "$VERSION")" - if [ -z "$PUBLISHED_ANY" ] || [ -z "$PUBLISHED_TARGET" ]; then - # A failed count is not "nothing is published" — that reading would send - # a first-publication (no flag) for a module that already has one. - echo "::error::Could not read published versions of ${TYPE}/${FLAVOR} for ${REF}" - echo "${REF} (publish)" >> "$FAILURES" - echo "::endgroup::"; continue - fi - - DECLARATION="" - if [ "$PUBLISHED_TARGET" != "0" ]; then - DECLARATION="--backward-compatible yes" - echo "${VERSION} is already published: declaring a compatible update." - elif [ "$PUBLISHED_ANY" != "0" ]; then - DECLARATION="--versionupgrade" - echo "${VERSION} is a new contract version for ${TYPE}/${FLAVOR}: declaring a version upgrade." - else - echo "First publication of ${TYPE}/${FLAVOR}: no declaration needed." - fi - - echo "Publishing ${REF}..." - # shellcheck disable=SC2086 # $DECLARATION is a deliberate empty-or-flag word list - if ! raptor publish iac-module "$REF" $DECLARATION; then - echo "::error::Publish failed for ${REF}" - echo "${REF} (publish)" >> "$FAILURES" - echo "::endgroup::"; continue - fi - echo "Published ${REF}" - echo "::endgroup::" - done - - - name: Cleanup previews (pull request closed) - if: env.MODE == 'cleanup' && env.dirs != '' - shell: bash - env: - CONTROL_PLANE_URL: ${{ inputs.control_plane_url }} - FACETS_USERNAME: ${{ inputs.username }} - FACETS_TOKEN: ${{ inputs.token }} - GH_TOKEN: ${{ inputs.github_token }} - HAS_TOKEN: ${{ inputs.github_token != '' }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -uo pipefail - FAILURES="${RUNNER_TEMP}/ci_failures" - - if [ "$HAS_TOKEN" != "true" ]; then - echo "::warning::github_token not provided; cannot read this PR's commit SHAs, so preview ownership can't be verified. Skipping cleanup." - exit 0 - fi - - # This PR's commit SHAs form the ownership set: a preview is only deleted - # if the commit that created it is one of these commits. - PR_SHAS="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits" --paginate --jq '.[].sha' 2>/dev/null || true)" - if [ -z "$PR_SHAS" ]; then - echo "::warning::Could not fetch commits for PR #${PR_NUMBER}; skipping cleanup." - exit 0 - fi - - # List once up front for ownership context. Distinguish a genuine list - # failure from an empty result, so a failed list is not silently read as - # "every module has no preview slot". - if MODULES_JSON="$(raptor get iac-module -o json 2>/dev/null)"; then - LIST_OK=1 - else - LIST_OK=0 - MODULES_JSON='[]' - echo "::warning::Could not list modules (raptor get iac-module failed); cleanup will skip every module." - fi - - meta() { - sed -n -E "s/^$2:[[:space:]]*(.*)$/\1/p" "$1/facets.yaml" | head -n1 \ - | sed -E "s/[[:space:]]*#.*$//; s/^[\"']//; s/[\"'][[:space:]]*$//; s/[[:space:]]*$//" - } - - # owner_sha -> the commit that owns the - # module's preview slot, resolved by stage: a PREVIEW row's own .gitRef, - # else a PUBLISHED row's .previewGitRef when it has a preview sibling - # (.previewModuleId). Tolerant of a non-array payload; empty when there is - # no preview slot or the list JSON carries no provenance. - owner_sha() { - printf '%s' "$1" | jq -r --arg t "$2" --arg f "$3" --arg v "$4" ' - (if type=="array" then . else [] end) - | map(select(.intent==$t and .flavor==$f and .version==$v)) as $rows - | ($rows | map(select(.stage=="PREVIEW"))[0]) as $preview - | ($rows | map(select(.stage=="PUBLISHED" and ((.previewModuleId // "") != "")))[0]) as $published - | if $preview then ($preview.gitRef // "") - elif $published then ($published.previewGitRef // "") - else "" end - ' 2>/dev/null || true - } - - # module_rows -> count of matching rows. - module_rows() { - printf '%s' "$1" | jq -r --arg t "$2" --arg f "$3" --arg v "$4" \ - '(if type=="array" then . else [] end) | map(select(.intent==$t and .flavor==$f and .version==$v)) | length' \ - 2>/dev/null || echo 0 - } - - # shellcheck disable=SC2086,SC2154 # $dirs is an intentional space-separated list injected via $GITHUB_ENV - for dir in $dirs; do - echo "::group::Cleanup ${dir}" - TYPE="$(meta "$dir" intent)"; FLAVOR="$(meta "$dir" flavor)"; VERSION="$(meta "$dir" version)" - if [ -z "$TYPE" ] || [ -z "$FLAVOR" ] || [ -z "$VERSION" ]; then - echo "::warning::Could not read intent/flavor/version from ${dir}/facets.yaml; skipping." - echo "::endgroup::"; continue - fi - REF="${TYPE}/${FLAVOR}/${VERSION}" - - OWNER_SHA="$(owner_sha "$MODULES_JSON" "$TYPE" "$FLAVOR" "$VERSION")" - if [ -z "$OWNER_SHA" ] || [ "$OWNER_SHA" = "null" ]; then - # The list succeeded and the module IS present, yet no owning commit - # resolved — surface it rather than silently claiming "no preview slot". - if [ "$LIST_OK" = "1" ] && [ "$(module_rows "$MODULES_JSON" "$TYPE" "$FLAVOR" "$VERSION")" != "0" ]; then - echo "::warning::${REF} is in the registry but no owning commit could be resolved (no preview slot, or the list JSON lacks provenance fields); skipping." - else - echo "No preview slot recorded for ${REF}; nothing to clean up." - fi - echo "::endgroup::"; continue - fi - - OWNED="false" - while IFS= read -r sha; do - [ -z "$sha" ] && continue - if [ "$sha" = "$OWNER_SHA" ]; then OWNED="true"; break; fi - done <<< "$PR_SHAS" - - if [ "$OWNED" != "true" ]; then - echo "Preview for ${REF} is owned by another branch (owning commit=${OWNER_SHA}); leaving it untouched." - echo "::endgroup::"; continue - fi - - # TOCTOU guard: the snapshot above may be stale (a concurrent run could - # have overwritten this slot). Re-fetch and re-verify the owning commit - # is still the one we validated as owned before deleting. A residual race - # remains between this re-check and the delete itself — see the README. - FRESH_SHA="$(owner_sha "$(raptor get iac-module -o json 2>/dev/null || echo '[]')" "$TYPE" "$FLAVOR" "$VERSION")" - if [ "$FRESH_SHA" != "$OWNER_SHA" ]; then - echo "::warning::Preview slot for ${REF} changed between the ownership check and delete (was ${OWNER_SHA}, now ${FRESH_SHA:-none}); skipping to avoid deleting a preview this PR no longer owns." - echo "::endgroup::"; continue - fi - - echo "Deleting preview for ${REF} (owned by this PR, owning commit=${OWNER_SHA})..." - # --stage PREVIEW targets ONLY the preview doc; without it a REF that has - # both a published and a preview doc would delete the live published - # module. Keep the usage guard (no --force) so a genuinely in-use preview - # surfaces as a real error rather than being force-deleted. - if ! raptor delete iac-module "$REF" --stage PREVIEW --yes; then - echo "::error::Failed to delete preview for ${REF}" - echo "${REF} (cleanup)" >> "$FAILURES" - fi - echo "::endgroup::" - done - - - name: Report summary and gate - if: always() - shell: bash - run: | - set -uo pipefail - FAILURES="${RUNNER_TEMP}/ci_failures" - echo "=== Facets Module CI summary (mode: ${MODE:-unknown}) ===" - if [ -f "$FAILURES" ] && [ -s "$FAILURES" ]; then - echo "The following modules failed:" - sed 's/^/ - /' "$FAILURES" - exit 1 + gh api -X POST "repos/${REPO}/issues/${PR_NUMBER}/comments" -F body=@"$BODY" >/dev/null fi - echo "All processed modules succeeded (or there was nothing to do)." From 87d779529eac64d998ba134cbc25e297fefcbb5f Mon Sep 17 00:00:00 2001 From: SatyendraGollaFacets Date: Fri, 16 Oct 2026 11:13:16 +0530 Subject: [PATCH 3/3] feat(module-ci-gitlab): add GitLab CI template + shared-core rename/root-safety - module-ci-gitlab/template.gitlab-ci.yml: thin GitLab glue mapping GitLab CI vars to the generic MC_* contract, fetching + running the shared core, posting the MR preview note in after_script (runs even when the core gates). Preview + publish supported; cleanup-on-close deferred (GitLab runs no pipeline on MR close). - Rename the generic contract vars CI_* -> MC_* (CI_ is GitLab's reserved prefix). - Core install is root-safe (GitLab containers run as root without sudo). - README + test workflow (YAML parse + shellcheck), mirroring module-ci-action. Co-Authored-By: Claude Opus 4.8 --- module-ci-action/action.yml | 14 ++-- module-ci-core.sh | 50 ++++++------ module-ci-gitlab/README.md | 50 ++++++++++++ module-ci-gitlab/template.gitlab-ci.yml | 101 ++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 29 deletions(-) create mode 100644 module-ci-gitlab/README.md create mode 100644 module-ci-gitlab/template.gitlab-ci.yml diff --git a/module-ci-action/action.yml b/module-ci-action/action.yml index dea4cd8..5f1480c 100644 --- a/module-ci-action/action.yml +++ b/module-ci-action/action.yml @@ -103,8 +103,8 @@ runs: echo "Resolved mode: ${MODE} (event=${EVENT_NAME}, action=${EVENT_ACTION:-none})" { echo "MODE=${MODE}" - echo "CI_DIFF_RANGE=${RANGE}" - echo "CI_HEAD_SHA=${PR_HEAD_SHA}" + echo "MC_DIFF_RANGE=${RANGE}" + echo "MC_HEAD_SHA=${PR_HEAD_SHA}" } >> "$GITHUB_ENV" - name: Resolve preview ownership SHAs (cleanup) @@ -119,7 +119,7 @@ runs: SHAS="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits" --paginate --jq '.[].sha' 2>/dev/null || true)" # Multi-line value: GITHUB_ENV heredoc form. { - echo "CI_OWNER_SHAS<<__EOF__" + echo "MC_OWNER_SHAS<<__EOF__" printf '%s\n' "$SHAS" echo "__EOF__" } >> "$GITHUB_ENV" @@ -138,7 +138,7 @@ runs: RAPTOR_DOWNLOAD_URL: ${{ inputs.raptor-download-url }} TERRAFORM_VERSION: ${{ inputs.terraform-version }} TRIVY_VERSION: ${{ inputs.trivy-version }} - CI_COMMENT_DIR: ${{ runner.temp }} + MC_COMMENT_DIR: ${{ runner.temp }} run: bash "${{ github.action_path }}/../module-ci-core.sh" - name: Upsert preview PR comment @@ -150,12 +150,12 @@ runs: GH_TOKEN: ${{ inputs.github_token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} - CI_COMMENT_DIR: ${{ runner.temp }} + MC_COMMENT_DIR: ${{ runner.temp }} run: | set -uo pipefail BODY="${RUNNER_TEMP}/preview_comment.md" - MODULES_SECTION="${CI_COMMENT_DIR}/preview_modules.md" - OUTPUTS_SECTION="${CI_COMMENT_DIR}/preview_outputs.md" + MODULES_SECTION="${MC_COMMENT_DIR}/preview_modules.md" + OUTPUTS_SECTION="${MC_COMMENT_DIR}/preview_outputs.md" echo "" > "$BODY" [ -f "$OUTPUTS_SECTION" ] && { cat "$OUTPUTS_SECTION" >> "$BODY"; echo "" >> "$BODY"; } [ -f "$MODULES_SECTION" ] && cat "$MODULES_SECTION" >> "$BODY" diff --git a/module-ci-core.sh b/module-ci-core.sh index c70c25b..1db87b6 100755 --- a/module-ci-core.sh +++ b/module-ci-core.sh @@ -4,15 +4,15 @@ # Shared by the GitHub action (module-ci-action) and the GitLab template # (module-ci-gitlab). Each host supplies thin glue that resolves the mode, # the git diff range, and the ownership SHAs, then calls this script; the -# host posts the review comment this script writes to $CI_COMMENT_DIR. +# host posts the review comment this script writes to $MC_COMMENT_DIR. # # Generic env contract (host glue sets these — this script reads NOTHING # host-specific like GITHUB_* or CI_MERGE_REQUEST_*): # MODE preview | publish | cleanup | noop -# CI_DIFF_RANGE git range for changed files ("base..head" / "base...head"); +# MC_DIFF_RANGE git range for changed files ("base..head" / "base...head"); # empty => diff-tree against HEAD (new branch / unknown base) -# CI_HEAD_SHA commit a preview is pinned to (preview mode) -# CI_OWNER_SHAS newline-separated commit SHAs that own previews (cleanup); +# MC_HEAD_SHA commit a preview is pinned to (preview mode) +# MC_OWNER_SHAS newline-separated commit SHAs that own previews (cleanup); # empty => cannot verify ownership, cleanup skipped # PATH_PREFIX sub-path to modules/ (e.g. "infra/"); empty => repo root # ALL_MODULES "true" => every module/output, not just changed ones @@ -20,7 +20,7 @@ # AUTO_CREATE_INTENTS "true" (default) => raptor --auto-create for new intents # CONTROL_PLANE_URL / FACETS_USERNAME / FACETS_TOKEN raptor auth (read by raptor) # RAPTOR_VERSION / RAPTOR_DOWNLOAD_URL / TERRAFORM_VERSION / TRIVY_VERSION -# CI_COMMENT_DIR dir to write preview_outputs.md / preview_modules.md +# MC_COMMENT_DIR dir to write preview_outputs.md / preview_modules.md # (default: a temp dir, printed at the end) set -uo pipefail @@ -34,9 +34,9 @@ RAPTOR_DOWNLOAD_URL="${RAPTOR_DOWNLOAD_URL:-}" TERRAFORM_VERSION="${TERRAFORM_VERSION:-1.5.7}" TRIVY_VERSION="${TRIVY_VERSION:-0.72.0}" WORK="$(mktemp -d)" -CI_COMMENT_DIR="${CI_COMMENT_DIR:-$WORK}" +MC_COMMENT_DIR="${MC_COMMENT_DIR:-$WORK}" FAILURES="${WORK}/ci_failures" -mkdir -p "$CI_COMMENT_DIR" +mkdir -p "$MC_COMMENT_DIR" # Keep raptor's Trivy scan out of terraform-init'd dependency trees (issue #13). export TRIVY_SKIP_DIRS="${TRIVY_SKIP_DIRS:-**/.terraform,**/.terraform/**}" @@ -51,12 +51,12 @@ meta() { | sed -E "s/[[:space:]]*#.*$//; s/^[\"']//; s/[\"'][[:space:]]*$//; s/[[:space:]]*$//" } -# Changed files for this event, via the host-formed CI_DIFF_RANGE. Empty range +# Changed files for this event, via the host-formed MC_DIFF_RANGE. Empty range # (new branch / unreachable base) falls back to the pushed commit's own tree so # git diff never errors and silently skips every module. changed_files() { - if [ -n "${CI_DIFF_RANGE:-}" ]; then - git diff --name-only "${CI_DIFF_RANGE}" + if [ -n "${MC_DIFF_RANGE:-}" ]; then + git diff --name-only "${MC_DIFF_RANGE}" else git diff-tree --no-commit-id --name-only -r HEAD fi @@ -89,9 +89,12 @@ prefix() { install_tools() { # Install only what's missing — a fresh CI runner installs all three; a dev - # machine that already has them is a no-op. Modes that don't run raptor's - # module validation (cleanup/noop) don't need terraform/trivy. - local p; p="$(prefix)" + # machine (or a pre-provisioned runner) that already has them is a no-op. Modes + # that don't run raptor's module validation (cleanup/noop) don't need tf/trivy. + # GitHub runners are non-root with sudo; GitLab containers are usually root + # without sudo — so only use sudo when not root and it exists. + local sudo="" + [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1 && sudo="sudo" if ! command -v raptor >/dev/null 2>&1; then local url if [ -n "$RAPTOR_DOWNLOAD_URL" ]; then url="$RAPTOR_DOWNLOAD_URL" @@ -101,20 +104,23 @@ install_tools() { url="https://github.com/Facets-cloud/raptor-releases/releases/download/${RAPTOR_VERSION}/raptor-linux-amd64" fi log "Installing raptor from ${url}" - curl -fsSL -o /tmp/raptor "$url" && chmod +x /tmp/raptor && sudo mv /tmp/raptor /usr/local/bin/raptor + # shellcheck disable=SC2086 # $sudo is a deliberate empty-or-"sudo" word + curl -fsSL -o /tmp/raptor "$url" && chmod +x /tmp/raptor && $sudo mv /tmp/raptor /usr/local/bin/raptor else log "raptor already present: $(command -v raptor)" fi if [ "$MODE" = "preview" ] || [ "$MODE" = "publish" ]; then if ! command -v terraform >/dev/null 2>&1; then log "Installing Terraform ${TERRAFORM_VERSION}..." + # shellcheck disable=SC2086 # $sudo is a deliberate empty-or-"sudo" word curl -fsSL -o /tmp/tf.zip "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" \ - && (cd /tmp && unzip -o tf.zip terraform) && sudo mv /tmp/terraform /usr/local/bin/terraform + && (cd /tmp && unzip -o tf.zip terraform) && $sudo mv /tmp/terraform /usr/local/bin/terraform fi if ! command -v trivy >/dev/null 2>&1; then log "Installing Trivy ${TRIVY_VERSION}..." + # shellcheck disable=SC2086 # $sudo is a deliberate empty-or-"sudo" word curl -fsSL -o /tmp/trivy.tgz "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \ - && (cd /tmp && tar -xzf trivy.tgz trivy) && sudo mv /tmp/trivy /usr/local/bin/trivy + && (cd /tmp && tar -xzf trivy.tgz trivy) && $sudo mv /tmp/trivy /usr/local/bin/trivy fi fi } @@ -162,7 +168,7 @@ detect_output_files() { # --- output types (applied BEFORE modules; a module refs its type by name) ---- apply_output_types() { - local files="$1" section="${CI_COMMENT_DIR}/preview_outputs.md" + local files="$1" section="${MC_COMMENT_DIR}/preview_outputs.md" [ -n "$files" ] || return 0 local mode_flag="" [ "$MODE" = "preview" ] && mode_flag="--if-absent" @@ -189,7 +195,7 @@ apply_output_types() { auto_create_flag() { [ "${AUTO_CREATE_INTENTS:-true}" = "true" ] && printf '%s' "--auto-create"; } preview_modules() { - local dirs="$1" comment="${CI_COMMENT_DIR}/preview_modules.md" ac; ac="$(auto_create_flag)" + local dirs="$1" comment="${MC_COMMENT_DIR}/preview_modules.md" ac; ac="$(auto_create_flag)" [ -n "$dirs" ] || return 0 { echo "### Facets module preview"; echo; echo "This branch now owns the single **preview slot** for each module below (concurrent MRs on the same module: last push wins)."; echo; echo "| Module | Type | Flavor | Version | Result |"; echo "|---|---|---|---|---|"; } > "$comment" local dir type flavor version ref result ok @@ -199,7 +205,7 @@ preview_modules() { if [ -z "$type" ] || [ -z "$flavor" ] || [ -z "$version" ]; then log "::error::Could not read intent/flavor/version from ${dir}/facets.yaml"; fail "${dir} (metadata)"; result="bad facets.yaml"; ok=0; fi if [ "$ok" = 1 ] && ! raptor create iac-module -f "$dir" --dry-run; then log "::error::Validation failed for ${ref}"; fail "${ref} (validate)"; result="validation failed"; ok=0; fi # shellcheck disable=SC2086 # $ac is a deliberate empty-or-one-flag word - if [ "$ok" = 1 ] && ! raptor create iac-module -f "$dir" $ac --feature-branch --git-ref "${CI_HEAD_SHA:-}"; then log "::error::Preview registration failed for ${ref}"; fail "${ref} (preview)"; result="preview failed"; ok=0; fi + if [ "$ok" = 1 ] && ! raptor create iac-module -f "$dir" $ac --feature-branch --git-ref "${MC_HEAD_SHA:-}"; then log "::error::Preview registration failed for ${ref}"; fail "${ref} (preview)"; result="preview failed"; ok=0; fi echo "| \`${dir}\` | ${type:-?} | ${flavor:-?} | ${version:-?} | ${result} |" >> "$comment" log "::endgroup::" done @@ -236,7 +242,7 @@ publish_modules() { cleanup_previews() { local dirs="$1" [ -n "$dirs" ] || return 0 - if [ -z "${CI_OWNER_SHAS:-}" ]; then log "::warning::No ownership SHAs provided; preview ownership can't be verified. Skipping cleanup."; return 0; fi + if [ -z "${MC_OWNER_SHAS:-}" ]; then log "::warning::No ownership SHAs provided; preview ownership can't be verified. Skipping cleanup."; return 0; fi local modules_json list_ok if modules_json="$(raptor get iac-module -o json 2>/dev/null)"; then list_ok=1; else list_ok=0; modules_json='[]'; log "::warning::Could not list modules; cleanup will skip every module."; fi _owner_sha() { printf '%s' "$modules_json" | jq -r --arg t "$1" --arg f "$2" --arg v "$3" '(if type=="array" then . else [] end) | map(select(.intent==$t and .flavor==$f and .version==$v)) as $rows | ($rows | map(select(.stage=="PREVIEW"))[0]) as $p | ($rows | map(select(.stage=="PUBLISHED" and ((.previewModuleId // "") != "")))[0]) as $pub | if $p then ($p.gitRef // "") elif $pub then ($pub.previewGitRef // "") else "" end' 2>/dev/null || true; } @@ -252,7 +258,7 @@ cleanup_previews() { if [ "$list_ok" = 1 ] && [ "$(_rows "$type" "$flavor" "$version")" != "0" ]; then log "::warning::${ref} is in the registry but no owning commit resolved; skipping."; else log "No preview slot recorded for ${ref}; nothing to clean up."; fi log "::endgroup::"; continue fi - owned="false"; while IFS= read -r sha; do [ -z "$sha" ] && continue; [ "$sha" = "$owner" ] && { owned="true"; break; }; done <<< "${CI_OWNER_SHAS}" + owned="false"; while IFS= read -r sha; do [ -z "$sha" ] && continue; [ "$sha" = "$owner" ] && { owned="true"; break; }; done <<< "${MC_OWNER_SHAS}" if [ "$owned" != "true" ]; then log "Preview for ${ref} is owned by another branch (commit=${owner}); leaving it."; log "::endgroup::"; continue; fi fresh="$(_owner_sha_fresh "$type" "$flavor" "$version")" if [ "$fresh" != "$owner" ]; then log "::warning::Preview slot for ${ref} changed between check and delete (was ${owner}, now ${fresh:-none}); skipping."; log "::endgroup::"; continue; fi @@ -291,5 +297,5 @@ case "$MODE" in *) log "::error::Unknown MODE '${MODE}'"; exit 1 ;; esac -log "Comment markdown in: ${CI_COMMENT_DIR}" +log "Comment markdown in: ${MC_COMMENT_DIR}" report_gate diff --git a/module-ci-gitlab/README.md b/module-ci-gitlab/README.md new file mode 100644 index 0000000..e051755 --- /dev/null +++ b/module-ci-gitlab/README.md @@ -0,0 +1,50 @@ +# Facets Module CI — GitLab CI template + +The GitLab counterpart of [`module-ci-action`](../module-ci-action/README.md). It runs +the **same** shared [`module-ci-core.sh`](../module-ci-core.sh) the GitHub action runs, so +the two hosts never drift — this template is only the thin GitLab glue (resolve mode, map +GitLab's CI variables to the generic `MC_*` contract, fetch + run the core, post the +merge-request preview note). + +Consumed as a remote include, pinned to a tag (the moving `v1` alias tracks the latest +`v1.x.y`, same convention as the actions): + +```yaml +include: + - remote: "https://raw.githubusercontent.com/Facets-cloud/github-actions/v1/module-ci-gitlab/template.gitlab-ci.yml" +``` + +The control plane wires this into a bootstrapped GitLab modules repo, the same way it wires +`module-ci-action@v1` into a GitHub one. + +## Modes + +- **Preview (on merge request)**: creates each changed output type the control plane does + not have yet, validates each changed module, and registers an unpublishable feature-branch + preview pinned to the source commit. Posts a merge-request note when `FACETS_MR_TOKEN` is set. +- **Publish (on default-branch push)**: applies each changed output type, then uploads and + publishes each changed module (PREVIEW → PUBLISHED), declaring compatible-update vs + version-upgrade from what the registry already holds. + +Output types are always applied **before** any module — a module names its output type by +reference, and the control plane refuses a module whose type it does not already hold. + +## Variables + +| Variable | Required | Meaning | +|---|---|---| +| `CONTROL_PLANE_URL` | yes | Facets Control Plane URL | +| `FACETS_USERNAME` | yes | Facets username | +| `FACETS_TOKEN` | yes | Facets API token | +| `FACETS_PATH_PREFIX` | no | sub-path to `modules/` (default `""`: repo root) | +| `FACETS_MODULE_CI_REF` | no | `github-actions` ref the core is fetched from (default `v1`) | +| `FACETS_MR_TOKEN` | no | a token that can write MR notes; enables the preview comment | + +The control-plane bootstrap provisions the three required variables as masked CI/CD variables. + +## Not yet supported + +**Cleanup-on-close.** GitLab runs no pipeline when a merge request is closed, so deleting a +closed MR's preview needs a separate trigger (a webhook or a scheduled sweep). The core +already implements `cleanup` (ownership-checked); only the GitLab trigger is missing. Tracked +as a follow-up. diff --git a/module-ci-gitlab/template.gitlab-ci.yml b/module-ci-gitlab/template.gitlab-ci.yml new file mode 100644 index 0000000..2361ef3 --- /dev/null +++ b/module-ci-gitlab/template.gitlab-ci.yml @@ -0,0 +1,101 @@ +# Facets Module CI — GitLab CI template. +# +# The GitLab counterpart of module-ci-action. A bootstrapped modules repo includes +# this (pinned to a tag), and it runs the SAME shared module-ci-core.sh the GitHub +# action runs — so the two hosts never drift. This file is only the thin GitLab +# glue: resolve mode + map GitLab's CI variables to the generic MC_* contract, +# fetch + run the core, and post the preview as a merge-request note. +# +# Consume it from a modules repo's .gitlab-ci.yml: +# +# include: +# - remote: "https://raw.githubusercontent.com/Facets-cloud/github-actions/v1/module-ci-gitlab/template.gitlab-ci.yml" +# +# Required CI/CD variables (set by the control-plane bootstrap, like the GitHub secrets): +# CONTROL_PLANE_URL, FACETS_USERNAME, FACETS_TOKEN +# Optional: +# FACETS_PATH_PREFIX sub-path to modules/ (default "": modules/ at repo root) +# FACETS_MODULE_CI_REF github-actions ref to fetch the core from (default "v1") +# FACETS_MR_TOKEN a token that can write MR notes; when set, the preview +# is posted as an MR comment (mirrors the GitHub github_token) +# +# NOTE (v1 scope): preview (on merge request) and publish (on default-branch push) +# are supported. Cleanup-on-close is not — GitLab runs no pipeline when an MR is +# closed, so preview cleanup needs a separate trigger (a webhook or a scheduled +# sweep); tracked as a follow-up. + +variables: + FACETS_PATH_PREFIX: "" + FACETS_MODULE_CI_REF: "v1" + GIT_DEPTH: "0" # full history: the core diffs base..head and raptor reads git provenance + +facets-module-ci: + stage: test + image: ubuntu:22.04 + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: + paths: + - "${FACETS_PATH_PREFIX}modules/**/*" + - "${FACETS_PATH_PREFIX}outputs/**/*" + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push"' + changes: + paths: + - "${FACETS_PATH_PREFIX}modules/**/*" + - "${FACETS_PATH_PREFIX}outputs/**/*" + before_script: + - apt-get update -qq && apt-get install -y -qq git curl jq unzip ca-certificates >/dev/null + script: + - | + set -euo pipefail + # Fetch the shared core, pinned to the same ref this template was included at. + curl -fsSL "https://raw.githubusercontent.com/Facets-cloud/github-actions/${FACETS_MODULE_CI_REF}/module-ci-core.sh" -o /tmp/module-ci-core.sh + + # Map GitLab's predefined variables to the generic MC_* contract. + if [ "${CI_PIPELINE_SOURCE}" = "merge_request_event" ]; then + export MODE="preview" + # CI_MERGE_REQUEST_DIFF_BASE_SHA is already the merge base, so two-dot. + export MC_DIFF_RANGE="${CI_MERGE_REQUEST_DIFF_BASE_SHA}..${CI_COMMIT_SHA}" + export MC_HEAD_SHA="${CI_COMMIT_SHA}" + else + export MODE="publish" + if [ -n "${CI_COMMIT_BEFORE_SHA:-}" ] && [ "${CI_COMMIT_BEFORE_SHA}" != "0000000000000000000000000000000000000000" ] \ + && git rev-parse --verify --quiet "${CI_COMMIT_BEFORE_SHA}^{commit}" >/dev/null 2>&1; then + export MC_DIFF_RANGE="${CI_COMMIT_BEFORE_SHA}..${CI_COMMIT_SHA}" + else + export MC_DIFF_RANGE="" + fi + fi + export PATH_PREFIX="${FACETS_PATH_PREFIX}" + export MC_COMMENT_DIR="${CI_PROJECT_DIR}/.facets-mci" + mkdir -p "${MC_COMMENT_DIR}" + # raptor auth: CONTROL_PLANE_URL / FACETS_USERNAME / FACETS_TOKEN come from CI/CD variables. + bash /tmp/module-ci-core.sh + after_script: + # after_script runs on success AND failure, so the preview comment posts even + # when the core gated on a bad module (mirrors the GitHub comment's if: always()). + - | + set +e + [ "${CI_PIPELINE_SOURCE}" = "merge_request_event" ] || exit 0 + [ -n "${FACETS_MR_TOKEN:-}" ] || { echo "FACETS_MR_TOKEN not set; skipping MR preview comment."; exit 0; } + DIR="${CI_PROJECT_DIR}/.facets-mci" + BODY_FILE="$(mktemp)" + echo "" > "$BODY_FILE" + [ -f "${DIR}/preview_outputs.md" ] && { cat "${DIR}/preview_outputs.md" >> "$BODY_FILE"; echo "" >> "$BODY_FILE"; } + [ -f "${DIR}/preview_modules.md" ] && cat "${DIR}/preview_modules.md" >> "$BODY_FILE" + if [ ! -f "${DIR}/preview_outputs.md" ] && [ ! -f "${DIR}/preview_modules.md" ]; then + echo "No preview comment body; nothing to post."; exit 0 + fi + API="${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/notes" + # Upsert: find an existing bot note carrying our marker, else create one. + EXISTING="$(curl -fsS --header "PRIVATE-TOKEN: ${FACETS_MR_TOKEN}" "${API}?per_page=100" \ + | jq -r '.[] | select(.body | contains("facets-module-ci:preview")) | .id' | head -n1)" + if [ -n "${EXISTING}" ]; then + curl -fsS --request PUT --header "PRIVATE-TOKEN: ${FACETS_MR_TOKEN}" \ + --data-urlencode "body@${BODY_FILE}" "${API}/${EXISTING}" >/dev/null \ + && echo "Updated MR preview note ${EXISTING}." + else + curl -fsS --request POST --header "PRIVATE-TOKEN: ${FACETS_MR_TOKEN}" \ + --data-urlencode "body@${BODY_FILE}" "${API}" >/dev/null \ + && echo "Posted MR preview note." + fi