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..15471b1 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,42 @@ +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: + 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..5625b1e --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,180 @@ +name: Package + +on: + workflow_call: + inputs: + 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: ${{ github.sha }} + persist-credentials: false + - name: Release metadata + id: metadata + env: + RELEASE: ${{ inputs.release }} + COMMIT: ${{ github.sha }} + 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=${{ github.sha }} + 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: ${{ 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 }} + 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: ${{ github.sha }} + 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=${{ github.sha }} + load: true + tags: livepeer:test-${{ matrix.arch }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + 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: ${{ github.sha }} + 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..5fb9185 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,75 @@ 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: + 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