Skip to content

Release

Release #9

Workflow file for this run

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