Skip to content

Publish Current Server #207

Publish Current Server

Publish Current Server #207

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"