diff --git a/.circleci/config.yml b/.circleci/config.yml index f12d5144..bb0a91fd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -128,9 +128,84 @@ jobs: # initialize the ci buildx builder make buildx-init - # create the github release + # --- Generate + per-format-sign the SBOM embedded in every package. + # Signed once with the RPM key and once with the APT/DEB key; + # goreleaser embeds the matching .asc per format (see nfpms overrides + # in .goreleaser.yaml). + if [[ -z "${GPG_FIPS_RPM_PRIVATE_KEY:-}" || -z "${GPG_FIPS_DEB_PRIVATE_KEY:-}" ]]; then + echo "GPG_FIPS_RPM_PRIVATE_KEY and GPG_FIPS_DEB_PRIVATE_KEY must be set (control-plane-release context)" + exit 1 + fi + # rpm provides rpmsign (used to sign the rpms below) + sudo apt-get update -qq && sudo apt-get install -y -qq rpm + # Install syft from the pinned release tarball and verify it against + # the published checksums file before installing (avoids piping a + # remote installer script straight into a shell). + SYFT_VERSION=1.45.1 + SYFT_TGZ="syft_${SYFT_VERSION}_linux_amd64.tar.gz" + SYFT_BASE="https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}" + syft_tmp="$(mktemp -d)" + curl -sSfL "${SYFT_BASE}/${SYFT_TGZ}" -o "${syft_tmp}/${SYFT_TGZ}" + curl -sSfL "${SYFT_BASE}/syft_${SYFT_VERSION}_checksums.txt" -o "${syft_tmp}/checksums.txt" + ( cd "${syft_tmp}" && grep " ${SYFT_TGZ}\$" checksums.txt | sha256sum -c - ) + tar -xzf "${syft_tmp}/${SYFT_TGZ}" -C "${syft_tmp}" syft + sudo install -m 0755 "${syft_tmp}/syft" /usr/local/bin/syft + rm -rf "${syft_tmp}" + syft version + # The two secrets hold the base64 of the armored private keys + # (single line — CircleCI mangles multi-line values), so decode them. + keyid() { # fingerprint of a base64 key blob, read in an isolated keyring + local d; d=$(mktemp -d) + echo "$1" | base64 -d | GNUPGHOME="$d" gpg --batch --import >/dev/null 2>&1 + GNUPGHOME="$d" gpg --batch --list-secret-keys --with-colons 2>/dev/null | awk -F: '/^sec/{print $5; exit}' + rm -rf "$d" + } + RPM_KEY_ID=$(keyid "${GPG_FIPS_RPM_PRIVATE_KEY}") + DEB_KEY_ID=$(keyid "${GPG_FIPS_DEB_PRIVATE_KEY}") + echo "${GPG_FIPS_RPM_PRIVATE_KEY}" | base64 -d | gpg --batch --import + echo "${GPG_FIPS_DEB_PRIVATE_KEY}" | base64 -d | gpg --batch --import + mkdir -p .sbom + syft dir:. -o cyclonedx-json > /tmp/cp-sbom.json + mv /tmp/cp-sbom.json .sbom/sbom.json + gpg --batch --yes --armor --detach-sign --local-user "${RPM_KEY_ID}" -o .sbom/sbom.rpm.asc .sbom/sbom.json + gpg --batch --yes --armor --detach-sign --local-user "${DEB_KEY_ID}" -o .sbom/sbom.deb.asc .sbom/sbom.json + + # pgEdge packages use the rcN/betaN convention (no dot), matching + # pgedge-parse-release-tag. goreleaser renders the tag's pre-release + # verbatim (v1.0.0-rc.1 -> 1.0.0~rc.1), so normalise it for the + # PACKAGES while keeping the GitHub release, binaries and docker + # image on the canonical (dotted) tag. + PKG_TAG=$(echo "${CIRCLE_TAG}" | sed -E 's/-(rc|beta|alpha|test|dev)\.([0-9]+)/-\1\2/') + + # Publish the release on the canonical tag WITHOUT packages (binaries, + # archives, SBOM); then build the rpm/deb with the normalised version + # (PKG_TAG == CIRCLE_TAG for GA). Packages are built --skip=publish so + # the rpms can be signed before they are attached to the release. GORELEASER_CURRENT_TAG=${CIRCLE_TAG} goreleaser release \ - --release-notes=${release_notes} + --release-notes=${release_notes} --skip=nfpm + GORELEASER_CURRENT_TAG=${PKG_TAG} goreleaser release \ + --clean --skip=publish,announce,validate,sbom + + # Sign the rpms with the RPM key (debs are signed later at the apt + # repo by reprepro, not per-file). rpm's default %__gpg is gpg2, + # which is absent on Ubuntu — point it at the real gpg. + for r in dist/*.rpm; do + rpmsign --define "__gpg $(command -v gpg)" \ + --define "_gpg_name ${RPM_KEY_ID}" --addsign "$r" + done + # Verify the rpm signatures when the public key is provided. + if [[ -n "${GPG_FIPS_RPM_PUBLIC_KEY:-}" ]]; then + echo "${GPG_FIPS_RPM_PUBLIC_KEY}" | base64 -d > /tmp/rpm-pub.asc + sudo rpm --import /tmp/rpm-pub.asc + for r in dist/*.rpm; do rpm --checksig "$r"; done + fi + + # Attach the signed rpms + debs to the (draft) release, then publish + # it. Flipping draft -> published fires the 'release: published' + # event, which triggers .github/workflows/publish.yml with all the + # signed packages already present (no race). + gh release upload "${CIRCLE_TAG}" dist/*.rpm dist/*.deb --clobber + gh release edit "${CIRCLE_TAG}" --draft=false # log into control plane ECR repo echo "${IMAGE_PUBLISH_TOKEN}" \ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..d5affc39 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,414 @@ +# ------------------------------------------------------------------------- +# +# pgEdge Control Plane — package publishing +# +# control-plane builds + signs its rpm/deb packages in CircleCI and attaches +# them to the GitHub release. This workflow fires when that release is +# published, downloads the signed packages from the release, and runs the +# pgEdge publish pipeline (same building blocks as ai-kb / ai-dba-workbench / +# anonymizer): +# determine-repo-type → push-dnf → push-apt → publish-manifest (+ Slack) +# +# It does NOT build anything. Differences from the in-repo repos: +# * packages come from the published release (gh release download), not a +# same-run build artifact; +# * no pgedge-detect-build-matrix (that needs a component dir with common.sh) +# — the per-distro lists are derived from the downloaded filenames and the +# dnf_ts/apt_ts timestamps are generated inline. +# +# RPMs are already signed in CircleCI; DEBs are signed at the apt repo here +# (reprepro, DEB key). pgedge-dnf-repo-builder only runs createrepo_c. +# +# ------------------------------------------------------------------------- + +name: Publish + +on: + release: + types: [published] + workflow_dispatch: {} # manual re-publish — run against the release tag + +permissions: + contents: read + +concurrency: + group: publish-${{ github.ref_name }} + cancel-in-progress: false + +env: + TAG: ${{ github.event.release.tag_name || github.ref_name }} + +jobs: + # ========================================================================= + # Tag parsing → (repo_type, version, buildnum). parse-release-tag reads + # GITHUB_REF_NAME (the release tag) — no simulate_tag, so simulated=false. + # ========================================================================= + determine-repo-type: + name: Determine repo routing + runs-on: ubuntu-latest + outputs: + repo-type: ${{ steps.route.outputs.repo_type }} + component-version: ${{ steps.parse.outputs.version }} + component-buildnum: ${{ steps.parse.outputs.buildnum }} + effective-tag: ${{ steps.parse.outputs.effective_tag }} + simulated: ${{ steps.parse.outputs.simulated }} + steps: + - name: Checkout pgedge-parse-release-tag + env: + TOKEN: ${{ secrets.PGEDGE_BUILDER_TOKEN }} + run: | + set -euo pipefail + mkdir -p .github/actions + # Pass the token via a transient auth header (kept out of the + # remote URL) and drop .git after clone, so the credential does + # not persist in the workspace for later steps to read. + auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')" + git -c "http.extraheader=${auth}" clone --depth 1 \ + "https://github.com/pgEdge/pgedge-parse-release-tag.git" \ + ".github/actions/pgedge-parse-release-tag" + rm -rf ".github/actions/pgedge-parse-release-tag/.git" + - name: Parse release tag + id: parse + uses: ./.github/actions/pgedge-parse-release-tag + - name: Route suffix → repo_type + id: route + env: + SUFFIX: ${{ steps.parse.outputs.suffix }} + EFFECTIVE_TAG: ${{ steps.parse.outputs.effective_tag }} + run: | + set -euo pipefail + # Routing is policy and stays per-repo. + case "$SUFFIX" in + test*|dev*) repo_type=daily ;; + rc*|beta*|alpha*|"") repo_type=staging ;; + *) + echo "::error::Unsupported suffix '$SUFFIX' (effective tag: $EFFECTIVE_TAG)" + exit 1 + ;; + esac + echo "repo_type=$repo_type" >> "$GITHUB_OUTPUT" + echo "Routed suffix '$SUFFIX' → repo_type=$repo_type" + + # ========================================================================= + # Push RPMs → dnf.pgedge.com (createrepo_c + S3 + CloudFront). RPMs are + # already signed in CircleCI; this job adds no GPG key. + # ========================================================================= + push-dnf: + name: Push DNF (all EL majors × archs, batched) + needs: [determine-repo-type] + if: needs.determine-repo-type.result == 'success' + runs-on: ubuntu-latest + concurrency: + group: dnf-push-${{ needs.determine-repo-type.outputs.repo-type }} + cancel-in-progress: false + outputs: + timestamp: ${{ steps.ts.outputs.dnf_ts }} + cells: ${{ steps.targets.outputs.cells }} + backups: ${{ steps.targets.outputs.backups }} + env: + REPO_TYPE: ${{ needs.determine-repo-type.outputs.repo-type }} + COMPONENT_VERSION: ${{ needs.determine-repo-type.outputs.component-version }} + COMPONENT_NAME: ${{ github.event.repository.name }} + steps: + - name: Timestamp + id: ts + run: echo "dnf_ts=$(date -u +'%Y%m%d-%H%M%S')" >> "$GITHUB_OUTPUT" + - name: Download RPMs from the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + mkdir -p rpm-dl + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern '*.rpm' --dir rpm-dl + ls -la rpm-dl + - name: Organize RPMs by EL major + build targets/cells + id: targets + env: + DNF_TS: ${{ steps.ts.outputs.dnf_ts }} + run: | + set -euo pipefail + push='[]'; backups='[]'; cells='[]' + # Group the downloaded rpms by their .elN. dist tag. + for el in $(ls rpm-dl/*.rpm | grep -oE 'el[0-9]+' | sort -u); do + osv="${el#el}"; dest="rpms-${el}"; mkdir -p "$dest" + mv rpm-dl/*."${el}".*.rpm "$dest"/ + echo "EL${osv}: $(ls "$dest" | wc -l) rpms" + push="$(jq -c --arg v "$osv" --arg d "$dest" '. + [{"os-version":$v,"rpm-dir":$d}]' <<<"$push")" + backups="$(jq -c --arg v "$osv" --arg d "$dest" '. + [{"os-version":$v,"output-dir":$d}]' <<<"$backups")" + bp="${REPO_TYPE}/${COMPONENT_NAME}/${COMPONENT_VERSION}/${DNF_TS}/dnf/${osv}/" + for f in "$dest"/*.rpm; do + arch="$(basename "$f" | sed -E 's/.*\.([^.]+)\.rpm$/\1/')" + cells="$(jq -c --arg comp "$COMPONENT_NAME" --arg cv "$COMPONENT_VERSION" --arg img "almalinux:${osv}" --arg arch "$arch" --arg osv "$osv" --arg bp "$bp" --arg ts "$DNF_TS" --arg rt "$REPO_TYPE" \ + '. + [{family:"rpm",component:$comp,component_version:$cv,image:$img,arch:$arch,os_version:$osv,backup_path:$bp,timestamp:$ts,repo_type:$rt}]' <<<"$cells")" + done + done + rm -rf rpm-dl + if [ "$(jq 'length' <<<"$push")" -eq 0 ]; then + echo "::error::no RPM publish targets derived from the release assets (no *.rpm attached, or none matched the .elN. naming)" + exit 1 + fi + echo "push=$push" >> "$GITHUB_OUTPUT" + echo "backups=$backups" >> "$GITHUB_OUTPUT" + echo "cells=$cells" >> "$GITHUB_OUTPUT" + - name: Checkout pgEdge action repos @multi-target + env: + TOKEN: ${{ secrets.PGEDGE_BUILDER_TOKEN }} + run: | + set -euo pipefail + mkdir -p .github/actions + # Token via transient auth header; drop .git after clone so the + # credential does not persist in the workspace. + auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')" + for repo in pgedge-dnf-repo-builder pgedge-backup-artifacts; do + git -c "http.extraheader=${auth}" clone --depth 1 --branch multi-target \ + "https://github.com/pgEdge/${repo}.git" ".github/actions/${repo}" + rm -rf ".github/actions/${repo}/.git" + done + - name: Push RPMs to yum repo (all EL majors batched) + uses: ./.github/actions/pgedge-dnf-repo-builder + with: + s3-bucket: ${{ secrets.S3_BUCKET_NAME }} + cf-distribution: ${{ secrets.CLOUDFRONT_DISTRIBUTION }} + repo-type: ${{ env.REPO_TYPE }} + targets: ${{ steps.targets.outputs.push }} + lock-bucket: ${{ secrets.S3_BACKUP_BUCKET_NAME }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: Back up RPMs to S3 (all EL majors batched) + uses: ./.github/actions/pgedge-backup-artifacts + with: + s3-backup-bucket: ${{ secrets.S3_BACKUP_BUCKET_NAME }} + repo-type: ${{ env.REPO_TYPE }} + component-name: ${{ env.COMPONENT_NAME }} + component-version: ${{ env.COMPONENT_VERSION }} + timestamp: ${{ steps.ts.outputs.dnf_ts }} + targets: ${{ steps.targets.outputs.backups }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + # ========================================================================= + # Push DEBs → apt.pgedge.com (reprepro signs the repo with the DEB key). + # ========================================================================= + push-apt: + name: Push APT (all distros × archs, batched) + needs: [determine-repo-type] + if: needs.determine-repo-type.result == 'success' + runs-on: ubuntu-latest + concurrency: + group: apt-push-${{ needs.determine-repo-type.outputs.repo-type }} + cancel-in-progress: false + outputs: + timestamp: ${{ steps.ts.outputs.apt_ts }} + cells: ${{ steps.targets.outputs.cells }} + backups: ${{ steps.targets.outputs.backups }} + env: + REPO_TYPE: ${{ needs.determine-repo-type.outputs.repo-type }} + COMPONENT_VERSION: ${{ needs.determine-repo-type.outputs.component-version }} + COMPONENT_NAME: ${{ github.event.repository.name }} + steps: + - name: Timestamp + id: ts + run: echo "apt_ts=$(date -u +'%Y%m%d-%H%M%S')" >> "$GITHUB_OUTPUT" + - name: Download DEBs from the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + mkdir -p deb-dl + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern '*.deb' --dir deb-dl + ls -la deb-dl + - name: Organize DEBs by codename + build targets/cells + id: targets + env: + APT_TS: ${{ steps.ts.outputs.apt_ts }} + run: | + set -euo pipefail + push='[]'; backups='[]'; cells='[]' + # codename lives in the deb revision: ..._-1._.deb + for code in $(ls deb-dl/*.deb | sed -E 's/.*-1\.([a-z]+)_[a-z0-9]+\.deb$/\1/' | sort -u); do + case "$code" in + jammy|noble) image="ubuntu:${code}" ;; + bullseye|bookworm|trixie) image="debian:${code}" ;; + *) echo "::error::unknown codename '${code}'"; exit 1 ;; + esac + dest="debs-${code}"; mkdir -p "$dest" + mv deb-dl/*-1."${code}"_*.deb "$dest"/ + echo "${code}: $(ls "$dest" | wc -l) debs" + push="$(jq -c --arg n "$image" --arg d "$dest" '. + [{"os-name":$n,"deb-dir":$d}]' <<<"$push")" + backups="$(jq -c --arg v "$code" --arg d "$dest" '. + [{"os-version":$v,"output-dir":$d}]' <<<"$backups")" + bp="${REPO_TYPE}/${COMPONENT_NAME}/${COMPONENT_VERSION}/${APT_TS}/apt/${code}/" + for f in "$dest"/*.deb; do + arch="$(basename "$f" | sed -E 's/.*_([a-z0-9]+)\.deb$/\1/')" + cells="$(jq -c --arg comp "$COMPONENT_NAME" --arg cv "$COMPONENT_VERSION" --arg img "$image" --arg arch "$arch" --arg distro "$code" --arg bp "$bp" --arg ts "$APT_TS" --arg rt "$REPO_TYPE" \ + '. + [{family:"deb",component:$comp,component_version:$cv,image:$img,arch:$arch,distro:$distro,backup_path:$bp,timestamp:$ts,repo_type:$rt}]' <<<"$cells")" + done + done + rm -rf deb-dl + if [ "$(jq 'length' <<<"$push")" -eq 0 ]; then + echo "::error::no DEB publish targets derived from the release assets (no *.deb attached, or none matched the -1._ naming)" + exit 1 + fi + echo "push=$push" >> "$GITHUB_OUTPUT" + echo "backups=$backups" >> "$GITHUB_OUTPUT" + echo "cells=$cells" >> "$GITHUB_OUTPUT" + - name: Checkout pgEdge action repos @multi-target + env: + TOKEN: ${{ secrets.PGEDGE_BUILDER_TOKEN }} + run: | + set -euo pipefail + mkdir -p .github/actions + # Token via transient auth header; drop .git after clone so the + # credential does not persist in the workspace. + auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')" + for repo in pgedge-apt-repo-builder pgedge-backup-artifacts; do + git -c "http.extraheader=${auth}" clone --depth 1 --branch multi-target \ + "https://github.com/pgEdge/${repo}.git" ".github/actions/${repo}" + rm -rf ".github/actions/${repo}/.git" + done + - name: Push DEBs to apt repo (all distros batched) + uses: ./.github/actions/pgedge-apt-repo-builder + with: + s3-bucket: ${{ secrets.APT_S3_BUCKET_NAME }} + cf-distribution: ${{ secrets.APT_CLOUDFRONT_DISTRIBUTION }} + repo-type: ${{ env.REPO_TYPE }} + targets: ${{ steps.targets.outputs.push }} + lock-bucket: ${{ secrets.S3_BACKUP_BUCKET_NAME }} + gpg-private-key: ${{ secrets.GPG_FIPS_DEB_PRIVATE_KEY }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: Back up DEBs to S3 (all distros batched) + uses: ./.github/actions/pgedge-backup-artifacts + with: + s3-backup-bucket: ${{ secrets.S3_BACKUP_BUCKET_NAME }} + repo-type: ${{ env.REPO_TYPE }} + component-name: ${{ env.COMPONENT_NAME }} + component-version: ${{ env.COMPONENT_VERSION }} + timestamp: ${{ steps.ts.outputs.apt_ts }} + targets: ${{ steps.targets.outputs.backups }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + # ========================================================================= + # Aggregated manifest (cells[] for qa-certify/promote) + Slack. + # ========================================================================= + publish-manifest: + name: Publish aggregated backup manifest + needs: [determine-repo-type, push-dnf, push-apt] + if: always() && needs.determine-repo-type.result == 'success' + runs-on: ubuntu-latest + env: + COMPONENT_NAME: ${{ github.event.repository.name }} + steps: + - name: Install jq + awscli + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y python3-pip jq + pip3 install --user awscli + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Build manifest (with cells[] for qa-certify/promote) + id: build_manifest + env: + REPO_TYPE: ${{ needs.determine-repo-type.outputs.repo-type }} + CV: ${{ needs.determine-repo-type.outputs.component-version }} + CBN: ${{ needs.determine-repo-type.outputs.component-buildnum }} + EFFECTIVE_TAG: ${{ needs.determine-repo-type.outputs.effective-tag }} + SIMULATED: ${{ needs.determine-repo-type.outputs.simulated }} + PUSH_DNF_RESULT: ${{ needs.push-dnf.result }} + PUSH_APT_RESULT: ${{ needs.push-apt.result }} + DNF_TS: ${{ needs.push-dnf.outputs.timestamp }} + APT_TS: ${{ needs.push-apt.outputs.timestamp }} + DNF_CELLS: ${{ needs.push-dnf.outputs.cells }} + APT_CELLS: ${{ needs.push-apt.outputs.cells }} + run: | + set -euo pipefail + mkdir -p aggregated + cells='[]'; dnf_backups='[]'; apt_backups='[]' + if [ "${PUSH_DNF_RESULT}" = "success" ] && [ -n "${DNF_CELLS:-}" ]; then + cells="$(jq -c --argjson a "$cells" --argjson b "${DNF_CELLS}" -n '$a + $b')" + dnf_backups="$(jq -c '[.[].backup_path] | unique' <<<"${DNF_CELLS}")" + fi + if [ "${PUSH_APT_RESULT}" = "success" ] && [ -n "${APT_CELLS:-}" ]; then + cells="$(jq -c --argjson a "$cells" --argjson b "${APT_CELLS}" -n '$a + $b')" + apt_backups="$(jq -c '[.[].backup_path] | unique' <<<"${APT_CELLS}")" + fi + jq -n \ + --arg run_id "${GITHUB_RUN_ID}" --arg run_attempt "${GITHUB_RUN_ATTEMPT}" \ + --arg repo "${GITHUB_REPOSITORY}" --arg tag "${EFFECTIVE_TAG}" \ + --argjson simulated "${SIMULATED}" --arg commit_sha "${GITHUB_SHA}" \ + --arg component_name "${COMPONENT_NAME}" --arg component_version "${CV}" \ + --arg component_buildnum "${CBN}" --arg repo_type "${REPO_TYPE}" \ + --arg dnf_timestamp "${DNF_TS}" --arg apt_timestamp "${APT_TS}" \ + --arg push_dnf_outcome "${PUSH_DNF_RESULT}" --arg push_apt_outcome "${PUSH_APT_RESULT}" \ + --argjson dnf_backups "${dnf_backups}" --argjson apt_backups "${apt_backups}" \ + --argjson cells "${cells}" \ + '{run_id:$run_id, run_attempt:$run_attempt, repo:$repo, tag:$tag, simulated:$simulated, commit_sha:$commit_sha, component_name:$component_name, component_version:$component_version, component_buildnum:$component_buildnum, repo_type:$repo_type, inputs:{component:$component_name, component_version:$component_version, component_buildnum:$component_buildnum}, timestamps:{dnf:$dnf_timestamp, apt:$apt_timestamp}, push_results:{dnf:$push_dnf_outcome, apt:$push_apt_outcome}, backups:{dnf:$dnf_backups, apt:$apt_backups}, cells:$cells}' \ + > aggregated/manifest.json + cat aggregated/manifest.json + { echo "manifest_json<<__EOF__"; jq -c '.' aggregated/manifest.json; echo "__EOF__"; } >> "$GITHUB_OUTPUT" + - name: Checkout pgedge-build-publisher + env: + TOKEN: ${{ secrets.PGEDGE_BUILDER_TOKEN }} + run: | + set -euo pipefail + mkdir -p .github/actions + # Token via transient auth header; drop .git after clone so the + # credential does not persist in the workspace. + auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')" + git -c "http.extraheader=${auth}" clone --depth 1 \ + "https://github.com/pgEdge/pgedge-build-publisher.git" \ + ".github/actions/pgedge-build-publisher" + rm -rf ".github/actions/pgedge-build-publisher/.git" + - name: Compose Slack fields + id: compose_slack + env: + CV: ${{ needs.determine-repo-type.outputs.component-version }} + CBN: ${{ needs.determine-repo-type.outputs.component-buildnum }} + EFFECTIVE_TAG: ${{ needs.determine-repo-type.outputs.effective-tag }} + REPO_TYPE: ${{ needs.determine-repo-type.outputs.repo-type }} + PUSH_DNF_RESULT: ${{ needs.push-dnf.result }} + PUSH_APT_RESULT: ${{ needs.push-apt.result }} + ACTOR: ${{ github.actor }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + run: | + set -euo pipefail + unix_ts="$(date -u +%s)"; fallback_ts="$(date -u +'%Y-%m-%d %H:%M UTC')" + subject="${COMPONENT_NAME} <${REPO_URL}/releases/tag/${EFFECTIVE_TAG}|${EFFECTIVE_TAG}>" + s=0; f=0; k=0 + for r in "${PUSH_DNF_RESULT}" "${PUSH_APT_RESULT}"; do + case "$r" in success) s=$((s+1));; skipped) k=$((k+1));; *) f=$((f+1));; esac + done + total=$((s+f+k)) + if [ "$f" -eq 0 ] && [ "$k" -eq 0 ]; then status="Success"; elif [ "$s" -gt 0 ]; then status="Partial"; elif [ "$k" -eq "$total" ]; then status="Skipped"; else status="Failed"; fi + archs="—" + if [ -f aggregated/manifest.json ]; then a=$(jq -r '(.cells // []) | map(.arch) | unique | join(", ")' aggregated/manifest.json); [ -n "$a" ] && archs="$a"; fi + fields=$(jq -nc --arg channel "${REPO_TYPE}" --arg version "${CV}" --arg buildnum "${CBN}" --arg archs "${archs}" --arg actor "${ACTOR}" --arg unix_ts "${unix_ts}" --arg fallback_ts "${fallback_ts}" '[{title:"Channel",value:("`"+$channel+"`")},{title:"Version",value:("`"+$version+"`")},{title:"Build #",value:("`"+$buildnum+"`")},{title:"Archs",value:("`"+$archs+"`")},{title:"Triggered by",value:$actor},{title:"When",value:("")}]') + result_fields=$(jq -nc --arg dnf "${PUSH_DNF_RESULT}" --arg apt "${PUSH_APT_RESULT}" '[{title:"Push DNF",value:("`"+$dnf+"`")},{title:"Push APT",value:("`"+$apt+"`")}]') + dnf_paths=$(jq -c '.backups.dnf // []' aggregated/manifest.json); apt_paths=$(jq -c '.backups.apt // []' aggregated/manifest.json) + code_sections=$(jq -nc --argjson dnf "${dnf_paths}" --argjson apt "${apt_paths}" '[{title:"DNF backup paths",lines:$dnf},{title:"APT backup paths",lines:$apt}]') + { echo "status=${status}"; echo "subject<<__S__"; echo "${subject}"; echo "__S__"; echo "fields<<__F__"; echo "${fields}"; echo "__F__"; echo "result_fields<<__RF__"; echo "${result_fields}"; echo "__RF__"; echo "code_sections<<__CS__"; echo "${code_sections}"; echo "__CS__"; } >> "$GITHUB_OUTPUT" + - name: Publish manifest + Slack notification + uses: ./.github/actions/pgedge-build-publisher + with: + manifest_json: ${{ steps.build_manifest.outputs.manifest_json }} + s3_backup_bucket: ${{ secrets.S3_BACKUP_BUCKET_NAME }} + repo_type: ${{ needs.determine-repo-type.outputs.repo-type }} + component_name: ${{ env.COMPONENT_NAME }} + skip_publish: ${{ needs.push-dnf.result != 'success' && needs.push-apt.result != 'success' }} + status: ${{ steps.compose_slack.outputs.status }} + push_dnf_result: ${{ needs.push-dnf.result }} + push_apt_result: ${{ needs.push-apt.result }} + slack_webhook: ${{ secrets.SLACK_CHANNEL_URL }} + subject: ${{ steps.compose_slack.outputs.subject }} + fields_json: ${{ steps.compose_slack.outputs.fields }} + result_fields_json: ${{ steps.compose_slack.outputs.result_fields }} + code_sections_json: ${{ steps.compose_slack.outputs.code_sections }} + tagline: "pgEdge release pipeline" + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: Upload aggregated manifest as workflow artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: aggregated-manifest + path: aggregated/manifest.json + retention-days: 90 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 47cb4776..5845130b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -26,17 +26,35 @@ sboms: documents: - '${artifact}.spdx.json' +# rpm/deb packages — one nfpm entry per target distro. The binary is static +# (CGO_ENABLED=0), so the SAME cross-compiled amd64/arm64 binaries are packaged +# for every distro; we only stamp a per-distro tag (rpm Release .el9/.el10; deb +# codename in the revision, e.g. 1.noble). Common fields live on the el9 anchor +# and merge into the rest; each entry overrides only id / formats / release. +# The VERSION comes from the (normalised) tag the CircleCI release job passes to +# goreleaser, e.g. 1.0.0~rc1 — both formats carry '~' for pre-releases. nfpms: - - package_name: pgedge-control-plane + - &nfpm_defaults + id: el9 + package_name: pgedge-control-plane vendor: pgEdge homepage: https://www.pgedge.com maintainer: pgEdge Support description: pgEdge Control Plane license: PostgreSQL License - formats: - - rpm - - deb + file_name_template: "{{ .ConventionalFileName }}" bindir: /usr/sbin + # Force gzip for the .deb members — reprepro can't read zstd; mirrors the + # enterprise debhelper convention `dh_builddeb -- -Zgzip`. + deb: + compression: gzip + # Contents are shared at the top level. The SBOM signature is selected per + # format via {{ .Format }} (rpm -> sbom.rpm.asc, deb -> sbom.deb.asc) — + # goreleaser templates contents.src, so no per-format contents override is + # needed. Only the postinstall and preremove scripts genuinely differ per + # format (rpm/ vs deb/), and those paths can't be templated, so they stay in + # overrides. (preremove is per-format so the service is only stopped on + # removal, not on upgrade — the guard condition differs between rpm/deb.) overrides: rpm: scripts: @@ -53,12 +71,52 @@ nfpms: - src: packaging/config.json dst: /etc/pgedge-control-plane/config.json type: config|noreplace + - src: .sbom/sbom.json + dst: /usr/share/pgedge-control-plane/pgedge-control-plane-sbom.json + - src: ".sbom/sbom.{{ .Format }}.asc" + dst: /usr/share/pgedge-control-plane/pgedge-control-plane-sbom.json.asc + formats: [rpm] + release: "1.el9" + - <<: *nfpm_defaults + id: el10 + release: "1.el10" + # --- DEB (codename in the revision) --- + - <<: *nfpm_defaults + id: jammy + formats: [deb] + release: "1.jammy" + - <<: *nfpm_defaults + id: noble + formats: [deb] + release: "1.noble" + - <<: *nfpm_defaults + id: bullseye + formats: [deb] + release: "1.bullseye" + - <<: *nfpm_defaults + id: bookworm + formats: [deb] + release: "1.bookworm" + - <<: *nfpm_defaults + id: trixie + formats: [deb] + release: "1.trixie" + # Ubuntu 26.04 LTS — enable when targeted: + # - <<: *nfpm_defaults + # id: resolute + # formats: [deb] + # release: "1.resolute" release: github: owner: pgEdge name: control-plane prerelease: auto + # Create the release as a DRAFT so the 'release: published' event (which + # triggers .github/workflows/publish.yml) does NOT fire until the signed + # rpm/deb packages are attached. CircleCI flips it to published at the end + # (gh release edit --draft=false), after the packages are uploaded. + draft: true footer: | Get the Docker image for this release from: