Release #9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| push: | |
| tags: ["v*"] | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: Existing published release tag | |
| required: true | |
| type: string | |
| operation: | |
| description: Maintenance operation | |
| required: true | |
| default: dockerhub-backfill | |
| type: choice | |
| options: | |
| - dockerhub-backfill | |
| - cleanup-ghcr-alias | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: release-${{ inputs.tag || github.ref_name }} | |
| cancel-in-progress: false | |
| jobs: | |
| preflight: | |
| name: Validate release identity | |
| runs-on: ubuntu-24.04 | |
| outputs: | |
| version: ${{ steps.identity.outputs.version }} | |
| commit: ${{ steps.identity.outputs.commit }} | |
| build_date: ${{ steps.identity.outputs.build_date }} | |
| image: ${{ steps.identity.outputs.image }} | |
| steps: | |
| - name: Check out complete history | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| - name: Validate tag and branch ancestry | |
| id: identity | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ inputs.tag || github.ref_name }} | |
| run: | | |
| case "$RELEASE_TAG" in | |
| v0.0.0|v0.0.0-*) echo "0.0.0 is not a releasable version" >&2; exit 1 ;; | |
| esac | |
| printf '%s' "$RELEASE_TAG" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$' | |
| git fetch --no-tags origin master | |
| git fetch --force origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" | |
| release_sha=$(git rev-list -n 1 "$RELEASE_TAG") | |
| test -n "$release_sha" | |
| git merge-base --is-ancestor "$release_sha" origin/master | |
| draft=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" --jq .draft 2>/dev/null || true) | |
| if test "$GITHUB_EVENT_NAME" = workflow_dispatch; then | |
| test "$draft" = false | |
| else | |
| test "$release_sha" = "$GITHUB_SHA" | |
| test "$draft" != false | |
| fi | |
| { | |
| echo "version=$RELEASE_TAG" | |
| echo "commit=$release_sha" | |
| echo "build_date=$(git show -s --format=%cI "$release_sha")" | |
| echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" | |
| } >> "$GITHUB_OUTPUT" | |
| assets: | |
| name: Build and attest release assets | |
| needs: preflight | |
| if: github.event_name == 'push' | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| contents: write | |
| id-token: write | |
| attestations: write | |
| steps: | |
| - name: Check out release source | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ needs.preflight.outputs.commit }} | |
| - name: Set up Go | |
| uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 | |
| with: | |
| go-version-file: .go-version | |
| cache: false | |
| - name: Test release source | |
| run: | | |
| go test -count=1 ./... | |
| go test -race -count=1 ./... | |
| go vet ./... | |
| - name: Build deterministic archives and packages | |
| env: | |
| VERSION: ${{ needs.preflight.outputs.version }} | |
| COMMIT: ${{ needs.preflight.outputs.commit }} | |
| BUILD_DATE: ${{ needs.preflight.outputs.build_date }} | |
| run: sh scripts/build-release.sh | |
| - name: Verify release asset set and checksums | |
| run: | | |
| test "$(find dist -maxdepth 1 -type f | wc -l)" -eq 17 | |
| test "$(find dist -maxdepth 1 -type f -name 'rule-bot-client_*_linux_*.tar.gz' | wc -l)" -eq 13 | |
| test "$(find dist -maxdepth 1 -type f -name 'rule-bot-client_*.deb' | wc -l)" -eq 3 | |
| test "$(find dist -maxdepth 1 -type f -name '*openwrt*' | wc -l)" -eq 0 | |
| test -f dist/checksums.txt | |
| for archive in dist/rule-bot-client_*_linux_*.tar.gz; do | |
| package=$(basename "$archive" .tar.gz) | |
| tar -tzf "$archive" \ | |
| "$package/config.example.json" \ | |
| "$package/PRIVACY.md" \ | |
| "$package/SECURITY.md" >/dev/null | |
| test "$(tar -tvzf "$archive" "$package/config.example.json" | awk '{print $1}')" = "-rw-------" | |
| done | |
| cd dist | |
| sha256sum -c checksums.txt | |
| - name: Attest release assets | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-path: dist/* | |
| - name: Create or update draft release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then | |
| test "$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft)" = true | |
| gh release upload "$GITHUB_REF_NAME" dist/* --clobber | |
| else | |
| gh release create "$GITHUB_REF_NAME" dist/* --draft --verify-tag --generate-notes --title "$GITHUB_REF_NAME" | |
| fi | |
| image: | |
| name: Publish and verify container image | |
| needs: [preflight, assets] | |
| if: >- | |
| always() && | |
| needs.preflight.result == 'success' && | |
| (needs.assets.result == 'success' || | |
| (github.event_name == 'workflow_dispatch' && inputs.operation == 'dockerhub-backfill')) | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| contents: read | |
| packages: write | |
| id-token: write | |
| attestations: write | |
| outputs: | |
| digest: ${{ steps.build.outputs.digest }} | |
| steps: | |
| - name: Check out release source | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ needs.preflight.outputs.commit }} | |
| - name: Set up Buildx | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 | |
| - name: Log in to GHCR | |
| if: github.event_name == 'push' | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Log in to Docker Hub | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Generate image metadata | |
| id: meta | |
| uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 | |
| with: | |
| images: | | |
| name=${{ needs.preflight.outputs.image }},enable=${{ github.event_name == 'push' }} | |
| name=${{ secrets.DOCKERHUB_USERNAME }}/rule-bot-client | |
| flavor: | | |
| latest=false | |
| tags: | | |
| type=semver,pattern={{version}},value=${{ needs.preflight.outputs.version }} | |
| type=raw,value=latest,enable=${{ !contains(needs.preflight.outputs.version, '-') }} | |
| - name: Build and push exact two-platform image | |
| id: build | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 | |
| with: | |
| context: . | |
| platforms: linux/amd64,linux/arm64 | |
| push: true | |
| tags: ${{ steps.meta.outputs.tags }} | |
| provenance: false | |
| sbom: false | |
| build-args: | | |
| VERSION=${{ needs.preflight.outputs.version }} | |
| COMMIT=${{ needs.preflight.outputs.commit }} | |
| BUILD_DATE=${{ needs.preflight.outputs.build_date }} | |
| - name: Attest image digest | |
| if: github.event_name == 'push' | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-name: ${{ needs.preflight.outputs.image }} | |
| subject-digest: ${{ steps.build.outputs.digest }} | |
| push-to-registry: true | |
| - name: Verify digest and exact platform index | |
| env: | |
| COMMIT: ${{ needs.preflight.outputs.commit }} | |
| DIGEST: ${{ steps.build.outputs.digest }} | |
| DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/rule-bot-client | |
| GHCR_IMAGE: ${{ needs.preflight.outputs.image }} | |
| PUBLISH_GHCR: ${{ github.event_name == 'push' }} | |
| run: | | |
| images=("$DOCKERHUB_IMAGE") | |
| if test "$PUBLISH_GHCR" = true; then | |
| images+=("$GHCR_IMAGE") | |
| fi | |
| for image in "${images[@]}"; do | |
| reference="$image@$DIGEST" | |
| docker buildx imagetools inspect "$reference" --raw > manifest.json | |
| jq -e ' | |
| [.manifests[].platform | select(.os != "linux" or (.architecture != "amd64" and .architecture != "arm64"))] | length == 0 | |
| ' manifest.json | |
| jq -e '[.manifests[].platform | "\(.os)/\(.architecture)"] | sort == ["linux/amd64", "linux/arm64"]' manifest.json | |
| docker run --rm --platform linux/amd64 "$reference" --version | tee version.txt | |
| grep -F "rule-bot-client ${{ needs.preflight.outputs.version }} commit=$COMMIT" version.txt | |
| test "$(docker image inspect "$reference" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" = "$COMMIT" | |
| test "$(docker image inspect "$reference" --format '{{ index .Config.Labels "org.opencontainers.image.version" }}')" = "${{ needs.preflight.outputs.version }}" | |
| test "$(docker image inspect "$reference" --format '{{ index .Config.Labels "org.opencontainers.image.created" }}')" = "${{ needs.preflight.outputs.build_date }}" | |
| done | |
| cleanup: | |
| name: Remove legacy GHCR minor-version alias | |
| needs: preflight | |
| if: github.event_name == 'workflow_dispatch' && inputs.operation == 'cleanup-ghcr-alias' | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| - name: Recreate the release index without its redundant alias | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ needs.preflight.outputs.version }} | |
| run: | | |
| set -Eeuo pipefail | |
| version=${RELEASE_TAG#v} | |
| case "$version" in | |
| *-*) echo "Pre-release versions do not have a legacy minor alias" >&2; exit 1 ;; | |
| esac | |
| minor=${version%.*} | |
| image_path=${GITHUB_REPOSITORY,,} | |
| image="ghcr.io/$image_path" | |
| package=rule-bot-client | |
| accept='application/vnd.oci.image.index.v1+json' | |
| registry_auth=$(printf '%s' "$GITHUB_ACTOR:$GH_TOKEN" | base64 -w0) | |
| registry_token=$( | |
| curl -fsSL -H "Authorization: Basic $registry_auth" \ | |
| --get \ | |
| --data-urlencode service=ghcr.io \ | |
| --data-urlencode "scope=repository:$image_path:pull,push" \ | |
| https://ghcr.io/token | | |
| jq -er '.token // .access_token' | |
| ) | |
| curl -fsSL -D manifest-headers.txt \ | |
| -H "Authorization: Bearer $registry_token" \ | |
| -H "Accept: $accept" \ | |
| "https://ghcr.io/v2/$image_path/manifests/$version" \ | |
| -o manifest.json | |
| digest=$(awk 'BEGIN { IGNORECASE=1 } /^Docker-Content-Digest:/ { gsub("\r", ""); print $2; exit }' manifest-headers.txt) | |
| media_type=$(awk 'BEGIN { IGNORECASE=1 } /^Content-Type:/ { gsub("\r", ""); print $2; exit }' manifest-headers.txt) | |
| calculated="sha256:$(sha256sum manifest.json | cut -d' ' -f1)" | |
| test -n "$digest" | |
| test "$calculated" = "$digest" | |
| jq -e ' | |
| .mediaType == "application/vnd.oci.image.index.v1+json" and | |
| ([.manifests[].platform | "\(.os)/\(.architecture)"] | sort == ["linux/amd64", "linux/arm64"]) | |
| ' manifest.json | |
| gh_api_retry() { | |
| local output=$1 | |
| local attempt | |
| shift | |
| for attempt in 1 2 3; do | |
| if gh api "$@" >"$output"; then | |
| return 0 | |
| fi | |
| test "$attempt" -lt 3 | |
| sleep $((attempt * 2)) | |
| done | |
| return 1 | |
| } | |
| gh_api_retry version-id.txt --paginate \ | |
| "users/$GITHUB_REPOSITORY_OWNER/packages/container/$package/versions?per_page=100" \ | |
| --jq ".[] | select(.name == \"$digest\") | .id" | |
| version_id=$(cat version-id.txt) | |
| test "$(printf '%s\n' "$version_id" | grep -c .)" -eq 1 | |
| gh_api_retry original-tags.txt \ | |
| "users/$GITHUB_REPOSITORY_OWNER/packages/container/$package/versions/$version_id" \ | |
| --jq '.metadata.container.tags[]' | |
| mapfile -t original_tags <original-tags.txt | |
| has_tag() { | |
| printf '%s\n' "${original_tags[@]}" | grep -Fxq "$1" | |
| } | |
| has_tag "$version" | |
| has_tag "$minor" | |
| for tag in "${original_tags[@]}"; do | |
| case "$tag" in | |
| "$version"|"$minor"|latest) ;; | |
| *) echo "Unexpected tag on $digest: $tag" >&2; exit 1 ;; | |
| esac | |
| done | |
| retained_tags=("$version") | |
| if has_tag latest; then | |
| retained_tags+=(latest) | |
| fi | |
| put_manifest() { | |
| curl -sS --fail-with-body -X PUT \ | |
| -H "Authorization: Bearer $registry_token" \ | |
| -H "Content-Type: $media_type" \ | |
| --data-binary @manifest.json \ | |
| "https://ghcr.io/v2/$image_path/manifests/$1" >/dev/null | |
| } | |
| resolve_digest() { | |
| curl -fsSL -D - -o /dev/null \ | |
| -H "Authorization: Bearer $registry_token" \ | |
| -H "Accept: $accept" \ | |
| "https://ghcr.io/v2/$image_path/manifests/$1" | | |
| awk 'BEGIN { IGNORECASE=1 } /^Docker-Content-Digest:/ { gsub("\r", ""); print $2; exit }' | |
| } | |
| resolve_status() { | |
| curl -sS -o /dev/null -w '%{http_code}' \ | |
| -H "Authorization: Bearer $registry_token" \ | |
| -H "Accept: $accept" \ | |
| "https://ghcr.io/v2/$image_path/manifests/$1" | |
| } | |
| wait_for_status() { | |
| local tag=$1 | |
| local expected=$2 | |
| local attempt current | |
| for attempt in $(seq 1 30); do | |
| current=$(resolve_status "$tag") | |
| if test "$current" = "$expected"; then | |
| echo "$tag returned HTTP $expected" | |
| return 0 | |
| fi | |
| sleep 1 | |
| done | |
| echo "$tag returned HTTP $current, expected $expected" >&2 | |
| return 1 | |
| } | |
| wait_for_digest() { | |
| local tag=$1 | |
| local attempt current | |
| for attempt in $(seq 1 30); do | |
| current=$(resolve_digest "$tag" 2>/dev/null || true) | |
| if test "$current" = "$digest"; then | |
| echo "$tag resolved to $digest" | |
| return 0 | |
| fi | |
| sleep 1 | |
| done | |
| echo "$tag resolved to ${current:-nothing}, expected $digest" >&2 | |
| return 1 | |
| } | |
| deleted=false | |
| rollback() { | |
| status=$? | |
| if test "$status" -ne 0 && test "$deleted" = true; then | |
| set +e | |
| rollback_ok=true | |
| for tag in "${original_tags[@]}"; do | |
| put_manifest "$tag" || rollback_ok=false | |
| done | |
| if test "$rollback_ok" != true; then | |
| gh api --method POST \ | |
| "users/$GITHUB_REPOSITORY_OWNER/packages/container/$package/versions/$version_id/restore" || true | |
| fi | |
| fi | |
| exit "$status" | |
| } | |
| trap rollback EXIT | |
| gh api --method DELETE \ | |
| "users/$GITHUB_REPOSITORY_OWNER/packages/container/$package/versions/$version_id" | |
| deleted=true | |
| wait_for_status "$version" 404 | |
| for tag in "${retained_tags[@]}"; do | |
| put_manifest "$tag" | |
| done | |
| for tag in "${retained_tags[@]}"; do | |
| wait_for_digest "$tag" | |
| done | |
| active_version_id='' | |
| for attempt in $(seq 1 30); do | |
| gh_api_retry active-version-id.txt --paginate \ | |
| "users/$GITHUB_REPOSITORY_OWNER/packages/container/$package/versions?per_page=100" \ | |
| --jq ".[] | select(.name == \"$digest\") | .id" | |
| active_version_id=$(cat active-version-id.txt) | |
| test "$(printf '%s\n' "$active_version_id" | grep -c .)" -eq 1 && break | |
| sleep 1 | |
| done | |
| test "$(printf '%s\n' "$active_version_id" | grep -c .)" -eq 1 | |
| gh_api_retry active-tags.txt \ | |
| "users/$GITHUB_REPOSITORY_OWNER/packages/container/$package/versions/$active_version_id" \ | |
| --jq '.metadata.container.tags[]' | |
| mapfile -t active_tags <active-tags.txt | |
| test "${#active_tags[@]}" -eq "${#retained_tags[@]}" | |
| for tag in "${retained_tags[@]}"; do | |
| printf '%s\n' "${active_tags[@]}" | grep -Fxq "$tag" | |
| done | |
| minor_status=$(resolve_status "$minor") | |
| echo "$minor returned HTTP $minor_status immediately after cleanup; GHCR may briefly cache the old tag" | |
| trap - EXIT | |
| echo "Removed redundant GHCR alias $minor from $image@$digest" | |
| echo "Retained tags: ${retained_tags[*]}" | |
| publish: | |
| name: Publish draft release | |
| needs: [preflight, assets, image] | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Verify assets and publish | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| test "$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)" = true | |
| ready=false | |
| for attempt in $(seq 1 120); do | |
| gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' > release-assets.txt | |
| if test "$(wc -l < release-assets.txt)" -eq 28 \ | |
| && grep -Fx checksums.txt release-assets.txt \ | |
| && grep -Fx openwrt-checksums.txt release-assets.txt \ | |
| && grep -Fx openwrt-manifest.tsv release-assets.txt \ | |
| && grep -Fx install-rule-bot-client-openwrt.sh release-assets.txt \ | |
| && test "$(grep -Ec '^luci-app-rule-bot-client.*\.(ipk|apk)$' release-assets.txt)" -eq 8; then | |
| ready=true | |
| break | |
| fi | |
| sleep 10 | |
| done | |
| test "$ready" = true | |
| gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false |