Add verifiable GHCR release workflow for tagged releases and dry runs #1
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: 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 | |
| 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 | |
| # Oldest supported runner image, so release binaries do not pick up a newer | |
| # glibc than the distros users actually run. | |
| runs-on: ubuntu-22.04 | |
| permissions: | |
| contents: read | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - name: linux-amd64 | |
| build_os_name: linux | |
| - name: linux-arm64 | |
| build_os_name: arm64 | |
| 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.build_os_name == 'arm64' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross | |
| - name: Build amd64 binaries | |
| if: matrix.build_os_name == 'linux' | |
| run: go run build/ci.go install | |
| - name: Build arm64 binaries | |
| if: matrix.build_os_name == 'arm64' | |
| run: go run build/ci.go install -arch arm64 -cc aarch64-linux-gnu-gcc | |
| - 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/* |