Skip to content

Add verifiable GHCR release workflow for tagged releases and dry runs #6

Add verifiable GHCR release workflow for tagged releases and dry runs

Add verifiable GHCR release workflow for tagged releases and dry runs #6

Workflow file for this run

name: Release GHCR artifacts
on:
push:
tags:
- 'v*'
# Exercise the release path whenever the release path itself changes, so a
# broken release is caught on the PR rather than at tag time. Always a dry
# run: nothing is pushed for a pull_request event.
pull_request:
paths:
- '.github/workflows/release-ghcr.yml'
- 'Dockerfile'
- 'build/archive-signing.sh'
- 'build/ci.go'
workflow_dispatch:
inputs:
dry_run:
description: Build and attest without pushing release images
required: false
default: true
type: boolean
# Least privilege by default. Jobs opt in to the scopes they actually need.
permissions:
contents: read
# archive-signing.sh is bash, including on the Windows runner.
defaults:
run:
shell: bash
env:
IMAGE_NAME: ghcr.io/ethereumclassic/core-geth
GO_VERSION: '1.21'
WORKFLOW_IDENTITY_BASE: https://github.com/ethereumclassic/core-geth/.github/workflows/release-ghcr.yml
jobs:
prepare:
name: Prepare release metadata
runs-on: ubuntu-latest
outputs:
dry_run: ${{ steps.vars.outputs.dry_run }}
should_push: ${{ steps.vars.outputs.should_push }}
should_attest: ${{ steps.vars.outputs.should_attest }}
version: ${{ steps.vars.outputs.version }}
image_tags: ${{ steps.vars.outputs.image_tags }}
is_prerelease: ${{ steps.vars.outputs.is_prerelease }}
workflow_ref: ${{ steps.vars.outputs.workflow_ref }}
signer_identity: ${{ steps.vars.outputs.signer_identity }}
steps:
- name: Compute metadata
id: vars
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
SHA: ${{ github.sha }}
REPOSITORY: ${{ github.repository }}
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
IMAGE_NAME: ${{ env.IMAGE_NAME }}
WORKFLOW_IDENTITY_BASE: ${{ env.WORKFLOW_IDENTITY_BASE }}
run: |
set -euo pipefail
# Publishing requires a tag ref. Everything else is a dry run,
# including workflow_dispatch on a branch and any pull_request.
should_push=false
if [[ "$REF_TYPE" == "tag" ]]; then
case "$EVENT_NAME" in
push) should_push=true ;;
workflow_dispatch)
[[ "$INPUT_DRY_RUN" == "true" ]] || should_push=true ;;
esac
fi
dry_run=true
if [[ "$should_push" == "true" ]]; then
dry_run=false
fi
# Fork pull requests get a read-only token and no OIDC, so
# attestation cannot run there. Still build; just skip signing.
should_attest=true
if [[ "$EVENT_NAME" == "pull_request" && "$PR_HEAD_REPO" != "$REPOSITORY" ]]; then
should_attest=false
fi
version="dry-run-${SHA::7}"
if [[ "$REF_TYPE" == "tag" ]]; then
version="$REF_NAME"
fi
# GITHUB_REF is the ref the OIDC token actually carries, so derive
# the signer identity from it instead of reconstructing it.
workflow_ref="$GITHUB_REF"
# Release tags are plain (v1.12.20); anything carrying a suffix
# (v1.13.0-rc1) is a prerelease and must not move :latest.
is_prerelease=false
if [[ "$version" == *-* && "$version" != dry-run-* ]]; then
is_prerelease=true
fi
{
echo "dry_run=$dry_run"
echo "should_push=$should_push"
echo "should_attest=$should_attest"
echo "version=$version"
echo "is_prerelease=$is_prerelease"
echo "workflow_ref=$workflow_ref"
echo "signer_identity=${WORKFLOW_IDENTITY_BASE}@${workflow_ref}"
echo 'image_tags<<EOF'
echo "${IMAGE_NAME}:${version}"
if [[ "$should_push" == "true" && "$is_prerelease" != "true" ]]; then
echo "${IMAGE_NAME}:latest"
fi
echo 'EOF'
} >> "$GITHUB_OUTPUT"
{
echo "### Release mode"
echo
echo "| field | value |"
echo "| --- | --- |"
echo "| event | \`$EVENT_NAME\` |"
echo "| version | \`$version\` |"
echo "| publishes to GHCR | \`$should_push\` |"
echo "| attests artifacts | \`$should_attest\` |"
echo "| prerelease | \`$is_prerelease\` |"
echo "| signer identity | \`${WORKFLOW_IDENTITY_BASE}@${workflow_ref}\` |"
} >> "$GITHUB_STEP_SUMMARY"
build-image:
name: Build image (${{ matrix.slug }})
needs: prepare
# Each platform builds on a native runner. Emulating an arm64 cgo build of
# go-ethereum under QEMU takes hours and risks the job timeout.
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- slug: linux-amd64
platform: linux/amd64
runner: ubuntu-latest
- slug: linux-arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Log in to GHCR
if: needs.prepare.outputs.should_push == 'true'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: push
if: needs.prepare.outputs.should_push == 'true'
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
with:
context: .
file: ./Dockerfile
platforms: ${{ matrix.platform }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.version=${{ needs.prepare.outputs.version }}
build-args: |
COMMIT=${{ github.sha }}
VERSION=${{ needs.prepare.outputs.version }}
BUILDNUM=${{ github.run_number }}
provenance: false
sbom: false
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
# The buildx OCI exporter will not create the destination directory.
- name: Prepare dry-run export directory
if: needs.prepare.outputs.should_push != 'true'
run: mkdir -p /tmp/oci
- name: Build dry-run image
id: dryrun
if: needs.prepare.outputs.should_push != 'true'
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
with:
context: .
file: ./Dockerfile
platforms: ${{ matrix.platform }}
tags: ${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.version=${{ needs.prepare.outputs.version }}
build-args: |
COMMIT=${{ github.sha }}
VERSION=${{ needs.prepare.outputs.version }}
BUILDNUM=${{ github.run_number }}
provenance: false
sbom: false
outputs: type=oci,dest=/tmp/oci/core-geth-image-${{ matrix.slug }}-${{ needs.prepare.outputs.version }}.tar
- name: Record image digest
env:
PUSH_DIGEST: ${{ steps.push.outputs.digest }}
DRYRUN_DIGEST: ${{ steps.dryrun.outputs.digest }}
SLUG: ${{ matrix.slug }}
PLATFORM: ${{ matrix.platform }}
run: |
set -euo pipefail
digest="${PUSH_DIGEST:-$DRYRUN_DIGEST}"
if [[ -z "$digest" ]]; then
echo "::error::buildx reported no image digest for ${PLATFORM}" >&2
exit 1
fi
mkdir -p /tmp/digests
printf '%s' "$digest" > "/tmp/digests/${SLUG}"
echo "${PLATFORM} -> ${digest}"
- name: Upload image digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: image-digest-${{ matrix.slug }}
path: /tmp/digests/${{ matrix.slug }}
if-no-files-found: error
retention-days: 1
# A dry run nobody can inspect is not a rehearsal. Publish the OCI bundle
# so reviewers can load and verify exactly what was built.
- name: Upload dry-run image bundle
if: needs.prepare.outputs.should_push != 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: image-oci-${{ matrix.slug }}
path: /tmp/oci/*.tar
if-no-files-found: error
publish-image:
name: Publish and attest container image
needs:
- prepare
- build-image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Download image digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
pattern: image-digest-*
merge-multiple: true
path: /tmp/digests
- name: Download dry-run image bundles
if: needs.prepare.outputs.should_push != 'true'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
pattern: image-oci-*
merge-multiple: true
path: /tmp/oci
- name: Set up Docker Buildx
if: needs.prepare.outputs.should_push == 'true'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Log in to GHCR
if: needs.prepare.outputs.should_push == 'true'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install Cosign
if: needs.prepare.outputs.should_push == 'true'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6
- name: Create multi-arch manifest list
id: manifest
if: needs.prepare.outputs.should_push == 'true'
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
IMAGE_TAGS: ${{ needs.prepare.outputs.image_tags }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
tag_args=()
while IFS= read -r tag; do
[[ -n "$tag" ]] && tag_args+=(--tag "$tag")
done <<< "$IMAGE_TAGS"
digest_refs=()
for digest_file in /tmp/digests/*; do
digest_refs+=("${IMAGE_NAME}@$(cat "$digest_file")")
done
if [[ ${#digest_refs[@]} -eq 0 ]]; then
echo "::error::no per-platform digests were produced" >&2
exit 1
fi
docker buildx imagetools create "${tag_args[@]}" "${digest_refs[@]}"
index_digest="$(docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}" \
--format '{{json .Manifest}}' | jq -r '.digest')"
if [[ -z "$index_digest" || "$index_digest" == "null" ]]; then
echo "::error::could not resolve manifest list digest" >&2
exit 1
fi
echo "index_digest=${index_digest}" >> "$GITHUB_OUTPUT"
- name: Sign image
if: needs.prepare.outputs.should_push == 'true'
env:
DIGEST: ${{ steps.manifest.outputs.index_digest }}
IMAGE_NAME: ${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
# --recursive also signs each per-platform manifest under the index.
cosign sign --yes --recursive "${IMAGE_NAME}@${DIGEST}"
- name: Attest published image provenance
if: needs.prepare.outputs.should_push == 'true'
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be
with:
subject-name: ${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.manifest.outputs.index_digest }}
push-to-registry: true
# Dry runs have no registry image to reference, so the OCI bundles
# themselves are the attested subjects.
- name: Attest dry-run image bundles
if: needs.prepare.outputs.should_push != 'true' && needs.prepare.outputs.should_attest == 'true'
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be
with:
subject-path: /tmp/oci/*.tar
build-binaries:
name: Build release binaries (${{ matrix.name }})
needs: prepare
runs-on: ${{ matrix.runner }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
# ubuntu-22.04 is the oldest available image, so Linux release
# archives do not link a newer glibc than the distros users run.
- name: linux-amd64
runner: ubuntu-22.04
build_os_name: linux
- name: linux-arm64
runner: ubuntu-22.04
build_os_name: arm64
goarch: arm64
cc: aarch64-linux-gnu-gcc
# The arch is in the name deliberately: the old bare "osx" archive
# silently changed meaning when macos-latest moved to Apple
# Silicon. There is no darwin/amd64 archive — blst v0.3.11 cannot
# be built for x86_64 macOS on current runner images. See the
# "macOS" note in RELEASING.md.
- name: osx-arm64
runner: macos-latest
build_os_name: osx-arm64
# blst v0.3.11 typedefs `bool`, which is a reserved keyword under
# C23 — the default for the MinGW GCC on current Windows images.
# ci.go hardcodes CGO_CFLAGS, so the standard has to come in via CC.
- name: win64
runner: windows-latest
build_os_name: win64
cc: gcc -std=gnu17
steps:
- name: Checkout source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
with:
go-version: ${{ env.GO_VERSION }}
- name: Install cross-compiler
if: matrix.name == 'linux-arm64'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross
- name: Build binaries
env:
TARGET_ARCH: ${{ matrix.goarch }}
TARGET_CC: ${{ matrix.cc }}
run: |
set -euo pipefail
args=(build/ci.go install)
if [[ -n "${TARGET_ARCH}" ]]; then
args+=(-arch "${TARGET_ARCH}")
fi
if [[ -n "${TARGET_CC}" ]]; then
args+=(-cc "${TARGET_CC}")
fi
echo "go run ${args[*]}"
go run "${args[@]}"
- name: Package release archives
env:
BUILD_OS_NAME: ${{ matrix.build_os_name }}
ARCHIVE_VERSION: ${{ needs.prepare.outputs.version }}
run: ./build/archive-signing.sh
- name: Collect artifacts
env:
BUILD_OS_NAME: ${{ matrix.build_os_name }}
ARCHIVE_VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
mkdir -p dist
mv core-geth*-"${BUILD_OS_NAME}"-"${ARCHIVE_VERSION}".zip* dist/
ls -l dist/
- name: Upload artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: release-binaries-${{ matrix.name }}
path: dist/*
if-no-files-found: error
attest-binaries:
name: Attest release binaries and checksums
needs:
- prepare
- build-binaries
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Download binary artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
pattern: release-binaries-*
merge-multiple: true
path: release-artifacts
- name: Create consolidated checksums
working-directory: release-artifacts
env:
ARCHIVE_VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
cat ./*.sha256 | sort > "SHA256SUMS-${ARCHIVE_VERSION}.txt"
sha256sum -c "SHA256SUMS-${ARCHIVE_VERSION}.txt"
cat "SHA256SUMS-${ARCHIVE_VERSION}.txt"
- name: Upload combined release artifact bundle
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: release-binaries-${{ needs.prepare.outputs.version }}
path: release-artifacts/*
if-no-files-found: error
- name: Attest binary provenance
if: needs.prepare.outputs.should_attest == 'true'
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be
with:
subject-path: release-artifacts/*
publish-release:
name: Publish draft GitHub Release
# Depends on both attest jobs, so a release is only ever drafted once the
# complete verifiable set — signed image and attested archives — exists.
needs:
- prepare
- publish-image
- attest-binaries
if: needs.prepare.outputs.should_push == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download attested release artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: release-binaries-${{ needs.prepare.outputs.version }}
path: release-artifacts
- name: Create draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
SIGNER_IDENTITY: ${{ needs.prepare.outputs.signer_identity }}
IMAGE_NAME: ${{ env.IMAGE_NAME }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
args=(--draft --title "$VERSION" --generate-notes)
if [[ "$IS_PRERELEASE" == "true" ]]; then
args+=(--prerelease)
fi
gh release create "$VERSION" "${args[@]}" release-artifacts/*
# Prepend verification instructions to the generated changelog, so
# the commands are on the page people actually download from.
{
cat <<EOF
## Verifying this release
Every archive below, the \`SHA256SUMS\` file, and the container image
carry GitHub build provenance produced by this workflow alone.
\`\`\`sh
gh attestation verify core-geth-linux-${VERSION}.zip \\
--repo ${REPOSITORY} \\
--cert-identity ${SIGNER_IDENTITY} \\
--cert-oidc-issuer https://token.actions.githubusercontent.com
\`\`\`
Container image: \`${IMAGE_NAME}:${VERSION}\`
See [RELEASING.md](../blob/master/RELEASING.md) for container image
signature verification and the full trust model.
---
EOF
gh release view "$VERSION" --json body -q .body
} > notes.md
gh release edit "$VERSION" --notes-file notes.md