// Shared A-Team recovery build pipeline. // // One Jenkins job per build_manifests branch, named after the branch // (ofrp-12.1, twrp-12.1, ...), using "Pipeline script from SCM" on this file // (branch main). Base settings come from build.toml on the job's branch; the // device comes from .xml on that branch. // // Jobs named cms- build the same base for the cms test portal // (test bucket, test credentials, ateam-cms.gotadell.com). def JOB = env.JOB_BASE_NAME def IS_TEST_JOB = JOB.startsWith('cms-') def BASE_BRANCH = IS_TEST_JOB ? JOB.substring(4) : JOB if (!(BASE_BRANCH ==~ /[a-z0-9][a-z0-9._-]*/)) { error "Job name '${JOB}' must be a build_manifests branch name (optionally prefixed with cms-)" } def TARGET = IS_TEST_JOB ? [ bucket : 'ateam-cms-staging', s3Credentials : 'garage-s3-cms', portalCredentials : 'portal-ingest-token-cms', portalUrl : 'https://ateam-cms.gotadell.com/api/jenkins/build-complete/', releaseRoot : '/storage/jenkins-artifacts/staging-cms', ] : [ bucket : 'ateam-staging', s3Credentials : 'garage-s3', portalCredentials : 'portal-ingest-token', portalUrl : 'https://ateam.gotadell.com/api/jenkins/build-complete/', releaseRoot : '/storage/jenkins-artifacts/staging', ] pipeline { agent { label 'buildBox' } options { // The repo is checked out inside Preflight, once the per-build TMPDIR exists skipDefaultCheckout() timestamps() disableConcurrentBuilds() // A base's live and cms- jobs share one ZFS dataset; never roll it back under each other. lock(resource: "warm-base:${BASE_BRANCH}") timeout(time: 6, unit: 'HOURS') buildDiscarder(logRotator(numToKeepStr: '20')) } parameters { // Device list = every .xml on this job's build_manifests branch (Active Choices plugin) activeChoice( name: 'DEVICE', description: 'Device codename. Builds from .xml on the manifest branch.', choiceType: 'PT_SINGLE_SELECT', filterable: false, script: groovyScript( script: [ classpath: [], sandbox: false, script: """ import groovy.json.JsonSlurper def api = 'https://ateam.gotadell.com/git/api/v1/repos/A-Team_Digital_Solutions/build_manifests/contents?ref=${BASE_BRANCH}' def conn = new URL(api).openConnection() conn.connectTimeout = 5000 conn.readTimeout = 5000 return new JsonSlurper().parse(conn.inputStream) .findAll { it.type == 'file' && it.name.endsWith('.xml') } .collect { it.name[0..-5] } .sort() """ ], fallbackScript: [ classpath: [], sandbox: false, script: 'return ["ERROR-could-not-list-manifests"]' ] ) ) text( name: 'CHANGELOG', defaultValue: '', description: 'Testing notes displayed in the A-Team Portal (empty = changelog from build.toml).' ) string( name: 'PORTAL_REQUEST_ID', defaultValue: '', description: 'Set by the portal "Start build" button. Empty for builds started here (owner-only approval).' ) string( name: 'PORTAL_USER', defaultValue: '', description: 'Portal user who started the build (informational; set by the portal).' ) string( name: 'MANIFEST_REF', defaultValue: '', description: 'cms- test jobs only: build_manifests ref to read build.toml and the device XML from (default: the job branch).' ) } environment { DEVICE = "${params.DEVICE}" MANIFEST_URL = 'ssh://git@ateam.gotadell.com/A-Team_Digital_Solutions/build_manifests.git' MANIFEST_BRANCH = "${BASE_BRANCH}" MANIFEST_REF = "${params.MANIFEST_REF ?: BASE_BRANCH}" MANIFEST_FILE = "${params.DEVICE}.xml" TEST_JOB = "${IS_TEST_JOB ? '1' : ''}" TMPDIR = "/build/tmp/jenkins/${JOB}-${params.DEVICE}-${BUILD_NUMBER}" BUILD_CONFIG = "/build/tmp/jenkins/${JOB}-${params.DEVICE}-${BUILD_NUMBER}/build-config.env" RELEASE_ROOT = "${TARGET.releaseRoot}" // Match the env the warm out/ was built with, or soong regenerates everything // (OUT_DIR comes from build.toml: /out) CC_WRAPPER = '/usr/bin/ccache' CCACHE_EXEC = '/usr/bin/ccache' CCACHE_DIR = '/build/ccache' USE_CCACHE = '1' ALLOW_MISSING_DEPENDENCIES = 'true' S3_ENDPOINT_URL = 'http://10.0.55.47:9000' S3_REGION = 'garage' S3_BUCKET = "${TARGET.bucket}" PORTAL_URL = "${TARGET.portalUrl}" } stages { stage('Preflight') { steps { sh '''#!/usr/bin/env bash set -Eeuo pipefail [[ "$DEVICE" =~ ^[A-Za-z0-9_]+$ ]] || { echo "ERROR: bad DEVICE '$DEVICE'"; exit 1; } [[ -z "$PORTAL_REQUEST_ID" || "$PORTAL_REQUEST_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] \ || { echo "ERROR: bad PORTAL_REQUEST_ID"; exit 1; } if [ -z "$TEST_JOB" ] && [ "$MANIFEST_REF" != "$MANIFEST_BRANCH" ]; then echo "ERROR: MANIFEST_REF is only allowed on cms- test jobs" exit 1 fi [[ "$MANIFEST_REF" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "ERROR: bad MANIFEST_REF"; exit 1; } echo "===== REQUIRED TOOLS =====" for TOOL in \ git \ repo \ python3 \ zfs \ ccache \ aws \ jq \ curl \ md5sum \ sha256sum \ unzip do command -v "$TOOL" done echo echo "===== VERIFY CLEAN PER-BUILD PATHS =====" test ! -e "$TMPDIR" install -d -m 2775 "$TMPDIR" ''' // Jenkinsfile + pipeline/build_config.py from build_manifests main checkout scm sh '''#!/usr/bin/env bash set -Eeuo pipefail echo "===== FETCH BUILD MANIFEST =====" MANIFEST_CHECKOUT="$TMPDIR/build_manifests" git clone \ --depth 1 \ --branch "$MANIFEST_REF" \ --single-branch \ "$MANIFEST_URL" \ "$MANIFEST_CHECKOUT" test -s "$MANIFEST_CHECKOUT/$MANIFEST_FILE" \ || { echo "ERROR: $MANIFEST_FILE not found on $MANIFEST_REF"; exit 1; } echo echo "===== RESOLVE build.toml FOR $DEVICE =====" python3 "$WORKSPACE/pipeline/build_config.py" \ "$MANIFEST_CHECKOUT/build.toml" \ "$DEVICE" \ > "$BUILD_CONFIG" source "$BUILD_CONFIG" { printf 'RELEASE_DIR=%q\\n' "$RELEASE_ROOT/$PROJECT/$DEVICE/$PROJECT-$DEVICE-$BUILD_NUMBER" printf 'STAGING_PREFIX=%q\\n' "$PROJECT/$DEVICE/build-$BUILD_NUMBER/" } >> "$BUILD_CONFIG" cat "$BUILD_CONFIG" source "$BUILD_CONFIG" echo echo "===== BUILD IDENTIFICATION =====" echo "Target: $([ -n "$TEST_JOB" ] && echo "cms test portal" || echo "live portal")" echo "Project: $PROJECT_NAME $VERSION" echo "Device: $DEVICE" echo "Manifest: $MANIFEST_URL ($MANIFEST_REF/$MANIFEST_FILE)" echo "Warm base: $WARM_SNAPSHOT" echo "Lunch: $LUNCH_TARGET" echo "Portal prefix: $STAGING_PREFIX" echo "Portal request: ${PORTAL_REQUEST_ID:-none} ${PORTAL_USER:+(by $PORTAL_USER)}" echo echo "===== VERIFY WARM SNAPSHOT =====" zfs list -H -t snapshot -o name,creation "$WARM_SNAPSHOT" zfs allow "$ZFS_DATASET" MOUNTPOINT="$(zfs get -H -o value mountpoint "$ZFS_DATASET")" test "$MOUNTPOINT" = "$SOURCE_ROOT" \ || { echo "ERROR: $ZFS_DATASET is mounted at $MOUNTPOINT, build.toml says $SOURCE_ROOT"; exit 1; } echo echo "===== PREPARE RELEASE DIRECTORY =====" test ! -e "$RELEASE_DIR" install -d -m 2775 "$RELEASE_DIR" echo "Temporary: $TMPDIR" echo "Release staging: $RELEASE_DIR" ''' } } stage('Restore Warm Base') { steps { sh '''#!/usr/bin/env bash set -Eeuo pipefail source "$BUILD_CONFIG" echo "===== ROLLBACK TO WARM =====" zfs rollback "$WARM_SNAPSHOT" echo echo "===== VERIFY WARM BASE =====" cd "$SOURCE_ROOT" test -d .repo test -d out for P in $WARM_CHECK; do test -e "$P" || { echo "ERROR: $P missing from warm base"; exit 1; } done if compgen -G "device/*/$DEVICE" >/dev/null; then echo "ERROR: device tree for $DEVICE already present in warm base" exit 1 fi test -z "$(ls -A out/target/product 2>/dev/null)" test -z "$(ls -A .repo/local_manifests 2>/dev/null)" echo echo "===== VERIFY JENKINS CAN WRITE THE TREE =====" test -w "$SOURCE_ROOT" test -w "$SOURCE_ROOT/out" test -w "$SOURCE_ROOT/.repo" echo "Source: $SOURCE_ROOT" ''' } } stage('Sync Device Trees From Manifest') { steps { sh '''#!/usr/bin/env bash set -Eeuo pipefail source "$BUILD_CONFIG" MANIFEST_CHECKOUT="$TMPDIR/build_manifests" MANIFEST_COMMIT="$(git -C "$MANIFEST_CHECKOUT" rev-parse HEAD)" printf '%s\\n' "$MANIFEST_COMMIT" > "$RELEASE_DIR/manifest-commit.txt" echo "Manifest commit: $MANIFEST_COMMIT" cd "$SOURCE_ROOT" mkdir -p .repo/local_manifests install -m 0644 \ "$MANIFEST_CHECKOUT/$MANIFEST_FILE" \ ".repo/local_manifests/$MANIFEST_FILE" echo echo "===== RESOLVE BRANCH NAMES (case-insensitive) =====" python3 - ".repo/local_manifests/$MANIFEST_FILE" <<'PYEOF' import sys, subprocess import xml.etree.ElementTree as ET path = sys.argv[1] tree = ET.parse(path) root = tree.getroot() remotes = {r.get("name"): r.get("fetch", "") for r in root.iter("remote")} default = root.find("default") dflt = lambda k: default.get(k) if default is not None else None changed = False for p in root.iter("project"): name = p.get("name") rev = p.get("revision") or dflt("revision") # leave tags, full refs and commit hashes alone if not rev or rev.startswith("refs/") or len(rev) == 40: continue fetch = remotes.get(p.get("remote") or dflt("remote"), "") if "://" not in fetch and "@" not in fetch: continue url = fetch + ("" if fetch.endswith(("/", ":")) else "/") + name out = subprocess.run(["git", "ls-remote", "--heads", url], capture_output=True, text=True, check=True).stdout heads = [l.split("refs/heads/", 1)[1] for l in out.splitlines() if "refs/heads/" in l] if rev in heads: print(f"{name}: {rev}") continue match = [h for h in heads if h.lower() == rev.lower()] if len(match) != 1: sys.exit(f"ERROR: {name}: no branch '{rev}'. Branches: {', '.join(heads) or 'none'}") print(f"{name}: {match[0]} (manifest says {rev})") p.set("revision", match[0]) changed = True if changed: tree.write(path, xml_declaration=True, encoding="UTF-8") PYEOF echo echo "===== PROJECTS IN $MANIFEST_FILE =====" mapfile -t PATHS < <( python3 - ".repo/local_manifests/$MANIFEST_FILE" <<'PYEOF' import sys import xml.etree.ElementTree as ET for p in ET.parse(sys.argv[1]).getroot().iter("project"): print(p.get("path") or p.get("name")) PYEOF ) test "${#PATHS[@]}" -ge 1 printf ' %s\\n' "${PATHS[@]}" echo echo "===== DEVICE INFO FROM $MANIFEST_FILE =====" python3 - ".repo/local_manifests/$MANIFEST_FILE" > "$RELEASE_DIR/device-info.env" <<'PYEOF' import sys, shlex import xml.etree.ElementTree as ET root = ET.parse(sys.argv[1]).getroot() remotes = {r.get("name"): r.get("fetch", "") for r in root.iter("remote")} default = root.find("default") dflt = lambda k: default.get(k, "") if default is not None else "" out = lambda k, v: print(f"{k}={shlex.quote(v or '')}") # The device tree is the project carrying the ateam.* annotations trees = [p for p in root.iter("project") if any(a.get("name", "").startswith("ateam.") for a in p.iter("annotation"))] if len(trees) != 1: sys.exit(f"ERROR: expected ateam.* annotations on exactly one project, found {len(trees)}") p = trees[0] for a in p.iter("annotation"): n = a.get("name", "") if n.startswith("ateam."): out(n[6:].upper(), a.get("value")) name = p.get("name") rev = p.get("revision") or dflt("revision") fetch = remotes.get(p.get("remote") or dflt("remote"), "") host = fetch.split("@")[-1].split("://")[-1].strip("/").split("/")[0].split(":")[0] if host == "ateam.gotadell.com": url = f"https://ateam.gotadell.com/git/{name}/src/branch/{rev}" elif host == "github.com": url = f"https://github.com/{name}/tree/{rev}" else: url = f"{fetch.rstrip('/')}/{name}" out("TREE_PATH", p.get("path") or name) out("TREE_URL", url) PYEOF cat "$RELEASE_DIR/device-info.env" source "$RELEASE_DIR/device-info.env" for VAR in MODEL DEVICE_NAME MANUFACTURER PARTITION TREE_PATH TREE_URL; do test -n "${!VAR:-}" || { echo "ERROR: $VAR missing (check ateam.* annotations in $MANIFEST_FILE)"; exit 1; } done case "$PARTITION" in recovery|boot|vendor_boot) ;; *) echo "ERROR: unknown ateam.partition '$PARTITION'"; exit 1 ;; esac echo echo "===== SYNC ONLY MANIFEST PROJECTS =====" repo sync \ -c \ --no-tags \ --no-clone-bundle \ --force-sync \ -j8 \ "${PATHS[@]}" test -d "$TREE_PATH/.git" || test -e "$TREE_PATH/.git" echo echo "===== RECORD SYNCED COMMITS =====" for P in "${PATHS[@]}"; do printf '%s %s\\n' "$P" "$(git -C "$P" rev-parse HEAD)" done | tee "$RELEASE_DIR/synced-projects.txt" TREE_COMMIT="$(git -C "$TREE_PATH" rev-parse HEAD)" TREE_SUBJECT="$(git -C "$TREE_PATH" log -1 --pretty=%s)" printf '%s\\n' "$TREE_COMMIT" > "$RELEASE_DIR/device-tree-commit.txt" printf '%s\\n' "$TREE_SUBJECT" > "$RELEASE_DIR/device-tree-subject.txt" echo echo "Tree commit: $TREE_COMMIT" echo "Tree subject: $TREE_SUBJECT" ''' } } stage('Configure Ccache') { steps { sh '''#!/usr/bin/env bash set -Eeuo pipefail mkdir -p "$CCACHE_DIR" test -d "$CCACHE_DIR" test -w "$CCACHE_DIR" ccache -M 100G ccache -o compression=true ccache -z ccache -s ''' } } stage('Lunch') { steps { sh '''#!/usr/bin/env bash set -o pipefail source "$BUILD_CONFIG" || exit 1 cd "$SOURCE_ROOT" || exit 1 export OUT_DIR export CC_WRAPPER export CCACHE_EXEC export TMPDIR export TMP="$TMPDIR" export TEMP="$TMPDIR" export CCACHE_DIR export USE_CCACHE export ALLOW_MISSING_DEPENDENCIES for VAR in $BUILD_ENV_NAMES; do export "$VAR"; done source build/envsetup.sh type lunch >/dev/null 2>&1 || exit 1 type mka >/dev/null 2>&1 || exit 1 lunch "$LUNCH_TARGET" || exit 1 echo echo "===== BUILD VARIABLES =====" echo -n "TARGET_PRODUCT: " get_build_var TARGET_PRODUCT || exit 1 echo -n "TARGET_DEVICE: " get_build_var TARGET_DEVICE || exit 1 echo -n "PRODUCT_OUT: " get_build_var PRODUCT_OUT || exit 1 ''' } } stage('Install Clean and Build') { steps { sh '''#!/usr/bin/env bash set -o pipefail source "$BUILD_CONFIG" || exit 1 cd "$SOURCE_ROOT" || exit 1 export OUT_DIR export CC_WRAPPER export CCACHE_EXEC export TMPDIR export TMP="$TMPDIR" export TEMP="$TMPDIR" export CCACHE_DIR export USE_CCACHE export ALLOW_MISSING_DEPENDENCIES for VAR in $BUILD_ENV_NAMES; do export "$VAR"; done source build/envsetup.sh lunch "$LUNCH_TARGET" || exit 1 echo "===== INSTALL CLEAN =====" m installclean INSTALLCLEAN_STATUS=$? if [ "$INSTALLCLEAN_STATUS" -ne 0 ]; then echo "ERROR: installclean failed with status $INSTALLCLEAN_STATUS" exit "$INSTALLCLEAN_STATUS" fi source "$RELEASE_DIR/device-info.env" || exit 1 case "$PARTITION" in recovery) BUILD_TARGET=recoveryimage ;; boot) BUILD_TARGET=bootimage ;; vendor_boot) BUILD_TARGET=vendorbootimage ;; *) echo "ERROR: unknown partition '$PARTITION'"; exit 1 ;; esac echo echo "===== BUILD $PROJECT_NAME ($MAKE_EXTRA $BUILD_TARGET) =====" START_TIME="$(date +%s)" # MAKE_EXTRA is validated by build_config.py (plain words only) mka $MAKE_EXTRA "$BUILD_TARGET" BUILD_STATUS=$? END_TIME="$(date +%s)" ELAPSED=$((END_TIME - START_TIME)) if [ "$BUILD_STATUS" -ne 0 ]; then echo "ERROR: $PROJECT_NAME build failed with status $BUILD_STATUS" exit "$BUILD_STATUS" fi PRODUCT_OUT="$(get_build_var PRODUCT_OUT)" || exit 1 echo echo "===== BUILD TIME =====" printf "$PROJECT_NAME $DEVICE build: %d:%02d (mm:ss)\\n" \ "$((ELAPSED / 60))" \ "$((ELAPSED % 60))" echo echo "===== PRODUCT OUTPUT =====" cd "$PRODUCT_OUT" || exit 1 for EXT in $OUTPUT_EXTS; do GLOB_VAR="OUTPUT_${EXT^^}_GLOB" find . -maxdepth 1 -type f \\( -name "${!GLOB_VAR}" -o -name "${!GLOB_VAR}.*" \\) \ -printf '%f %s bytes\\n' done | sort ''' } } stage('Collect and Rename Artifacts') { steps { sh '''#!/usr/bin/env bash set -Eeuo pipefail source "$BUILD_CONFIG" source "$RELEASE_DIR/device-info.env" PRODUCT_OUT="$OUT_DIR/target/product/$DEVICE" cd "$PRODUCT_OUT" BASE="${RELEASE_TAG}-${RELEASE_VER}_A-Team_${DEVICE}_${MODEL}-$(TZ=America/New_York date +%y%m%d)" : > "$RELEASE_DIR/deliverables.txt" for EXT in $OUTPUT_EXTS; do GLOB_VAR="OUTPUT_${EXT^^}_GLOB" CHECKSUM_VAR="OUTPUT_${EXT^^}_CHECKSUM" mapfile -t FOUND < <(find . -maxdepth 1 -type f -name "${!GLOB_VAR}" -printf '%f\\n') if [ "${#FOUND[@]}" -ne 1 ]; then echo "ERROR: Expected exactly one .$EXT matching '${!GLOB_VAR}', found ${#FOUND[@]}." printf '%s\\n' "${FOUND[@]}" exit 1 fi FILE="${FOUND[0]}" case "${!CHECKSUM_VAR}" in md5) test -s "$FILE.md5" EXPECTED="$(awk 'NR == 1 {print $1}' "$FILE.md5")" ACTUAL="$(md5sum "$FILE" | awk '{print $1}')" test "$EXPECTED" = "$ACTUAL" echo "$FILE: native MD5 OK" ;; sha256) test -s "$FILE.sha256" EXPECTED="$(awk 'NR == 1 {print $1}' "$FILE.sha256")" ACTUAL="$(sha256sum "$FILE" | awk '{print $1}')" test "$EXPECTED" = "$ACTUAL" echo "$FILE: native SHA-256 OK" ;; none) ;; esac install -m 0644 "$FILE" "$RELEASE_DIR/$BASE.$EXT" ( cd "$RELEASE_DIR" && sha256sum "$BASE.$EXT" > "$BASE.$EXT.sha256" ) printf '%s\\n%s\\n' "$BASE.$EXT" "$BASE.$EXT.sha256" >> "$RELEASE_DIR/deliverables.txt" done echo echo "===== RENAMED TO A-TEAM SCHEME =====" ls -lh "$RELEASE_DIR" ''' } } stage('Verify Deliverables') { steps { sh '''#!/usr/bin/env bash set -Eeuo pipefail source "$BUILD_CONFIG" cd "$RELEASE_DIR" mapfile -t FILES < deliverables.txt EXPECTED_COUNT=$(( $(wc -w <<< "$OUTPUT_EXTS") * 2 )) test "${#FILES[@]}" -eq "$EXPECTED_COUNT" for FILE in "${FILES[@]}"; do test -s "$FILE" ls -lh "$FILE" done echo echo "===== VERIFY SHA-256 FILES =====" for FILE in "${FILES[@]}"; do case "$FILE" in *.sha256) sha256sum -c "$FILE" ;; esac done for EXT in $OUTPUT_EXTS; do TEST_ZIP_VAR="OUTPUT_${EXT^^}_TEST_ZIP" if [ -n "${!TEST_ZIP_VAR}" ]; then echo echo "===== VERIFY .$EXT ARCHIVE =====" for FILE in "${FILES[@]}"; do case "$FILE" in *."$EXT") unzip -t "$FILE" ;; esac done fi done echo echo "===== CREATE INTERNAL SHA-256 MANIFEST =====" sha256sum "${FILES[@]}" > artifact-manifest.sha256 test "$(wc -l < artifact-manifest.sha256)" -eq "$EXPECTED_COUNT" cat artifact-manifest.sha256 ''' } } stage('Upload to Garage') { steps { withCredentials([ usernamePassword( credentialsId: TARGET.s3Credentials, usernameVariable: 'AWS_ACCESS_KEY_ID', passwordVariable: 'AWS_SECRET_ACCESS_KEY' ) ]) { sh '''#!/usr/bin/env bash set -Eeuo pipefail set +x source "$BUILD_CONFIG" export AWS_DEFAULT_REGION="$S3_REGION" export AWS_REGION="$S3_REGION" export AWS_EC2_METADATA_DISABLED=true cd "$RELEASE_DIR" mapfile -t FILES < deliverables.txt FILES+=("artifact-manifest.sha256") test "${#FILES[@]}" -eq $(( $(wc -w <<< "$OUTPUT_EXTS") * 2 + 1 )) echo "===== UPLOAD GARAGE ARTIFACTS =====" echo "Bucket: $S3_BUCKET" echo "Prefix: $STAGING_PREFIX" for FILE in "${FILES[@]}"; do echo "Uploading: $FILE" aws \ --endpoint-url "$S3_ENDPOINT_URL" \ s3api put-object \ --bucket "$S3_BUCKET" \ --key "${STAGING_PREFIX}${FILE}" \ --body "$FILE" \ >/dev/null done echo echo "GARAGE UPLOAD COMPLETE" ''' } } } stage('Verify Garage Objects') { steps { withCredentials([ usernamePassword( credentialsId: TARGET.s3Credentials, usernameVariable: 'AWS_ACCESS_KEY_ID', passwordVariable: 'AWS_SECRET_ACCESS_KEY' ) ]) { sh '''#!/usr/bin/env bash set -Eeuo pipefail set +x source "$BUILD_CONFIG" export AWS_DEFAULT_REGION="$S3_REGION" export AWS_REGION="$S3_REGION" export AWS_EC2_METADATA_DISABLED=true cd "$RELEASE_DIR" mapfile -t FILES < deliverables.txt FILES+=("artifact-manifest.sha256") echo "===== VERIFY REMOTE OBJECT SIZES =====" for FILE in "${FILES[@]}"; do LOCAL_SIZE="$(stat -c '%s' "$FILE")" REMOTE_SIZE="$( aws \ --endpoint-url "$S3_ENDPOINT_URL" \ s3api head-object \ --bucket "$S3_BUCKET" \ --key "${STAGING_PREFIX}${FILE}" \ --query ContentLength \ --output text )" printf '%-80s local=%s remote=%s\\n' \ "$FILE" \ "$LOCAL_SIZE" \ "$REMOTE_SIZE" test "$LOCAL_SIZE" = "$REMOTE_SIZE" done REMOTE_MANIFEST="$(mktemp)" trap 'rm -f "$REMOTE_MANIFEST"' EXIT aws \ --endpoint-url "$S3_ENDPOINT_URL" \ s3api get-object \ --bucket "$S3_BUCKET" \ --key "${STAGING_PREFIX}artifact-manifest.sha256" \ "$REMOTE_MANIFEST" \ >/dev/null cmp artifact-manifest.sha256 "$REMOTE_MANIFEST" echo echo "GARAGE VERIFICATION PASSED" ''' } } } stage('Register Portal Testing Build') { steps { withCredentials([ string( credentialsId: TARGET.portalCredentials, variable: 'PORTAL_TOKEN' ) ]) { sh '''#!/usr/bin/env bash set -Eeuo pipefail set +x source "$BUILD_CONFIG" TREE_COMMIT="$(cat "$RELEASE_DIR/device-tree-commit.txt")" TREE_SUBJECT="$(cat "$RELEASE_DIR/device-tree-subject.txt")" source "$RELEASE_DIR/device-info.env" PAYLOAD_FILE="$(mktemp)" RESPONSE_FILE="$(mktemp)" cleanup_files() { rm -f "$PAYLOAD_FILE" "$RESPONSE_FILE" } trap cleanup_files EXIT jq -n \ --arg project "$PROJECT" \ --arg project_name "$PROJECT_NAME" \ --arg device "$DEVICE" \ --arg device_name "$DEVICE_NAME ($MODEL)" \ --arg manufacturer "$MANUFACTURER" \ --arg version "$VERSION" \ --argjson build_number "$BUILD_NUMBER" \ --arg staging_prefix "$STAGING_PREFIX" \ --arg source_branch "$MANIFEST_BRANCH" \ --arg jenkins_url "$BUILD_URL" \ --arg tree_url "$TREE_URL" \ --arg tree_commit "$TREE_COMMIT" \ --arg tree_subject "$TREE_SUBJECT" \ --arg operator_changelog "${CHANGELOG:-$CHANGELOG_DEFAULT}" \ --arg portal_request_id "$PORTAL_REQUEST_ID" \ '{ project: $project, project_name: $project_name, device: $device, device_name: $device_name, manufacturer: $manufacturer, version: $version, build_number: $build_number, staging_prefix: $staging_prefix, source_branch: $source_branch, jenkins_url: $jenkins_url, portal_request_id: $portal_request_id, changelog: ( $operator_changelog + "\\n\\nDevice tree: " + $tree_url + "\\nCommit: " + $tree_commit + "\\nCommit subject: " + $tree_subject ) }' \ > "$PAYLOAD_FILE" HTTP_CODE="$( curl \ -sS \ --connect-timeout 15 \ --max-time 120 \ -o "$RESPONSE_FILE" \ -w '%{http_code}' \ -H "X-Jenkins-Token: $PORTAL_TOKEN" \ -H 'Content-Type: application/json' \ --data-binary "@$PAYLOAD_FILE" \ "$PORTAL_URL" )" echo "===== PORTAL RESPONSE =====" echo "HTTP=$HTTP_CODE" jq . "$RESPONSE_FILE" \ 2>/dev/null \ || cat "$RESPONSE_FILE" case "$HTTP_CODE" in 200|201) ;; *) echo "ERROR: Portal registration failed." exit 1 ;; esac if jq -e '.error' "$RESPONSE_FILE" >/dev/null 2>&1; then echo "ERROR: Portal returned an error." exit 1 fi if [ -n "$PORTAL_REQUEST_ID" ] && ! jq -e '.started_by' "$RESPONSE_FILE" >/dev/null 2>&1; then echo "WARNING: portal did not attribute this build: $(jq -r '.attribution_warning // empty' "$RESPONSE_FILE")" fi echo echo "PORTAL TESTING REGISTRATION PASSED" ''' } } } stage('Ccache Results') { steps { sh '''#!/usr/bin/env bash set -Eeuo pipefail ccache -s ''' } } } post { success { sh '''#!/usr/bin/env bash set -Eeuo pipefail source "$BUILD_CONFIG" echo "===== SUCCESSFUL BUILD CLEANUP =====" rm -rf "$TMPDIR" echo echo "===== RETURN BASE TO WARM =====" zfs rollback "$WARM_SNAPSHOT" echo echo "Warm base restored: $WARM_SNAPSHOT" echo "Local release files retained at:" echo "$RELEASE_DIR" echo echo "Portal status: Testing" echo "Garage prefix: $STAGING_PREFIX" ''' echo "${BASE_BRANCH} ${env.DEVICE} build uploaded and registered for testing." } failure { echo "${BASE_BRANCH} ${env.DEVICE} pipeline failed. Source tree left as-is for diagnosis (next run rolls back to @warm)." sh '''#!/usr/bin/env bash set +e source "$BUILD_CONFIG" 2>/dev/null echo "===== RETAINED FAILURE PATHS =====" echo "Source + out: ${SOURCE_ROOT:-not resolved}" echo "Temporary files: $TMPDIR" echo "Release staging: ${RELEASE_DIR:-not created}" echo "Garage prefix: ${STAGING_PREFIX:-not resolved}" ''' } aborted { echo "${BASE_BRANCH} ${env.DEVICE} pipeline was aborted. Diagnostic paths were retained." } always { echo "${BASE_BRANCH} ${env.DEVICE} pipeline finished." } } }