Add verifiable GHCR release workflow for tagged releases and dry runs #6
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 | |
| # 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 |