Publish Current Server #207
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Publish Current Server | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| release_tag: | |
| description: Exact semantic release tag declared by the Server image | |
| required: true | |
| type: string | |
| permissions: | |
| contents: write | |
| packages: write | |
| id-token: write | |
| attestations: write | |
| artifact-metadata: write | |
| concurrency: | |
| group: publish-current-server-${{ inputs.release_tag }} | |
| cancel-in-progress: false | |
| jobs: | |
| publish: | |
| if: github.repository == 'PastureStack/server' && github.ref == 'refs/heads/main' | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 90 | |
| env: | |
| RELEASE_TAG: ${{ inputs.release_tag }} | |
| SOURCE_SHA: ${{ github.sha }} | |
| TARGET_IMAGE: ghcr.io/pasturestack/server:${{ inputs.release_tag }} | |
| LAYERED_CANDIDATE_IMAGE: local/pasturestack/server-layered:${{ inputs.release_tag }}-${{ github.run_id }} | |
| CANDIDATE_IMAGE: local/pasturestack/server:${{ inputs.release_tag }}-${{ github.run_id }} | |
| CANDIDATE_NAME: pasturestack-server-release-${{ github.run_id }} | |
| CATTLE_VOLUME: pasturestack-server-release-cattle-${{ github.run_id }} | |
| MYSQL_VOLUME: pasturestack-server-release-mysql-${{ github.run_id }} | |
| MYSQL_LOG_VOLUME: pasturestack-server-release-mysqllog-${{ github.run_id }} | |
| TRIVY_IMAGE: ghcr.io/aquasecurity/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 | |
| steps: | |
| - name: Enable the containerd image store | |
| shell: bash | |
| run: | | |
| set -Eeuo pipefail | |
| if sudo test -f /etc/docker/daemon.json; then | |
| sudo cat /etc/docker/daemon.json >"$RUNNER_TEMP/daemon-current.json" | |
| else | |
| printf '{}\n' >"$RUNNER_TEMP/daemon-current.json" | |
| fi | |
| jq ' | |
| .features = (.features // {}) | |
| | .features["containerd-snapshotter"] = true | |
| ' "$RUNNER_TEMP/daemon-current.json" >"$RUNNER_TEMP/daemon-containerd.json" | |
| sudo install -o root -g root -m 0644 \ | |
| "$RUNNER_TEMP/daemon-containerd.json" /etc/docker/daemon.json | |
| sudo systemctl restart docker | |
| docker info --format '{{json .DriverStatus}}' | | |
| grep -F 'io.containerd.snapshotter.v1' >/dev/null | |
| - name: Check out exact clean source | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -Eeuo pipefail | |
| [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] | |
| git clone --filter=blob:none "https://github.com/${GITHUB_REPOSITORY}.git" source | |
| git -C source checkout --detach "$SOURCE_SHA" | |
| test "$(git -C source rev-parse HEAD)" = "$SOURCE_SHA" | |
| test -z "$(git -C source status --short)" | |
| declared_release="$(sed -n 's/.*org.opencontainers.image.version="\([^"]*\)".*/\1/p' \ | |
| source/server/Dockerfile.web-compose-release)" | |
| test "$declared_release" = "$RELEASE_TAG" | |
| release_notes="source/docs/releases/server-${RELEASE_TAG#v}.md" | |
| test -f "$release_notes" | |
| test -s "$release_notes" | |
| if git -C source ls-remote --exit-code --tags origin "refs/tags/${RELEASE_TAG}" \ | |
| >/dev/null 2>&1; then | |
| echo "Release tag already exists: $RELEASE_TAG" >&2 | |
| exit 1 | |
| fi | |
| if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ | |
| >/dev/null 2>&1; then | |
| echo "GitHub Release already exists: $RELEASE_TAG" >&2 | |
| exit 1 | |
| fi | |
| if docker manifest inspect "$TARGET_IMAGE" >/dev/null 2>&1; then | |
| echo "Container tag already exists: $TARGET_IMAGE" >&2 | |
| exit 1 | |
| fi | |
| mkdir -p "$RUNNER_TEMP/server-evidence" | |
| - name: Run source gates | |
| shell: bash | |
| working-directory: source | |
| run: | | |
| set -Eeuo pipefail | |
| bash scripts/check-server-source-gates.sh | |
| - name: Build exact release image once | |
| shell: bash | |
| working-directory: source | |
| run: | | |
| set -Eeuo pipefail | |
| PASTURESTACK_BUILD_NO_CACHE=1 IMAGE="$LAYERED_CANDIDATE_IMAGE" \ | |
| bash server/build-api-explorer-patch-image.sh |& | |
| tee "$RUNNER_TEMP/server-evidence/build.log" | |
| test "$(docker image inspect "$LAYERED_CANDIDATE_IMAGE" \ | |
| --format '{{index .Config.Labels "org.opencontainers.image.version"}}')" = \ | |
| "$RELEASE_TAG" | |
| test "$(docker image inspect "$LAYERED_CANDIDATE_IMAGE" \ | |
| --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = \ | |
| "$SOURCE_SHA" | |
| base_ref='ghcr.io/pasturestack/server:v1.6.460@sha256:c855af8aea232dacc5bb6df68e2271d482c68b53c43ab0c108ec19118f5ab403' | |
| base_count="$(docker buildx imagetools inspect --raw "$base_ref" | | |
| jq -r '.layers | length')" | |
| layered_count="$(docker image inspect "$LAYERED_CANDIDATE_IMAGE" | | |
| jq -r '.[0].RootFS.Layers | length')" | |
| test "$base_count" -eq 1 | |
| test "$layered_count" -gt 1 | |
| test "$layered_count" -le 32 | |
| printf 'SERVER_LAYERED_BUILD_OK base_layers=%s source_layers=%s maximum=32 base=v1.6.460\n' \ | |
| "$base_count" "$layered_count" | | |
| tee "$RUNNER_TEMP/server-evidence/source-layers.txt" | |
| - name: Flatten and compare the release image for classic overlay2 | |
| shell: bash | |
| run: | | |
| set -Eeuo pipefail | |
| extract_name="pasturestack-server-layer-export-${GITHUB_RUN_ID}" | |
| rootfs_tar="$RUNNER_TEMP/server-layered-rootfs.tar" | |
| changes_file="$RUNNER_TEMP/server-flatten-changes.bin" | |
| cleanup_extract() { | |
| docker rm -v "$extract_name" >/dev/null 2>&1 || true | |
| rm -f -- "$rootfs_tar" "$changes_file" | |
| } | |
| trap cleanup_extract EXIT | |
| [[ "$LAYERED_CANDIDATE_IMAGE" =~ ^local/pasturestack/server-layered:v[0-9]+\.[0-9]+\.[0-9]+-[0-9]+$ ]] | |
| [[ "$CANDIDATE_IMAGE" =~ ^local/pasturestack/server:v[0-9]+\.[0-9]+\.[0-9]+-[0-9]+$ ]] | |
| docker create --name "$extract_name" "$LAYERED_CANDIDATE_IMAGE" >/dev/null | |
| docker export --output "$rootfs_tar" "$extract_name" | |
| test -f "$rootfs_tar" | |
| test ! -L "$rootfs_tar" | |
| docker image inspect "$LAYERED_CANDIDATE_IMAGE" | | |
| python3 source/scripts/flatten-server-image.py changes >"$changes_file" | |
| mapfile -d '' -t flatten_changes <"$changes_file" | |
| test "${#flatten_changes[@]}" -gt 0 | |
| flatten_args=() | |
| for change in "${flatten_changes[@]}"; do | |
| flatten_args+=(--change "$change") | |
| done | |
| docker image import --platform linux/amd64 \ | |
| "${flatten_args[@]}" "$rootfs_tar" "$CANDIDATE_IMAGE" >/dev/null | |
| docker image inspect "$LAYERED_CANDIDATE_IMAGE" "$CANDIDATE_IMAGE" | | |
| python3 source/scripts/flatten-server-image.py verify | | |
| tee "$RUNNER_TEMP/server-evidence/image-flatten.txt" | |
| grep -F 'SERVER_IMAGE_FLATTEN_OK' \ | |
| "$RUNNER_TEMP/server-evidence/image-flatten.txt" >/dev/null | |
| test "$(docker image inspect "$CANDIDATE_IMAGE" \ | |
| --format '{{index .Config.Labels "org.opencontainers.image.version"}}')" = \ | |
| "$RELEASE_TAG" | |
| test "$(docker image inspect "$CANDIDATE_IMAGE" \ | |
| --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = \ | |
| "$SOURCE_SHA" | |
| cleanup_extract | |
| trap - EXIT | |
| - name: Start, restart, and export the candidate | |
| shell: bash | |
| run: | | |
| set -Eeuo pipefail | |
| evidence="$RUNNER_TEMP/server-evidence" | |
| rootfs="$RUNNER_TEMP/server-rootfs" | |
| capture_candidate_evidence() { | |
| if docker inspect "$CANDIDATE_NAME" >/dev/null 2>&1; then | |
| docker logs "$CANDIDATE_NAME" >"$evidence/candidate.log" 2>&1 || true | |
| docker inspect "$CANDIDATE_NAME" >"$evidence/candidate-inspect.json" \ | |
| 2>/dev/null || true | |
| fi | |
| } | |
| cleanup_candidate() { | |
| capture_candidate_evidence | |
| docker rm -f "$CANDIDATE_NAME" >/dev/null 2>&1 || true | |
| docker volume rm "$CATTLE_VOLUME" "$MYSQL_VOLUME" "$MYSQL_LOG_VOLUME" \ | |
| >/dev/null 2>&1 || true | |
| } | |
| trap cleanup_candidate EXIT | |
| for object in "$CANDIDATE_NAME" "$CATTLE_VOLUME" "$MYSQL_VOLUME" "$MYSQL_LOG_VOLUME"; do | |
| ! docker inspect "$object" >/dev/null 2>&1 | |
| done | |
| docker run -d \ | |
| --name "$CANDIDATE_NAME" \ | |
| --restart no \ | |
| -p 127.0.0.1:18082:8080 \ | |
| -e PROXY_PLATFORM_PUBLIC_ORIGIN=https://stack.example.test \ | |
| -v "$CATTLE_VOLUME:/var/lib/cattle" \ | |
| -v "$MYSQL_VOLUME:/var/lib/mysql" \ | |
| -v "$MYSQL_LOG_VOLUME:/var/log/mysql" \ | |
| "$CANDIDATE_IMAGE" >/dev/null | |
| wait_for_pong() { | |
| local body attempt | |
| for attempt in $(seq 1 180); do | |
| if [[ "$(docker inspect "$CANDIDATE_NAME" \ | |
| --format '{{.State.Running}}' 2>/dev/null || true)" != true ]]; then | |
| return 1 | |
| fi | |
| body="$(curl -fsS --max-time 3 http://127.0.0.1:18082/ping 2>/dev/null || true)" | |
| if [[ "$body" == pong ]]; then | |
| printf '%s' "$attempt" | |
| return 0 | |
| fi | |
| sleep 2 | |
| done | |
| return 1 | |
| } | |
| header_value() { | |
| local file=$1 name=$2 | |
| awk -v name="$name" ' | |
| BEGIN { IGNORECASE = 1 } | |
| { | |
| line = $0 | |
| sub(/\r$/, "", line) | |
| colon = index(line, ":") | |
| if (colon > 0 && tolower(substr(line, 1, colon - 1)) == tolower(name)) { | |
| value = substr(line, colon + 1) | |
| sub(/^[[:space:]]+/, "", value) | |
| } | |
| } | |
| END { print value } | |
| ' "$file" | |
| } | |
| assert_public_proxy_contract() { | |
| local phase=$1 attempt schema_code schema_value path slug response_code | |
| local private_api_attempt cache_control pragma expires age | |
| local schema_matched=false | |
| local schema_headers="$RUNNER_TEMP/public-origin-${phase}-schema.headers" | |
| local response_headers private_api_matched | |
| local summary="$evidence/reverse-proxy-${phase}.txt" | |
| public_proxy_contract_attempts=0 | |
| : >"$summary" | |
| for attempt in $(seq 1 60); do | |
| public_proxy_contract_attempts="$attempt" | |
| : >"$schema_headers" | |
| schema_code="$(curl -sS --max-time 5 -D "$schema_headers" -o /dev/null \ | |
| -w '%{http_code}' -H 'Host: stack.example.test' \ | |
| http://127.0.0.1:18082/v2-beta/schemas 2>/dev/null || true)" | |
| schema_value="$(header_value "$schema_headers" X-Api-Schemas 2>/dev/null || true)" | |
| if [[ "$schema_code" == 200 && \ | |
| "$schema_value" == https://stack.example.test/v2-beta/schemas ]]; then | |
| schema_matched=true | |
| break | |
| fi | |
| sleep 2 | |
| done | |
| printf 'phase=%s path=/v2-beta/schemas status=%s x_api_schemas=%s\n' \ | |
| "$phase" "$schema_code" "$schema_value" >>"$summary" | |
| if [[ "$schema_matched" != true ]]; then | |
| echo "Public proxy contract did not converge during ${phase}: status=${schema_code} X-Api-Schemas=${schema_value}" >&2 | |
| return 1 | |
| fi | |
| for path in /v2-beta/accounts /v1-auth/config; do | |
| slug="${path#/}" | |
| slug="${slug//\//-}" | |
| response_headers="$RUNNER_TEMP/public-origin-${phase}-${slug}.headers" | |
| private_api_matched=false | |
| for private_api_attempt in $(seq 1 60); do | |
| : >"$response_headers" | |
| response_code="$(curl -sS --max-time 5 -D "$response_headers" -o /dev/null \ | |
| -w '%{http_code}' -H 'Host: stack.example.test' \ | |
| "http://127.0.0.1:18082${path}" 2>/dev/null || true)" | |
| cache_control="$(header_value "$response_headers" Cache-Control 2>/dev/null || true)" | |
| pragma="$(header_value "$response_headers" Pragma 2>/dev/null || true)" | |
| expires="$(header_value "$response_headers" Expires 2>/dev/null || true)" | |
| age="$(header_value "$response_headers" Age 2>/dev/null || true)" | |
| if [[ "$response_code" =~ ^(200|401|403)$ && \ | |
| "$cache_control" == 'private, no-store' && \ | |
| "$pragma" == 'no-cache' && "$expires" == 0 && -z "$age" ]]; then | |
| private_api_matched=true | |
| break | |
| fi | |
| sleep 2 | |
| done | |
| if (( private_api_attempt > public_proxy_contract_attempts )); then | |
| public_proxy_contract_attempts="$private_api_attempt" | |
| fi | |
| printf 'phase=%s path=%s status=%s cache_control=%s pragma=%s expires=%s age=%s attempts=%s\n' \ | |
| "$phase" "$path" "$response_code" "$cache_control" "$pragma" \ | |
| "$expires" "${age:-absent}" "$private_api_attempt" >>"$summary" | |
| if [[ "$private_api_matched" != true ]]; then | |
| echo "Private API contract did not converge during ${phase}: path=${path} status=${response_code} cache_control=${cache_control} pragma=${pragma} expires=${expires} age=${age:-absent}" >&2 | |
| return 1 | |
| fi | |
| done | |
| } | |
| first_attempts="$(wait_for_pong)" | |
| for catalog_attempts in $(seq 1 90); do | |
| docker logs "$CANDIDATE_NAME" >"$evidence/candidate.log" 2>&1 | |
| if grep -Fq 'PastureStack Catalog bootstrap verified a non-empty collection.' \ | |
| "$evidence/candidate.log"; then | |
| break | |
| fi | |
| sleep 2 | |
| done | |
| grep -Fq 'PastureStack Catalog bootstrap verified a non-empty collection.' \ | |
| "$evidence/candidate.log" | |
| ! grep -Fq "mysql_native_password': this user requires mysql native password authentication" \ | |
| "$evidence/candidate.log" | |
| docker inspect "$CANDIDATE_NAME" --format '{{range .Config.Env}}{{println .}}{{end}}' | | |
| grep -Fx "CATTLE_RANCHER_SERVER_VERSION=$RELEASE_TAG" >/dev/null | |
| docker inspect "$CANDIDATE_NAME" --format '{{range .Config.Env}}{{println .}}{{end}}' | | |
| grep -Fx 'PROXY_PLATFORM_PUBLIC_ORIGIN=https://stack.example.test' >/dev/null | |
| assert_public_proxy_contract before-restart | |
| first_contract_attempts="$public_proxy_contract_attempts" | |
| docker restart "$CANDIDATE_NAME" >/dev/null | |
| restart_attempts="$(wait_for_pong)" | |
| assert_public_proxy_contract after-restart | |
| restart_contract_attempts="$public_proxy_contract_attempts" | |
| test "$(docker inspect "$CANDIDATE_NAME" --format '{{.State.Running}}')" = true | |
| http_code="$(curl -sS --max-time 3 -o "$RUNNER_TEMP/ping-body" \ | |
| -w '%{http_code}' http://127.0.0.1:18082/ping)" | |
| test "$http_code" = 200 | |
| test "$(cat "$RUNNER_TEMP/ping-body")" = pong | |
| # Exercise the published API and real auth overlay on this fresh, | |
| # disposable database before the image is allowed to be released. | |
| python3 source/scripts/test-mfa-policy-api.py \ | |
| --port 18082 --disposable | | |
| tee "$evidence/mfa-policy-api-smoke.txt" | |
| docker stop "$CANDIDATE_NAME" >/dev/null | |
| mkdir -p "$rootfs" | |
| docker export --output "$RUNNER_TEMP/server-rootfs.tar" "$CANDIDATE_NAME" | |
| tar --no-same-owner -xf "$RUNNER_TEMP/server-rootfs.tar" -C "$rootfs" | |
| test "$(readlink "$rootfs/usr/share/cattle/artifacts/host-api.tar.gz")" = \ | |
| host-api-0.38.4.tar.gz | |
| grep -Fx \ | |
| 'export CATTLE_AGENT_PACKAGE_HOST_API_URL=/usr/share/cattle/artifacts/host-api-0.38.4.tar.gz' \ | |
| "$rootfs/usr/share/cattle/env_vars" >/dev/null | |
| cp "$rootfs/usr/share/cattle/artifacts/host-api-0.38.4.tar.gz" \ | |
| "$evidence/host-api-0.38.4.tar.gz" | |
| bash source/scripts/check-server-host-api-package.sh \ | |
| "$evidence/host-api-0.38.4.tar.gz" | | |
| tee "$evidence/host-api-package-check.txt" | |
| printf 'SERVER_CANDIDATE_SMOKE_OK release=%s first_attempts=%s restart_attempts=%s catalog_attempts=%s first_contract_attempts=%s restart_contract_attempts=%s http=200 body=pong public_origin=https://stack.example.test private_api_cache=no-store\n' \ | |
| "$RELEASE_TAG" "$first_attempts" "$restart_attempts" "$catalog_attempts" \ | |
| "$first_contract_attempts" "$restart_contract_attempts" | | |
| tee "$evidence/candidate-smoke.txt" | |
| cleanup_candidate | |
| trap - EXIT | |
| - name: Scan the final merged runtime and create SBOM | |
| shell: bash | |
| run: | | |
| set -Eeuo pipefail | |
| evidence="$RUNNER_TEMP/server-evidence" | |
| mkdir -p "$RUNNER_TEMP/trivy-cache" | |
| docker pull "$TRIVY_IMAGE" >/dev/null | |
| docker image inspect "$TRIVY_IMAGE" --format '{{json .RepoDigests}}' | | |
| jq -e --arg expected \ | |
| 'ghcr.io/aquasecurity/trivy@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969' \ | |
| 'index($expected) != null' >/dev/null | |
| cp source/server/security/openvex.json "$evidence/server.openvex.json" | |
| cp source/server/security/vendor-pending.json \ | |
| "$evidence/server.vendor-pending.json" | |
| docker run --rm \ | |
| -v "$RUNNER_TEMP/server-rootfs:/rootfs:ro" \ | |
| -v "$evidence:/evidence" \ | |
| -v "$RUNNER_TEMP/trivy-cache:/root/.cache/trivy" \ | |
| "$TRIVY_IMAGE" rootfs \ | |
| --scanners vuln,secret \ | |
| --format json \ | |
| --output /evidence/server-security-scan-raw.json \ | |
| /rootfs | |
| jq -e '.SchemaVersion == 2 and (.Results | type == "array")' \ | |
| "$evidence/server-security-scan-raw.json" >/dev/null | |
| jq -r ' | |
| .Results[]? | .Target as $target | .Vulnerabilities[]? | |
| | [.Severity, .VulnerabilityID, .PkgName, .InstalledVersion, | |
| (.FixedVersion // ""), ($target // "")] | @tsv | |
| ' "$evidence/server-security-scan-raw.json" | LC_ALL=C sort -u \ | |
| >"$evidence/server-vulnerabilities-raw.tsv" | |
| jq -r ' | |
| .Results[]? | .Target as $target | .Secrets[]? | |
| | [.RuleID, .Category, .Severity, (.Title // ""), ($target // "")] | |
| | @tsv | |
| ' "$evidence/server-security-scan-raw.json" | LC_ALL=C sort -u \ | |
| >"$evidence/server-secrets.tsv" | |
| test ! -s "$evidence/server-secrets.tsv" | |
| docker run --rm \ | |
| -v "$RUNNER_TEMP/server-rootfs:/rootfs:ro" \ | |
| -v "$evidence:/evidence" \ | |
| -v "$RUNNER_TEMP/trivy-cache:/root/.cache/trivy" \ | |
| "$TRIVY_IMAGE" rootfs \ | |
| --scanners vuln \ | |
| --skip-vex-repo-update \ | |
| --vex /evidence/server.openvex.json \ | |
| --format json \ | |
| --output /evidence/server-security-scan.json \ | |
| /rootfs | |
| jq -e '.SchemaVersion == 2 and (.Results | type == "array")' \ | |
| "$evidence/server-security-scan.json" >/dev/null | |
| jq -r ' | |
| .Results[]? | .Target as $target | .Vulnerabilities[]? | |
| | [.Severity, .VulnerabilityID, .PkgName, .InstalledVersion, | |
| (.FixedVersion // ""), ($target // "")] | @tsv | |
| ' "$evidence/server-security-scan.json" | LC_ALL=C sort -u \ | |
| >"$evidence/server-vulnerabilities-unresolved.tsv" | |
| bash source/scripts/validate-vendor-pending-findings.sh \ | |
| "$evidence/server.vendor-pending.json" \ | |
| "$evidence/server-vulnerabilities-unresolved.tsv" \ | |
| "$RELEASE_TAG" | tee "$evidence/vendor-pending-summary.txt" | |
| docker run --rm \ | |
| -v "$RUNNER_TEMP/server-rootfs:/rootfs:ro" \ | |
| -v "$evidence:/evidence" \ | |
| -v "$RUNNER_TEMP/trivy-cache:/root/.cache/trivy" \ | |
| "$TRIVY_IMAGE" rootfs \ | |
| --format cyclonedx \ | |
| --output /evidence/server.cdx.json \ | |
| /rootfs | |
| jq -e ' | |
| .bomFormat == "CycloneDX" | |
| and ((.components | type) == "array") | |
| and ((.components | length) > 0) | |
| ' "$evidence/server.cdx.json" >/dev/null | |
| raw_count="$(wc -l <"$evidence/server-vulnerabilities-raw.tsv")" | |
| vendor_pending_count="$(wc -l <"$evidence/server-vulnerabilities-unresolved.tsv" | tr -d ' ')" | |
| printf 'SERVER_SECURITY_GATE_OK untracked=0 vendor_pending=%s critical_high=0 fixed_available=0 secrets=0 raw_findings=%s vex=openvex-0.2.0 scan_target=merged-rootfs trivy=0.74.0\n' \ | |
| "$vendor_pending_count" "$raw_count" | | |
| tee "$evidence/security-summary.txt" | |
| - name: Publish semantic image | |
| id: publish | |
| shell: bash | |
| env: | |
| REGISTRY_TOKEN: ${{ github.token }} | |
| run: | | |
| set -Eeuo pipefail | |
| printf '%s' "$REGISTRY_TOKEN" | | |
| docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin >/dev/null | |
| docker tag "$CANDIDATE_IMAGE" "$TARGET_IMAGE" | |
| docker push "$TARGET_IMAGE" | tee "$RUNNER_TEMP/server-evidence/push.log" | |
| digest="$(docker buildx imagetools inspect "$TARGET_IMAGE" \ | |
| --format '{{.Manifest.Digest}}')" | |
| [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| digest_sha256="${digest#sha256:}" | |
| image_purl="pkg:oci/server@sha256%3A${digest_sha256}?repository_url=ghcr.io%2Fpasturestack%2Fserver&tag=${RELEASE_TAG}" | |
| sbom="$RUNNER_TEMP/server-evidence/server.cdx.json" | |
| sbom_tmp="$RUNNER_TEMP/server-evidence/server.cdx.tmp.json" | |
| jq \ | |
| --arg name 'ghcr.io/pasturestack/server' \ | |
| --arg release "$RELEASE_TAG" \ | |
| --arg digest "$digest_sha256" \ | |
| --arg purl "$image_purl" \ | |
| --arg source "$SOURCE_SHA" ' | |
| (.metadata.component["bom-ref"] // "") as $old_ref | |
| | .metadata = (.metadata // {}) | |
| | .metadata.component = { | |
| "type": "container", | |
| "bom-ref": $purl, | |
| "name": $name, | |
| "version": $release, | |
| "purl": $purl, | |
| "hashes": [{"alg": "SHA-256", "content": $digest}], | |
| "properties": [ | |
| { | |
| "name": "org.opencontainers.image.revision", | |
| "value": $source | |
| }, | |
| { | |
| "name": "org.opencontainers.image.ref.name", | |
| "value": ($name + ":" + $release) | |
| } | |
| ] | |
| } | |
| | if (.dependencies | type) == "array" then | |
| .dependencies |= map( | |
| .ref = (if .ref == $old_ref then $purl else .ref end) | |
| | if (.dependsOn | type) == "array" then | |
| .dependsOn |= map(if . == $old_ref then $purl else . end) | |
| else | |
| . | |
| end | |
| ) | |
| else | |
| . | |
| end | |
| ' "$sbom" >"$sbom_tmp" | |
| mv "$sbom_tmp" "$sbom" | |
| jq -e \ | |
| --arg release "$RELEASE_TAG" \ | |
| --arg digest "$digest_sha256" \ | |
| --arg purl "$image_purl" ' | |
| .bomFormat == "CycloneDX" | |
| and .metadata.component.type == "container" | |
| and .metadata.component.name == "ghcr.io/pasturestack/server" | |
| and .metadata.component.version == $release | |
| and .metadata.component.purl == $purl | |
| and .metadata.component["bom-ref"] == $purl | |
| and (.metadata.component.hashes | length) == 1 | |
| and .metadata.component.hashes[0].alg == "SHA-256" | |
| and .metadata.component.hashes[0].content == $digest | |
| and ([.dependencies[]? | select(.ref == $purl)] | length) == 1 | |
| ' "$sbom" >/dev/null | |
| printf 'SERVER_SBOM_IDENTITY_OK release=%s image=%s digest=%s purl=%s\n' \ | |
| "$RELEASE_TAG" "$TARGET_IMAGE" "$digest" "$image_purl" | | |
| tee "$RUNNER_TEMP/server-evidence/sbom-identity.txt" | |
| docker buildx imagetools inspect --raw "$TARGET_IMAGE" | | |
| jq -e \ | |
| '.schemaVersion == 2 and (.config.digest | test("^sha256:[0-9a-f]{64}$")) and (.layers | length) == 1' \ | |
| >"$RUNNER_TEMP/server-evidence/registry-layer-verification.json" | |
| printf 'SERVER_REGISTRY_LAYER_OK release=%s layers=1\n' \ | |
| "$RELEASE_TAG" | | |
| tee "$RUNNER_TEMP/server-evidence/registry-layer-summary.txt" | |
| docker buildx imagetools inspect "$TARGET_IMAGE" \ | |
| >"$RUNNER_TEMP/server-evidence/image-inspect.txt" | |
| printf 'SERVER_IMAGE_PUBLISHED release=%s source=%s reference=%s digest=%s\n' \ | |
| "$RELEASE_TAG" "$SOURCE_SHA" "$TARGET_IMAGE" "$digest" | | |
| tee "$RUNNER_TEMP/server-evidence/published.txt" | |
| printf 'digest=%s\n' "$digest" >>"$GITHUB_OUTPUT" | |
| - name: Attest image provenance | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-name: ghcr.io/pasturestack/server | |
| subject-digest: ${{ steps.publish.outputs.digest }} | |
| push-to-registry: true | |
| - name: Attest image SBOM | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-name: ghcr.io/pasturestack/server | |
| subject-digest: ${{ steps.publish.outputs.digest }} | |
| sbom-path: ${{ runner.temp }}/server-evidence/server.cdx.json | |
| push-to-registry: true | |
| - name: Create immutable GitHub Release | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -Eeuo pipefail | |
| evidence="$RUNNER_TEMP/server-evidence" | |
| release_notes="source/docs/releases/server-${RELEASE_TAG#v}.md" | |
| cp "$release_notes" "$evidence/release-notes.md" | |
| ( | |
| cd "$evidence" | |
| sha256sum \ | |
| build.log \ | |
| candidate.log \ | |
| candidate-smoke.txt \ | |
| host-api-0.38.4.tar.gz \ | |
| host-api-package-check.txt \ | |
| image-inspect.txt \ | |
| mfa-policy-api-smoke.txt \ | |
| published.txt \ | |
| push.log \ | |
| release-notes.md \ | |
| reverse-proxy-after-restart.txt \ | |
| reverse-proxy-before-restart.txt \ | |
| sbom-identity.txt \ | |
| security-summary.txt \ | |
| server.openvex.json \ | |
| server.vendor-pending.json \ | |
| server-security-scan-raw.json \ | |
| server-security-scan.json \ | |
| server-vulnerabilities-raw.tsv \ | |
| server-vulnerabilities-unresolved.tsv \ | |
| server.cdx.json \ | |
| vendor-pending-summary.txt \ | |
| >SHA256SUMS | |
| ) | |
| gh release create "$RELEASE_TAG" \ | |
| --repo "$GITHUB_REPOSITORY" \ | |
| --target "$SOURCE_SHA" \ | |
| --title "PastureStack Server $RELEASE_TAG" \ | |
| --notes-file "$evidence/release-notes.md" \ | |
| "$evidence/SHA256SUMS" \ | |
| "$evidence/build.log" \ | |
| "$evidence/candidate.log" \ | |
| "$evidence/candidate-smoke.txt" \ | |
| "$evidence/host-api-0.38.4.tar.gz" \ | |
| "$evidence/host-api-package-check.txt" \ | |
| "$evidence/image-inspect.txt" \ | |
| "$evidence/mfa-policy-api-smoke.txt" \ | |
| "$evidence/published.txt" \ | |
| "$evidence/push.log" \ | |
| "$evidence/release-notes.md" \ | |
| "$evidence/reverse-proxy-after-restart.txt" \ | |
| "$evidence/reverse-proxy-before-restart.txt" \ | |
| "$evidence/sbom-identity.txt" \ | |
| "$evidence/security-summary.txt" \ | |
| "$evidence/server.openvex.json" \ | |
| "$evidence/server.vendor-pending.json" \ | |
| "$evidence/server-security-scan-raw.json" \ | |
| "$evidence/server-security-scan.json" \ | |
| "$evidence/server-vulnerabilities-raw.tsv" \ | |
| "$evidence/server-vulnerabilities-unresolved.tsv" \ | |
| "$evidence/server.cdx.json" \ | |
| "$evidence/vendor-pending-summary.txt" | |
| { | |
| printf 'SHA256SUMS\tsha256:%s\n' \ | |
| "$(sha256sum "$evidence/SHA256SUMS" | awk '{print $1}')" | |
| awk '{ print $2 "\tsha256:" $1 }' "$evidence/SHA256SUMS" | |
| } | LC_ALL=C sort >"$evidence/release-assets-expected.tsv" | |
| gh release view "$RELEASE_TAG" \ | |
| --repo "$GITHUB_REPOSITORY" \ | |
| --json assets \ | |
| --jq '.assets[] | [.name, .digest] | @tsv' | | |
| LC_ALL=C sort >"$evidence/release-assets-published.tsv" | |
| diff -u \ | |
| "$evidence/release-assets-expected.tsv" \ | |
| "$evidence/release-assets-published.tsv" | |
| - name: Retain workflow evidence | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: server-${{ inputs.release_tag }}-publication-${{ github.run_id }} | |
| path: ${{ runner.temp }}/server-evidence | |
| if-no-files-found: warn | |
| retention-days: 30 | |
| compression-level: 9 | |
| - name: Clean run-owned resources | |
| if: always() | |
| shell: bash | |
| run: | | |
| docker logout ghcr.io >/dev/null 2>&1 || true | |
| docker rm -f "$CANDIDATE_NAME" >/dev/null 2>&1 || true | |
| docker volume rm "$CATTLE_VOLUME" "$MYSQL_VOLUME" "$MYSQL_LOG_VOLUME" \ | |
| >/dev/null 2>&1 || true | |
| docker image rm "$TARGET_IMAGE" "$CANDIDATE_IMAGE" "$TRIVY_IMAGE" \ | |
| >/dev/null 2>&1 || true | |
| sudo rm -rf \ | |
| "$RUNNER_TEMP/server-rootfs" \ | |
| "$RUNNER_TEMP/server-rootfs.tar" \ | |
| "$RUNNER_TEMP/trivy-cache" \ | |
| "$RUNNER_TEMP/daemon-current.json" \ | |
| "$RUNNER_TEMP/daemon-containerd.json" \ | |
| "$RUNNER_TEMP/ping-body" |