From 7a20b27113b376981f0755fc2d4306593e239c1b Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Mon, 26 Oct 2026 23:09:49 -0700 Subject: [PATCH 1/2] Simplify releases into a single workflow run Replace dispatch and workflow_run handoffs with one manual Release workflow pinned to the dispatch commit. Require main and live admin authorization, then gate publication on CI and packaging. Share packaging and Docker workflows with Build, preserving automatic main images and packaging-only PR/manual runs. Support same-run retries by verifying tags and published assets without replacing them. Add release-control tests and update release and retry documentation. --- .github/scripts/test-release.sh | 277 +++++++++++++++++++++++++++ .github/workflows/build.yml | 43 +++++ .github/workflows/ci.yml | 5 +- .github/workflows/docker-publish.yml | 89 ++++----- .github/workflows/manual-release.yml | 50 ----- .github/workflows/package.yml | 183 ++++++++++++++++++ .github/workflows/release.yml | 257 +++++-------------------- docs/development.md | 33 ++-- release.sh | 126 +++++++++++- 9 files changed, 726 insertions(+), 337 deletions(-) create mode 100644 .github/scripts/test-release.sh create mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/manual-release.yml create mode 100644 .github/workflows/package.yml diff --git a/.github/scripts/test-release.sh b/.github/scripts/test-release.sh new file mode 100644 index 0000000..3d7c4f1 --- /dev/null +++ b/.github/scripts/test-release.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +# Exercise real Git tags and mock only GitHub; no network or publishing credentials. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null +export GH_REPO=owner/repo GITHUB_REPOSITORY=owner/repo +export GITHUB_REF=refs/heads/main GITHUB_ACTOR=admin GITHUB_TRIGGERING_ACTOR=admin +export STATE="$work/state" +mkdir -p "$work/bin" "$STATE" + +fail() { echo "$*" >&2; exit 1; } +reject() { + if "$@" > "$work/rejected.log" 2>&1; then + fail "Unexpected success: $*" + fi +} +git init -q --bare "$work/remote.git" +git init -q -b main "$work/repo" +cd "$work/repo" +git config user.name Test +git config user.email test@example.com +git remote add origin "$work/remote.git" +git commit -q --allow-empty -m initial +echo 1.2.3 > VERSION +git add VERSION +git commit -qm version +RELEASE_COMMIT=$(git rev-parse HEAD) +export RELEASE_COMMIT RELEASE_TAG=v1.2.3 +release() { bash "$ROOT/release.sh" "$@"; } + +# Only the workflow commands are supported; legacy tag-only calls must fail. +reject release +reject release origin +reject release --allow-non-main +reject release --unknown +reject release --authorize extra +reject release --check-tag extra +reject release --publish extra +test -z "$(git tag --list)" +test -z "$(git --git-dir="$work/remote.git" tag --list)" + +# The fake requires pagination flags and returns two pages, including a distractor. +cat > "$work/bin/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +case "$*" in + api\ */collaborators/*/permission\ --jq\ .permission) + if [[ "$2" == */writer/permission ]]; then echo write + elif [[ -f "$STATE/permission-error" ]]; then exit 1 + else echo admin; fi ;; + api\ */commits/*\ --jq\ .sha) cat "$STATE/commit" ;; + api\ --paginate\ --slurp\ */releases\?per_page=100) + [[ ! -f "$STATE/api-error" ]] || exit 1 + jq -s '[[{id: 8, tag_name: "v0.0.0", draft: false}], .]' "$STATE/release" ;; + api\ --paginate\ --slurp\ */assets\?per_page=100) + jq -s '[[], .]' "$STATE/assets" ;; + "release create $RELEASE_TAG --repo $GH_REPO --verify-tag --draft --generate-notes --title $RELEASE_TAG") + echo create >> "$STATE/writes" + echo '{"id":7,"tag_name":"v1.2.3","draft":true,"prerelease":false}' > "$STATE/release" ;; + "release upload $RELEASE_TAG --repo $GH_REPO --clobber "*) + jq -e .draft "$STATE/release" > /dev/null + echo upload >> "$STATE/writes" + while [[ "$1" != --clobber ]]; do shift; done + shift + : > "$STATE/assets" + for file; do + hash=$(sha256sum "$file") + jq -nc --arg name "${file##*/}" --arg digest "sha256:${hash%% *}" \ + '{name: $name, digest: $digest, state: "uploaded"}' >> "$STATE/assets" + if [[ -f "$STATE/interrupt" ]]; then rm "$STATE/interrupt"; exit 1; fi + done + if [[ -f "$STATE/bad-upload" ]]; then + jq -c --arg field "$(cat "$STATE/bad-upload")" '.[$field] = null' "$STATE/assets" > "$STATE/changed" + mv "$STATE/changed" "$STATE/assets" + fi + if [[ -f "$STATE/move-tag" ]]; then echo moved > "$STATE/commit"; fi ;; + "release edit $RELEASE_TAG --repo $GH_REPO --draft=false --prerelease=false") + echo edit >> "$STATE/writes" + jq '.draft = false | .prerelease = false' "$STATE/release" > "$STATE/changed" + mv "$STATE/changed" "$STATE/release" ;; + *) echo "Unexpected gh arguments: $*" >&2; exit 1 ;; +esac +GH +chmod +x "$work/bin/gh" +export PATH="$work/bin:$PATH" + +# Both the original actor and the retrying actor must still be administrators. +bash "$ROOT/release.sh" --authorize +reject env GITHUB_REF=refs/heads/other bash "$ROOT/release.sh" --authorize +grep -Fxq 'Release must be run from main.' "$work/rejected.log" +reject env GITHUB_ACTOR=writer bash "$ROOT/release.sh" --authorize +reject env GITHUB_TRIGGERING_ACTOR=writer bash "$ROOT/release.sh" --authorize +touch "$STATE/permission-error" +reject bash "$ROOT/release.sh" --authorize +rm "$STATE/permission-error" + +reset_github() { + : > "$STATE/release" + : > "$STATE/assets" + : > "$STATE/writes" + echo "$RELEASE_COMMIT" > "$STATE/commit" +} +mkdir dist +for arch in amd64 arm64; do + name="livepeer-$RELEASE_TAG-linux-$arch.tar.gz" + echo "tested $arch" > "dist/$name" + (cd dist && sha256sum "$name" > "$name.sha256") +done +reset_github +# The preflight validates the remote tag without creating local or remote state. +release --check-tag +test -z "$(git tag --list)" +test -z "$(git --git-dir="$work/remote.git" tag --list)" +test ! -s "$STATE/writes" + +# Mismatched workflow metadata must fail before creating tags or publishing. +reject env RELEASE_TAG=v1.2.4 bash "$ROOT/release.sh" --publish +reject env RELEASE_COMMIT="$(git rev-parse HEAD^)" bash "$ROOT/release.sh" --publish +test -z "$(git tag --list)" +test -z "$(git --git-dir="$work/remote.git" tag --list)" +test ! -s "$STATE/writes" + +bundle="dist/livepeer-$RELEASE_TAG-linux-arm64.tar.gz" +cp "$bundle" "$work/original" +echo corrupt > "$bundle" +reject release --publish +rm "$bundle" +reject release --publish +test ! -s "$STATE/writes" +test -z "$(git --git-dir="$work/remote.git" tag --list)" +cp "$work/original" "$bundle" + +# A rejected push leaves no remote tag and must stop before GitHub publication. +printf '#!/bin/sh\nexit 1\n' > "$work/remote.git/hooks/pre-receive" +chmod +x "$work/remote.git/hooks/pre-receive" +reject release --publish +grep -Fq 'Push failed; checking whether the intended tag reached the remote.' "$work/rejected.log" +test -z "$(git --git-dir="$work/remote.git" tag --list)" +test ! -s "$STATE/writes" +rm "$work/remote.git/hooks/pre-receive" + +# Losing the response after a successful push is safe to recover from. +REAL_GIT=$(command -v git) +export REAL_GIT +cat > "$work/bin/git" <<'GIT' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == push ]]; then + "$REAL_GIT" "$@" + exit 1 +fi +exec "$REAL_GIT" "$@" +GIT +chmod +x "$work/bin/git" +release --publish > "$work/lost-response.log" 2>&1 +rm "$work/bin/git" +grep -Fq 'Push failed; checking whether the intended tag reached the remote.' "$work/lost-response.log" +test "$(git --git-dir="$work/remote.git" rev-parse 'v1.2.3^{commit}')" = "$RELEASE_COMMIT" +test "$(cat "$STATE/writes")" = $'create\nupload\nedit' +release --publish +test "$(cat "$STATE/writes")" = $'create\nupload\nedit' # Published retries write nothing. +cp "$STATE/assets" "$work/published" +cp "$STATE/release" "$work/published-release" + +# A tag shared by a draft and a published release cannot be selected safely. +jq -c '., (. + {id: 9, draft: true})' "$work/published-release" > "$STATE/release" +reject release --publish +grep -Fq 'Multiple releases match tag v1.2.3' "$work/rejected.log" +test "$(cat "$STATE/writes")" = $'create\nupload\nedit' + +# Matching assets do not make an already-published prerelease a stable release. +jq '.prerelease = true' "$work/published-release" > "$STATE/release" +reject release --publish +grep -Fq 'Published release v1.2.3 must have prerelease=false.' "$work/rejected.log" +test "$(cat "$STATE/writes")" = $'create\nupload\nedit' +jq -e '.draft == false and .prerelease == true' "$STATE/release" > /dev/null +cp "$work/published-release" "$STATE/release" + +# New, matching, conflicting, detached, and lightweight tags use a real remote. +release --check-tag +test "$(git --git-dir="$work/remote.git" rev-parse 'v1.2.3^{commit}')" = "$RELEASE_COMMIT" +git commit -q --allow-empty -m advance +reject release --check-tag +RELEASE_COMMIT=$(git rev-parse HEAD) reject release --publish +grep -Fq 'Remote tag v1.2.3 points to' "$work/rejected.log" +test "$(git --git-dir="$work/remote.git" rev-parse 'v1.2.3^{commit}')" = "$RELEASE_COMMIT" +git checkout -q --detach "$RELEASE_COMMIT" +release --publish +git push -q origin :refs/tags/v1.2.3 +release --publish # Resume after creating only the local annotated tag. +git push -q origin :refs/tags/v1.2.3 +git tag -d v1.2.3 > /dev/null +git tag v1.2.3 +release --publish +git checkout -q main +git push -q origin :refs/tags/v1.2.3 +RELEASE_COMMIT=$(git rev-parse HEAD) reject release --publish +grep -Fq 'Local tag v1.2.3 points to a different commit' "$work/rejected.log" +git tag -d v1.2.3 > /dev/null +git checkout -q --detach "$RELEASE_COMMIT" +git remote set-url origin "$work/missing" +reject release --check-tag +reject release --publish +test -z "$(git tag --list)" +git remote set-url origin "$work/remote.git" +echo 1.2.4 > VERSION +reject release --publish +git checkout -- VERSION +for version in 01.2.3 v1.2.3 1.2.3-rc1 1.2.3+build; do + echo "$version" > VERSION + git commit -qam invalid + reject release --check-tag + reject release --publish +done +git checkout -q --detach "$RELEASE_COMMIT" +release --check-tag +test -z "$(git tag --list)" +test -z "$(git --git-dir="$work/remote.git" tag --list)" +release --publish +test "$(cat "$STATE/writes")" = $'create\nupload\nedit' + +for failure in missing extra digest; do + cp "$work/published" "$STATE/assets" + case "$failure" in + missing) sed '1d' "$STATE/assets" > "$STATE/changed" ;; + extra) cat "$STATE/assets" "$STATE/assets" > "$STATE/changed" ;; + digest) jq -c '.digest = null' "$STATE/assets" > "$STATE/changed" ;; + esac + mv "$STATE/changed" "$STATE/assets" + reject release --publish + test "$(cat "$STATE/writes")" = $'create\nupload\nedit' +done + +reset_github +touch "$STATE/interrupt" +reject release --publish +jq -e .draft "$STATE/release" > /dev/null +release --publish +test "$(cat "$STATE/writes")" = $'create\nupload\nupload\nedit' + +for failure in digest state move-tag; do + reset_github + if [[ "$failure" == move-tag ]]; then + touch "$STATE/move-tag" + else + echo "$failure" > "$STATE/bad-upload" + fi + reject release --publish + jq -e .draft "$STATE/release" > /dev/null + test "$(cat "$STATE/writes")" = $'create\nupload' + rm -f "$STATE/bad-upload" "$STATE/move-tag" +done +reset_github +echo moved > "$STATE/commit" +reject release --publish +test ! -s "$STATE/writes" +reset_github +touch "$STATE/api-error" +reject release --publish +test ! -s "$STATE/writes" +rm "$STATE/api-error" + +# Publication needs only HEAD: a shallow checkout can create and push the tag. +git push -q origin "$RELEASE_COMMIT:refs/heads/main" :refs/tags/v1.2.3 +git clone -q --depth=1 --no-tags --branch main "file://$work/remote.git" "$work/shallow" +cp -R dist "$work/shallow/dist" +cd "$work/shallow" +git config user.name Test +git config user.email test@example.com +test "$(git rev-parse --is-shallow-repository)" = true +reset_github +release --publish +test "$(cat "$STATE/writes")" = $'create\nupload\nedit' +test "$(git --git-dir="$work/remote.git" rev-parse 'v1.2.3^{commit}')" = "$RELEASE_COMMIT" +echo 'Release tests passed: authorization, Git tags, bundle checks, draft recovery, and immutable assets.' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1675ddc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,43 @@ +name: Build + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + checks: + if: github.event_name == 'push' + uses: ./.github/workflows/ci.yml + + package: + if: >- + github.event_name != 'pull_request' || + (github.base_ref == 'main' && + github.event.pull_request.head.repo.full_name == github.repository) + uses: ./.github/workflows/package.yml + with: + commit: ${{ github.sha }} + bundles: ${{ github.event_name != 'push' }} + export_images: ${{ github.event_name == 'push' }} + + docker: + needs: [checks, package] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: read + packages: write + uses: ./.github/workflows/docker-publish.yml + secrets: inherit + with: + commit: ${{ github.sha }} + version: ${{ needs.package.outputs.version }} + target: main diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaddb88..d9995f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: Go on: pull_request: - workflow_call: # Release calls this workflow for main pushes and version tags. + workflow_call: permissions: contents: read @@ -16,6 +16,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Release control tests + run: bash .github/scripts/test-release.sh - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod @@ -57,6 +59,7 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-artifacts + overwrite: true path: ${{ runner.temp }}/livepeer-test-artifacts if-no-files-found: ignore - name: Vet diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 05aaae7..5022b4d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,11 +1,18 @@ name: Publish Docker -# Publish images triggered by release builds on main or version tags on: - workflow_run: - workflows: [Release] - branches: [main, 'v*'] - types: [completed] + workflow_call: + inputs: + commit: + required: true + type: string + version: + required: true + type: string + target: + description: main or the release tag + required: true + type: string permissions: contents: read @@ -15,57 +22,31 @@ defaults: shell: bash jobs: - images: - if: >- - (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'workflow_dispatch') && - (github.event.workflow_run.head_branch == 'main' || startsWith(github.event.workflow_run.head_branch, 'v')) && - github.event.workflow_run.head_repository.full_name == github.repository && - github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-26.04 - timeout-minutes: 5 - permissions: - actions: read - outputs: - available: ${{ steps.images.outputs.available }} - steps: - - name: Check for tested images - id: images - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - RUN_ID: ${{ github.event.workflow_run.id }} - SOURCE_EVENT: ${{ github.event.workflow_run.event }} - run: | - count=$(gh api --paginate --slurp "repos/$GH_REPO/actions/runs/$RUN_ID/artifacts?per_page=100" | \ - jq '[.[].artifacts[].name | select(. == "docker-linux-amd64" or . == "docker-linux-arm64")] | unique | length') - if [[ "$count" == 2 ]]; then - echo 'available=true' >> "$GITHUB_OUTPUT" - elif [[ "$count" == 0 && "$SOURCE_EVENT" == workflow_dispatch ]]; then - echo 'available=false' >> "$GITHUB_OUTPUT" - echo 'Skipping Docker publication: packaging-only manual run.' >> "$GITHUB_STEP_SUMMARY" - else - echo 'Expected tested images for both amd64 and arm64.' >&2 - exit 1 - fi - publish: - needs: images - if: needs.images.outputs.available == 'true' runs-on: ubuntu-26.04 environment: docker-publish timeout-minutes: 15 permissions: contents: read - actions: read packages: write outputs: ghcr_digest: ${{ steps.publish.outputs.ghcr_digest }} dockerhub_digest: ${{ steps.publish.outputs.dockerhub_digest }} steps: + - &release_checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: inputs.target != 'main' + with: + ref: ${{ inputs.commit }} + persist-credentials: false + - &authorize_release + name: Require current release administrators + if: inputs.target != 'main' + env: + GH_TOKEN: ${{ github.token }} + run: ./release.sh --authorize - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - github-token: ${{ github.token }} - run-id: ${{ github.event.workflow_run.id }} pattern: docker-linux-* path: images merge-multiple: true @@ -85,8 +66,9 @@ jobs: env: GH_TOKEN: ${{ github.token }} REPOSITORY: ${{ github.repository }} - COMMIT: ${{ github.event.workflow_run.head_sha }} - REF_NAME: ${{ github.event.workflow_run.head_branch }} + COMMIT: ${{ inputs.commit }} + REF_NAME: ${{ inputs.target }} + VERSION: ${{ inputs.version }} run: | repository=$(printf '%s' "$REPOSITORY" | tr '[:upper:]' '[:lower:]') ghcr_image="ghcr.io/$repository" @@ -99,15 +81,14 @@ jobs: } test "$(gh api "repos/$REPOSITORY/commits/refs/tags/$REF_NAME" --jq .sha)" = "$COMMIT" # Release binaries embed a different version than main at the same commit. + test "$VERSION" = "$REF_NAME" tag="$REF_NAME-$tag" fi for arch in amd64 arm64; do docker load --input "images/docker-$arch.tar" test "$(docker image inspect --platform "linux/$arch" --format '{{.Os}}/{{.Architecture}}' "livepeer:test-$arch")" = "linux/$arch" test "$(docker image inspect --platform "linux/$arch" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "livepeer:test-$arch")" = "$COMMIT" - if [[ "$REF_NAME" != main ]]; then - test "$(docker image inspect --platform "linux/$arch" --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "livepeer:test-$arch")" = "$REF_NAME" - fi + test "$(docker image inspect --platform "linux/$arch" --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "livepeer:test-$arch")" = "$VERSION" for image in "$ghcr_image" "$dockerhub_image"; do docker tag "livepeer:test-$arch" "$image:$tag-$arch" docker push "$image:$tag-$arch" @@ -160,13 +141,15 @@ jobs: REPOSITORY: ${{ github.repository }} GHCR_DIGEST: ${{ needs.publish.outputs.ghcr_digest }} DOCKERHUB_DIGEST: ${{ needs.publish.outputs.dockerhub_digest }} - COMMIT: ${{ github.event.workflow_run.head_sha }} + COMMIT: ${{ inputs.commit }} PLATFORM: linux/${{ matrix.arch }} + VERSION: ${{ inputs.version }} run: | repository=$(printf '%s' "$REPOSITORY" | tr '[:upper:]' '[:lower:]') for reference in "ghcr.io/$repository@$GHCR_DIGEST" "docker.io/$repository@$DOCKERHUB_DIGEST"; do docker pull --platform "$PLATFORM" "$reference" version=$(docker image inspect --platform "$PLATFORM" --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "$reference") + test "$version" = "$VERSION" test "$(docker run --rm --network none --platform "$PLATFORM" "$reference" version)" = "livepeer $version ($COMMIT)" done @@ -180,10 +163,12 @@ jobs: packages: write # Keep pending promotions: an older build may finish after the newest one. concurrency: - group: docker-${{ github.event.workflow_run.head_branch }}-promotion + group: docker-${{ inputs.target }}-promotion cancel-in-progress: false queue: max steps: + - *release_checkout + - *authorize_release - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io @@ -199,8 +184,8 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - COMMIT: ${{ github.event.workflow_run.head_sha }} - REF_NAME: ${{ github.event.workflow_run.head_branch }} + COMMIT: ${{ inputs.commit }} + REF_NAME: ${{ inputs.target }} GHCR_DIGEST: ${{ needs.publish.outputs.ghcr_digest }} DOCKERHUB_DIGEST: ${{ needs.publish.outputs.dockerhub_digest }} run: | diff --git a/.github/workflows/manual-release.yml b/.github/workflows/manual-release.yml deleted file mode 100644 index 64a727f..0000000 --- a/.github/workflows/manual-release.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Create Release - -on: - workflow_dispatch: - -permissions: - contents: read - -defaults: - run: - shell: bash - -concurrency: - group: create-release - cancel-in-progress: false - -jobs: - release: - runs-on: ubuntu-26.04 - timeout-minutes: 5 - permissions: - contents: write - actions: write - steps: - - name: Require main - run: | - if [[ "$GITHUB_REF" != refs/heads/main ]]; then - echo 'Create Release must be run from main.' >&2 - exit 1 - fi - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: main - fetch-depth: 0 - - name: Create and push release tag - id: tag - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - ./release.sh origin - tag="v$(git show HEAD:VERSION)" - echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "Created \`$tag\`." >> "$GITHUB_STEP_SUMMARY" - - name: Start release pipeline - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ steps.tag.outputs.tag }} - # A tag pushed with GITHUB_TOKEN does not trigger push workflows. - run: gh workflow run release.yml --ref "$RELEASE_TAG" -f publish=true diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..d715ed0 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,183 @@ +name: Package + +on: + workflow_call: + inputs: + commit: + required: true + type: string + release: + type: boolean + default: false + bundles: + type: boolean + default: true + export_images: + type: boolean + default: false + outputs: + version: + value: ${{ jobs.build.outputs.version }} + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + build: + name: Linux ${{ matrix.arch }} + outputs: + version: ${{ steps.metadata.outputs.version }} + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-26.04 + - arch: arm64 + runner: ubuntu-26.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + env: + ARCH: ${{ matrix.arch }} + BUILD_RELEASE: ${{ inputs.bundles }} + EXPORT_IMAGE: ${{ inputs.export_images }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.commit }} + persist-credentials: false + - name: Release metadata + id: metadata + env: + RELEASE: ${{ inputs.release }} + COMMIT: ${{ inputs.commit }} + run: | + test "$(git rev-parse HEAD)" = "$COMMIT" + release=$(git show HEAD:VERSION) + [[ "$release" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { + echo 'VERSION must be MAJOR.MINOR.PATCH without leading zeroes or suffixes.' >&2 + exit 1 + } + version="v$release-${COMMIT:0:12}" + if [[ "$RELEASE" == true ]]; then + version="v$release" + fi + { + echo "version=$version" + echo "go=$(awk '$1 == "go" {print $2}' go.mod)" + echo "epoch=$(git show -s --format=%ct HEAD)" + } >> "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Build binaries through the Dockerfile + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: docker/Dockerfile + target: binaries + platforms: linux/${{ matrix.arch }} + build-args: | + VERSION=${{ steps.metadata.outputs.version }} + COMMIT=${{ inputs.commit }} + outputs: type=local,dest=${{ runner.temp }}/bin + cache-from: type=gha,scope=docker-${{ matrix.arch }} + - name: Verify static binaries + run: | + for binary in livepeer livepeer-orchestrator livepeer-signer livepeer-chain livepeer-discovery; do + path="$RUNNER_TEMP/bin/$binary" + # A static ELF must have neither an interpreter nor shared-library dependencies. + readelf --program-headers "$path" > "$RUNNER_TEMP/program-headers" + readelf --dynamic "$path" > "$RUNNER_TEMP/dynamic" + if grep -Eq 'INTERP' "$RUNNER_TEMP/program-headers" || grep -Eq 'NEEDED' "$RUNNER_TEMP/dynamic"; then + echo "$binary is not statically linked" >&2 + exit 1 + fi + done + - name: Package deterministic bundle and checksums + if: env.BUILD_RELEASE == 'true' + env: + RELEASE_VERSION: ${{ steps.metadata.outputs.version }} + RELEASE_COMMIT: ${{ inputs.commit }} + RELEASE_GO_VERSION: ${{ steps.metadata.outputs.go }} + SOURCE_DATE_EPOCH: ${{ steps.metadata.outputs.epoch }} + BUNDLE: livepeer-${{ steps.metadata.outputs.version }}-linux-${{ matrix.arch }} + run: | + mkdir -p dist "$RUNNER_TEMP/package/$BUNDLE/bin" + cp "$RUNNER_TEMP/bin/"* "$RUNNER_TEMP/package/$BUNDLE/bin/" + cp LICENSE "$RUNNER_TEMP/package/$BUNDLE/" + printf 'version=%s\ncommit=%s\ngo=go%s\nos=linux\narch=%s\n' \ + "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_GO_VERSION" "$ARCH" > "$RUNNER_TEMP/package/$BUNDLE/VERSION" + ( + cd "$RUNNER_TEMP/package/$BUNDLE" + sha256sum bin/* > SHA256SUMS + chmod 755 . bin bin/* + chmod 644 LICENSE VERSION SHA256SUMS + ) + tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner \ + --format=gnu -C "$RUNNER_TEMP/package" -cf - "$BUNDLE" | gzip -n > "dist/$BUNDLE.tar.gz" + (cd dist && sha256sum "$BUNDLE.tar.gz" > "$BUNDLE.tar.gz.sha256") + - name: Install and smoke test without Go + if: env.BUILD_RELEASE == 'true' + env: + RELEASE_VERSION: ${{ steps.metadata.outputs.version }} + RELEASE_COMMIT: ${{ inputs.commit }} + BUNDLE: livepeer-${{ steps.metadata.outputs.version }}-linux-${{ matrix.arch }} + run: | + docker run --rm --network none --read-only --cap-drop=ALL \ + --security-opt=no-new-privileges --user 65534:65534 \ + --tmpfs /tmp:rw,exec,mode=1777 \ + -v "$PWD/dist:/artifacts:ro" \ + -v "$PWD/.github/scripts/release-smoke.sh:/smoke.sh:ro" \ + alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 \ + sh /smoke.sh "/artifacts/$BUNDLE.tar.gz" "$RELEASE_VERSION" "$RELEASE_COMMIT" + - name: Package image from verified binaries + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: docker/Dockerfile + platforms: linux/${{ matrix.arch }} + # Matching inputs reuse the binaries stage compiled above. + build-args: | + VERSION=${{ steps.metadata.outputs.version }} + COMMIT=${{ inputs.commit }} + load: true + tags: livepeer:test-${{ matrix.arch }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ inputs.commit }} + org.opencontainers.image.version=${{ steps.metadata.outputs.version }} + cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max + - name: Smoke test image + env: + IMAGE_VERSION: ${{ steps.metadata.outputs.version }} + IMAGE_COMMIT: ${{ inputs.commit }} + IMAGE: livepeer:test-${{ matrix.arch }} + run: sh .github/scripts/docker-smoke.sh "$IMAGE" "$IMAGE_VERSION" "$IMAGE_COMMIT" + - name: Save tested release bundle + if: env.BUILD_RELEASE == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-linux-${{ matrix.arch }} + path: dist/* + if-no-files-found: error + compression-level: 0 + overwrite: true + retention-days: 7 + - name: Export tested image + if: env.EXPORT_IMAGE == 'true' + env: + IMAGE: livepeer:test-${{ matrix.arch }} + run: docker save --output "$RUNNER_TEMP/docker-$ARCH.tar" "$IMAGE" + - name: Save tested image + if: env.EXPORT_IMAGE == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: docker-linux-${{ matrix.arch }} + path: ${{ runner.temp }}/docker-${{ matrix.arch }}.tar + if-no-files-found: error + compression-level: 0 + overwrite: true + retention-days: ${{ inputs.release && 7 || 1 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7983685..bb6426e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,7 @@ name: Release on: - pull_request: - push: - branches: [main] - tags: ['v*'] workflow_dispatch: - inputs: - publish: - description: Publish the selected version tag and Docker images - type: boolean - default: false permissions: contents: read @@ -20,224 +11,76 @@ defaults: shell: bash concurrency: - # Keep main builds independent so every successful commit can be published. - group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + group: release + cancel-in-progress: false + queue: max jobs: - checks: - if: github.event_name == 'push' || (inputs.publish && startsWith(github.ref, 'refs/tags/v')) - uses: ./.github/workflows/ci.yml - - build: - if: >- - github.event_name != 'pull_request' || - (github.base_ref == 'main' && - github.event.pull_request.head.repo.full_name == github.repository) - name: Linux ${{ matrix.arch }} - outputs: - commit: ${{ steps.metadata.outputs.commit }} - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - runner: ubuntu-26.04 - - arch: arm64 - runner: ubuntu-26.04-arm - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - env: - ARCH: ${{ matrix.arch }} - BUILD_RELEASE: ${{ github.event_name != 'push' || github.ref_type == 'tag' }} - EXPORT_IMAGE: ${{ github.event_name == 'push' || (inputs.publish && startsWith(github.ref, 'refs/tags/v')) }} + # NB: Retries are pinned to the authorized commit; advancing main does not change the source used. + authorize: + runs-on: ubuntu-26.04 + timeout-minutes: 5 steps: - - name: Require version tag for publication - if: inputs.publish && !startsWith(github.ref, 'refs/tags/v') - run: | - echo 'Publishing requires a vMAJOR.MINOR.PATCH tag. Use Create Release to tag main.' >&2 - exit 1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.sha }} persist-credentials: false - - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - - name: Release metadata - id: metadata - env: - REF_TYPE: ${{ github.ref_type }} - REF_NAME: ${{ github.ref_name }} - run: | - commit=$(git rev-parse HEAD) - go_version=$(awk '$1 == "go" {print $2}' go.mod) - release=$(< VERSION) - [[ "$release" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { - echo 'VERSION must be MAJOR.MINOR.PATCH, without a v prefix, leading zeroes, prerelease suffixes, or build metadata.' >&2 - exit 1 - } - version="v$release-${commit:0:12}" - if [[ "$REF_TYPE" == tag ]]; then - [[ "$REF_NAME" == "v$release" ]] || { - echo "Tag $REF_NAME does not match VERSION $release." >&2 - exit 1 - } - version="$REF_NAME" - fi - { - echo "version=$version" - echo "go=$go_version" - echo "commit=$commit" - echo "epoch=$(git show -s --format=%ct HEAD)" - echo "bundle=livepeer-${version}-linux-${ARCH}" - } >> "$GITHUB_OUTPUT" - - name: Build binaries through the Dockerfile - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - with: - context: . - file: docker/Dockerfile - target: binaries - platforms: linux/${{ matrix.arch }} - build-args: | - VERSION=${{ steps.metadata.outputs.version }} - COMMIT=${{ steps.metadata.outputs.commit }} - outputs: type=local,dest=${{ runner.temp }}/bin - cache-from: type=gha,scope=docker-${{ matrix.arch }} - - name: Verify static binaries - run: | - for binary in livepeer livepeer-orchestrator livepeer-signer livepeer-chain livepeer-discovery; do - path="$RUNNER_TEMP/bin/$binary" - # A static ELF must have neither an interpreter nor shared-library dependencies. - readelf --program-headers "$path" > "$RUNNER_TEMP/program-headers" - readelf --dynamic "$path" > "$RUNNER_TEMP/dynamic" - if grep -Eq 'INTERP' "$RUNNER_TEMP/program-headers" || grep -Eq 'NEEDED' "$RUNNER_TEMP/dynamic"; then - echo "$binary is not statically linked" >&2 - exit 1 - fi - done - - name: Package deterministic bundle and checksums - if: env.BUILD_RELEASE == 'true' - env: - RELEASE_VERSION: ${{ steps.metadata.outputs.version }} - RELEASE_COMMIT: ${{ steps.metadata.outputs.commit }} - RELEASE_GO_VERSION: ${{ steps.metadata.outputs.go }} - SOURCE_DATE_EPOCH: ${{ steps.metadata.outputs.epoch }} - BUNDLE: ${{ steps.metadata.outputs.bundle }} - run: | - mkdir -p dist "$RUNNER_TEMP/package/$BUNDLE/bin" - cp "$RUNNER_TEMP/bin/"* "$RUNNER_TEMP/package/$BUNDLE/bin/" - cp LICENSE "$RUNNER_TEMP/package/$BUNDLE/" - printf 'version=%s\ncommit=%s\ngo=go%s\nos=linux\narch=%s\n' \ - "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_GO_VERSION" "$ARCH" > "$RUNNER_TEMP/package/$BUNDLE/VERSION" - ( - cd "$RUNNER_TEMP/package/$BUNDLE" - sha256sum bin/* > SHA256SUMS - chmod 755 . bin bin/* - chmod 644 LICENSE VERSION SHA256SUMS - ) - tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner \ - --format=gnu -C "$RUNNER_TEMP/package" -cf - "$BUNDLE" | gzip -n > "dist/$BUNDLE.tar.gz" - (cd dist && sha256sum "$BUNDLE.tar.gz" > "$BUNDLE.tar.gz.sha256") - - name: Install and smoke test without Go - if: env.BUILD_RELEASE == 'true' - env: - RELEASE_VERSION: ${{ steps.metadata.outputs.version }} - RELEASE_COMMIT: ${{ steps.metadata.outputs.commit }} - BUNDLE: ${{ steps.metadata.outputs.bundle }} - run: | - docker run --rm --network none --read-only --cap-drop=ALL \ - --security-opt=no-new-privileges --user 65534:65534 \ - --tmpfs /tmp:rw,exec,mode=1777 \ - -v "$PWD/dist:/artifacts:ro" \ - -v "$PWD/.github/scripts/release-smoke.sh:/smoke.sh:ro" \ - alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 \ - sh /smoke.sh "/artifacts/$BUNDLE.tar.gz" "$RELEASE_VERSION" "$RELEASE_COMMIT" - - name: Package image from verified binaries - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - with: - context: . - file: docker/Dockerfile - platforms: linux/${{ matrix.arch }} - # Matching inputs reuse the binaries stage compiled above. - build-args: | - VERSION=${{ steps.metadata.outputs.version }} - COMMIT=${{ steps.metadata.outputs.commit }} - load: true - tags: livepeer:test-${{ matrix.arch }} - labels: | - org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.revision=${{ steps.metadata.outputs.commit }} - org.opencontainers.image.version=${{ steps.metadata.outputs.version }} - cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max - - name: Smoke test image + - &authorize + name: Require main and current repository administrators env: - IMAGE_VERSION: ${{ steps.metadata.outputs.version }} - IMAGE_COMMIT: ${{ steps.metadata.outputs.commit }} - IMAGE: livepeer:test-${{ matrix.arch }} - run: sh .github/scripts/docker-smoke.sh "$IMAGE" "$IMAGE_VERSION" "$IMAGE_COMMIT" - - name: Save tested release bundle - if: env.BUILD_RELEASE == 'true' - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/* - if-no-files-found: error - compression-level: 0 - - name: Export tested image - if: env.EXPORT_IMAGE == 'true' - env: - IMAGE: livepeer:test-${{ matrix.arch }} - run: docker save --output "$RUNNER_TEMP/docker-$ARCH.tar" "$IMAGE" - - name: Save tested image - if: env.EXPORT_IMAGE == 'true' - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: docker-linux-${{ matrix.arch }} - path: ${{ runner.temp }}/docker-${{ matrix.arch }}.tar - if-no-files-found: error - compression-level: 0 - retention-days: 1 + GH_TOKEN: ${{ github.token }} + run: ./release.sh --authorize + - name: Check release tag before building + run: ./release.sh --check-tag + + checks: + needs: authorize + uses: ./.github/workflows/ci.yml + + package: + needs: authorize + uses: ./.github/workflows/package.yml + with: + commit: ${{ github.sha }} + release: true + export_images: true publish: - needs: [checks, build] - if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || inputs.publish) - runs-on: ubuntu-24.04 + needs: [checks, package] + runs-on: ubuntu-26.04 + environment: release timeout-minutes: 10 permissions: contents: write steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + - *authorize - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: release-linux-* path: dist merge-multiple: true - - name: Verify release checksums - run: | - cd dist - cat ./*.tar.gz.sha256 > SHA256SUMS - sha256sum --check SHA256SUMS - name: Publish verified bundles env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ github.ref_name }} - RELEASE_COMMIT: ${{ needs.build.outputs.commit }} + RELEASE_TAG: ${{ needs.package.outputs.version }} + RELEASE_COMMIT: ${{ github.sha }} run: | - verify_tag() { - remote_commit=$(gh api "repos/$GH_REPO/commits/refs/tags/$RELEASE_TAG" --jq .sha) - if [[ "$remote_commit" != "$RELEASE_COMMIT" ]]; then - echo "Tag $RELEASE_TAG no longer points to built commit $RELEASE_COMMIT; refusing to publish." >&2 - exit 1 - fi - } - verify_tag - # Leave incomplete uploads as a draft, and permit a retry of that draft. - if draft=$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft 2>/dev/null); then - if [[ "$draft" != true ]]; then - echo "Release $RELEASE_TAG is already published; refusing to replace its assets." >&2 - exit 1 - fi - else - gh release create "$RELEASE_TAG" --verify-tag --draft --generate-notes --title "$RELEASE_TAG" - fi - gh release upload "$RELEASE_TAG" dist/*.tar.gz dist/SHA256SUMS --clobber - verify_tag - gh release edit "$RELEASE_TAG" --draft=false --prerelease=false + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + ./release.sh --publish + + docker: + needs: [publish, package] + permissions: + contents: read + packages: write + uses: ./.github/workflows/docker-publish.yml + secrets: inherit + with: + commit: ${{ github.sha }} + version: ${{ needs.package.outputs.version }} + target: ${{ needs.package.outputs.version }} diff --git a/docs/development.md b/docs/development.md index fbc85eb..9fbad66 100644 --- a/docs/development.md +++ b/docs/development.md @@ -16,33 +16,32 @@ builder in `docker/Dockerfile` aligned with it. ## Releases Set the [VERSION](../VERSION) file to the next `MAJOR.MINOR.PATCH` number without -a `v` prefix, leading zeroes, prerelease suffix, or build metadata. Commit it to -`main`, then run **Create Release** from GitHub Actions. - -You can also run the release helper locally with the Git remote name: +a `v` prefix, leading zeroes, prerelease suffix, or build metadata. Merge it to +`main`, then run **Release** from GitHub Actions on `main`: ```sh -./release.sh github +gh workflow run release.yml --repo livepeer/node --ref main ``` -The helper creates and pushes a `vMAJOR.MINOR.PATCH` tag. Release tags cannot be -updated or deleted, and published releases are immutable. -The [Release workflow](../.github/workflows/release.yml) on GitHub publishes -bundles after CI and packaging tests pass. Successful publication also starts -Docker image publishing. - -If **Create Release** creates the tag but fails to start **Release**, run: +The [Release workflow](../.github/workflows/release.yml) requires repository admin +permission and uses the dispatch commit throughout CI, packaging, tagging, +GitHub publication, and Docker publication. -```sh -gh workflow run release.yml --repo livepeer/node --ref vMAJOR.MINOR.PATCH -f publish=true -``` +If a job fails, choose **Re-run failed jobs** in that same run within seven days, +while the tested bundles and images are retained. Matching tags, incomplete +drafts, and published stable releases with identical assets are safe to retry; +conflicting tags or published assets fail without replacement. If artifacts +have expired, rerun all jobs to rebuild before retrying publication. -If **Release** or **Publish Docker** fails, choose **Re-run failed jobs** before -its image artifacts expire. +For tested snapshot bundles without publication, run +[Build](../.github/workflows/build.yml) manually. Eligible PRs also build bundles; +main pushes automatically test and publish snapshot Docker images, retained as +workflow artifacts for one day. ## Checks ```sh +bash .github/scripts/test-release.sh go test -race ./... go vet ./... go build ./... diff --git a/release.sh b/release.sh index 40f0167..4a7a2ab 100755 --- a/release.sh +++ b/release.sh @@ -1,17 +1,27 @@ #!/usr/bin/env bash -set -e +set -euo pipefail -remote=${1:?Usage: ./release.sh [--allow-non-main]} -if [[ $# -gt 2 || ( $# -eq 2 && "$2" != --allow-non-main ) ]]; then - echo 'Usage: ./release.sh [--allow-non-main]' >&2 +mode=${1:-} +if [[ $# -ne 1 || ( "$mode" != --authorize && "$mode" != --check-tag && "$mode" != --publish ) ]]; then + echo 'Usage: ./release.sh --authorize|--check-tag|--publish' >&2 exit 1 fi -branch=$(git branch --show-current) -if [[ "$branch" != main && "${2:-}" != --allow-non-main ]]; then - echo "Warning: refusing to release from ${branch:-detached HEAD}; switch to main or pass --allow-non-main." >&2 - exit 1 +# Called inside every release-writing job so partial reruns check the new actor. +if [[ "$mode" == --authorize ]]; then + test "$GITHUB_REF" = refs/heads/main || { + echo 'Release must be run from main.' >&2 + exit 1 + } + for actor in "$GITHUB_ACTOR" "$GITHUB_TRIGGERING_ACTOR"; do + permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$actor/permission" --jq .permission) + if [[ "$permission" != admin ]]; then + echo "$actor must currently have admin permission to release." >&2 + exit 1 + fi + done + exit 0 fi version=$(git show HEAD:VERSION) @@ -24,5 +34,101 @@ if ! git diff --quiet HEAD -- :/VERSION; then exit 1 fi -git tag -a "v$version" -m "Release v$version" -git push "$remote" "v$version" +tag="v$version" +commit=$(git rev-parse HEAD) + +if [[ "$mode" == --publish ]]; then + test "$tag" = "${RELEASE_TAG:?}" + test "$commit" = "${RELEASE_COMMIT:?}" + repo=${GH_REPO:?} + assets=("livepeer-$tag-linux-amd64.tar.gz" "livepeer-$tag-linux-arm64.tar.gz" SHA256SUMS) + # Check both bundles before creating any remote state. + expected=$( + set -e + cd dist + : > SHA256SUMS + for name in "${assets[@]:0:2}"; do + checksum=$(sha256sum "$name") + test "$(< "$name.sha256")" = "$checksum" || { + echo "Checksum mismatch: $name" >&2 + exit 1 + } + printf '%s\n' "$checksum" >> SHA256SUMS + done + sha256sum "${assets[@]}" | jq -Rn ' + [inputs | capture("^(?[0-9a-f]{64}) (?.*)$") | + {name, digest: ("sha256:" + .digest), state: "uploaded"}] | sort_by(.name)' + ) +fi + +# ls-remote distinguishes an absent tag (2) from a transport/authentication error. +# Prefer the peeled commit of an annotated tag; also accept lightweight tags. +remote_commit() { + local refs + refs=$(git ls-remote --exit-code origin "refs/tags/$tag" "refs/tags/$tag^{}") || return $? + printf '%s\n' "$refs" | awk ' + /\^\{\}$/ { peeled = $1 } + { direct = $1 } + END { print peeled ? peeled : direct }' +} +verify_remote() { + [[ "$1" == "$commit" ]] || { + echo "Remote tag $tag points to $1, expected $commit; refusing to move it." >&2 + return 1 + } +} + +if existing=$(remote_commit); then + verify_remote "$existing" +else + status=$? + [[ "$status" == 2 ]] || exit "$status" + [[ "$mode" != --check-tag ]] || exit 0 + if git show-ref --verify --quiet "refs/tags/$tag"; then + [[ "$(git rev-parse "$tag^{commit}")" == "$commit" ]] || { + echo "Local tag $tag points to a different commit; refusing to move it." >&2 + exit 1 + } + else + git tag -a "$tag" -m "Release $tag" + fi + # A push may reach the remote even if its response is lost. + if ! git push origin "refs/tags/$tag:refs/tags/$tag"; then + echo 'Push failed; checking whether the intended tag reached the remote.' >&2 + fi + existing=$(remote_commit) + verify_remote "$existing" +fi + +[[ "$mode" == --publish ]] || exit 0 +verify_tag() { + test "$(gh api "repos/$repo/commits/refs/tags/$tag" --jq .sha)" = "$commit" +} +find_release() { + # Listing includes drafts; the tag endpoint only returns published releases. + gh api --paginate --slurp "repos/$repo/releases?per_page=100" | + jq -c --arg tag "$tag" '[.[][] | select(.tag_name == $tag)] | + if length > 1 then error("Multiple releases match tag " + $tag) else .[0] end' +} +verify_tag +release=$(find_release) +if [[ "$release" == null ]]; then + gh release create "$tag" --repo "$repo" --verify-tag --draft --generate-notes --title "$tag" + release=$(find_release) +fi +id=$(jq -er .id <<< "$release") +if jq -e .draft <<< "$release" > /dev/null; then + gh release upload "$tag" --repo "$repo" --clobber "${assets[@]/#/dist/}" +elif ! jq -e '.prerelease == false' <<< "$release" > /dev/null; then + echo "Published release $tag must have prerelease=false." >&2 + exit 1 +fi +gh api --paginate --slurp "repos/$repo/releases/$id/assets?per_page=100" | + jq -e --argjson expected "$expected" '[.[][] | {name, digest, state}] | sort_by(.name) == $expected' > /dev/null || { + echo 'Release assets differ from the tested bundles.' >&2 + exit 1 + } +verify_tag +if jq -e .draft <<< "$release" > /dev/null; then + gh release edit "$tag" --repo "$repo" --draft=false --prerelease=false +fi From b3fe84aabc95b5b8951c79bb54d8ecb6367ac197 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Mon, 26 Oct 2026 23:16:59 -0700 Subject: [PATCH 2/2] Remove redundant inputs.commit --- .github/workflows/build.yml | 1 - .github/workflows/package.yml | 19 ++++++++----------- .github/workflows/release.yml | 1 - 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1675ddc..15471b1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,6 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository) uses: ./.github/workflows/package.yml with: - commit: ${{ github.sha }} bundles: ${{ github.event_name != 'push' }} export_images: ${{ github.event_name == 'push' }} diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index d715ed0..5625b1e 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -3,9 +3,6 @@ name: Package on: workflow_call: inputs: - commit: - required: true - type: string release: type: boolean default: false @@ -48,13 +45,13 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.commit }} + ref: ${{ github.sha }} persist-credentials: false - name: Release metadata id: metadata env: RELEASE: ${{ inputs.release }} - COMMIT: ${{ inputs.commit }} + COMMIT: ${{ github.sha }} run: | test "$(git rev-parse HEAD)" = "$COMMIT" release=$(git show HEAD:VERSION) @@ -81,7 +78,7 @@ jobs: platforms: linux/${{ matrix.arch }} build-args: | VERSION=${{ steps.metadata.outputs.version }} - COMMIT=${{ inputs.commit }} + COMMIT=${{ github.sha }} outputs: type=local,dest=${{ runner.temp }}/bin cache-from: type=gha,scope=docker-${{ matrix.arch }} - name: Verify static binaries @@ -100,7 +97,7 @@ jobs: if: env.BUILD_RELEASE == 'true' env: RELEASE_VERSION: ${{ steps.metadata.outputs.version }} - RELEASE_COMMIT: ${{ inputs.commit }} + RELEASE_COMMIT: ${{ github.sha }} RELEASE_GO_VERSION: ${{ steps.metadata.outputs.go }} SOURCE_DATE_EPOCH: ${{ steps.metadata.outputs.epoch }} BUNDLE: livepeer-${{ steps.metadata.outputs.version }}-linux-${{ matrix.arch }} @@ -123,7 +120,7 @@ jobs: if: env.BUILD_RELEASE == 'true' env: RELEASE_VERSION: ${{ steps.metadata.outputs.version }} - RELEASE_COMMIT: ${{ inputs.commit }} + RELEASE_COMMIT: ${{ github.sha }} BUNDLE: livepeer-${{ steps.metadata.outputs.version }}-linux-${{ matrix.arch }} run: | docker run --rm --network none --read-only --cap-drop=ALL \ @@ -142,18 +139,18 @@ jobs: # Matching inputs reuse the binaries stage compiled above. build-args: | VERSION=${{ steps.metadata.outputs.version }} - COMMIT=${{ inputs.commit }} + COMMIT=${{ github.sha }} load: true tags: livepeer:test-${{ matrix.arch }} labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.revision=${{ inputs.commit }} + org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.version=${{ steps.metadata.outputs.version }} cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max - name: Smoke test image env: IMAGE_VERSION: ${{ steps.metadata.outputs.version }} - IMAGE_COMMIT: ${{ inputs.commit }} + IMAGE_COMMIT: ${{ github.sha }} IMAGE: livepeer:test-${{ matrix.arch }} run: sh .github/scripts/docker-smoke.sh "$IMAGE" "$IMAGE_VERSION" "$IMAGE_COMMIT" - name: Save tested release bundle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb6426e..5fb9185 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,6 @@ jobs: needs: authorize uses: ./.github/workflows/package.yml with: - commit: ${{ github.sha }} release: true export_images: true