name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Existing tag to release (e.g. v0.1.0 or v0.0.0-test1)"
required: true
type: string
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
RUSTFLAGS: "-D warnings"
CROSS_VERSION: "0.2.5"
CARGO_DEB_VERSION: "3.6.3"
CARGO_GENERATE_RPM_VERSION: "0.20.0"
CARGO_CYCLONEDX_VERSION: "0.5.7"
CARGO_ABOUT_VERSION: "0.8.4"
jobs:
preflight:
name: preflight
runs-on: ubuntu-latest
outputs:
version: ${{ steps.classify.outputs.version }}
tag: ${{ steps.classify.outputs.tag }}
prerelease: ${{ steps.classify.outputs.prerelease }}
ref: ${{ steps.classify.outputs.ref }}
toolchain: ${{ steps.toolchain.outputs.toolchain }}
steps:
- name: Resolve tag
id: tag
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
set -eu
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
TAG="$INPUT_TAG"
else
TAG="$REF_NAME"
fi
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then
echo "::error::Tag '$TAG' is not a valid semver release tag"
exit 1
fi
# abuild's pkgver grammar accepts only `_<alpha|beta|pre|rc|cvs|svn|git|hg|p><digits>`
# with no dots in the suffix. The render step maps the semver
# hyphen to abuild's underscore, but `1.0.0-rc.2` would still
# emit `1.0.0_rc.2` which apk rejects mid-pipeline. Fail here
# instead, so tag-format mistakes cost 30 s in preflight rather
# than 2+ min across the apk arches.
if [[ "$TAG" == *-*.* ]]; then
echo "::error::Pre-release suffix in '$TAG' contains a dot. abuild does not accept dotted suffixes (e.g. 'rc.2'); use 'rc2' instead."
exit 1
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with:
persist-credentials: false
ref: ${{ steps.tag.outputs.tag }}
- name: Derive toolchain from Cargo.toml
id: toolchain
run: |
set -eu
TOOLCHAIN=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[] | select(.name == "git-remote-object-store") | .rust_version')
if [[ -z "$TOOLCHAIN" || "$TOOLCHAIN" == "null" ]]; then
echo "::error::Could not read rust-version from Cargo.toml"
exit 1
fi
echo "toolchain=$TOOLCHAIN" >> "$GITHUB_OUTPUT"
echo "Derived toolchain: $TOOLCHAIN"
- uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 with:
toolchain: ${{ steps.toolchain.outputs.toolchain }}
- name: Verify tag ↔ Cargo.toml version parity
env:
VERSION: ${{ steps.tag.outputs.version }}
run: |
set -eu
# The version appears in three places that must agree, and
# each one fails differently if it drifts:
#
# 1. root Cargo.toml [package].version → library's
# published version on crates.io.
# 2. cli/Cargo.toml [package].version → CLI's published
# version + driver for cargo-deb / cargo-generate-rpm
# filename and metadata.
# 3. cli/Cargo.toml [dependencies].git-remote-object-store
# .version → version requirement applied to
# the library when the CLI is built from crates.io. A
# drift here passes every preceding stage and fails
# only at `publish-crates`, after the GitHub Release is
# already cut and irreversible.
# Single source of truth: `cargo metadata`. jq pulls each
# version from the resolved tree without our own TOML parser.
META=$(cargo metadata --format-version 1 --no-deps)
LIB_VER=$(jq -r '.packages[] | select(.name=="git-remote-object-store") | .version' <<<"$META")
CLI_VER=$(jq -r '.packages[] | select(.name=="git-remote-object-store-cli") | .version' <<<"$META")
# Collect every normal-kind (.kind == null) dependency
# entry on the library. We exclude dev-deps and build-deps:
# cli/Cargo.toml [dev-dependencies] declares a path-only
# entry to opt the integration tests into the library's
# `test-util` feature, with no `version =` field — cargo
# metadata reports its `req` as `*`, which is not relevant
# to the crates.io publish requirement we are validating.
# Materialising as a JSON array lets us validate the
# cardinality (exactly one normal-kind entry) before
# touching the value.
DEP_REQS_JSON=$(jq '[.packages[] | select(.name=="git-remote-object-store-cli") | .dependencies[] | select(.name=="git-remote-object-store" and .kind == null) | .req]' <<<"$META")
DEP_REQ_COUNT=$(jq 'length' <<<"$DEP_REQS_JSON")
DEP_REQ=$(jq -r '.[0] // ""' <<<"$DEP_REQS_JSON")
# Strip the cargo requirement-prefix (`^`, `~`, `=`) so we
# compare bare versions.
DEP_REQ_STRIPPED="${DEP_REQ#[\^~=]}"
fail=0
if [[ "$LIB_VER" != "$VERSION" ]]; then
echo "::error::root Cargo.toml version is $LIB_VER, tag is $VERSION"
fail=1
fi
if [[ "$CLI_VER" != "$VERSION" ]]; then
echo "::error::cli/Cargo.toml [package].version is $CLI_VER, tag is $VERSION"
fail=1
fi
# The third drift: the CLI manifest pins the library via
# `version = "..."` for crates.io publish. A drift here
# passes every preceding stage and fails only at
# `publish-crates`, after the GitHub Release is
# irreversible. Three distinct failure modes — surface
# each one with its own message so the operator does not
# waste time chasing "bump it in lockstep" when the
# underlying problem is something else entirely.
if [[ "$DEP_REQ_COUNT" -eq 0 ]]; then
echo "::error::cli/Cargo.toml has no normal-kind [dependencies].git-remote-object-store entry. The CLI requires the library as a published dep for crates.io to resolve it; restore the [dependencies] declaration before tagging."
fail=1
elif [[ "$DEP_REQ_COUNT" -gt 1 ]]; then
echo "::error::cli/Cargo.toml declares git-remote-object-store as a normal dep ${DEP_REQ_COUNT} times (target-gated entries under [target.'cfg(...)'.dependencies] are the usual cause). Preflight validates parity against a single declaration; consolidate the entries or extend this check to walk all of them."
fail=1
elif [[ "$DEP_REQ_STRIPPED" != "$VERSION" ]]; then
echo "::error::cli/Cargo.toml [dependencies].git-remote-object-store version is '$DEP_REQ', tag is $VERSION — bump it in lockstep"
fail=1
fi
[[ $fail -eq 0 ]] || exit 1
- name: Verify minisign.pub is not the placeholder
run: |
set -eu
# Anchored match against the exact committed placeholder comment.
# A real public key file's `untrusted comment:` line won't start with this.
if grep -q '^untrusted comment: placeholder' minisign.pub; then
echo "::error::minisign.pub is still the committed placeholder. Rotate the key before tagging a release."
exit 1
fi
- name: Dry-run cargo publish for git-remote-object-store
run: cargo publish -p git-remote-object-store --dry-run --locked
- name: Extract release notes from CHANGELOG
env:
VERSION: ${{ steps.tag.outputs.version }}
run: |
set -eu
if ! grep -Fq "## [${VERSION}]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no section for [${VERSION}]"
exit 1
fi
# Forward-looking check: every release should leave a fresh
# `[Unreleased]` heading at the top so future commits have a
# place to land. Forgetting to re-add it after promoting it
# to a version section is silent — the *next* release would
# have nowhere to record changes — so fail-fast here instead.
if ! grep -q '^## \[Unreleased\]' CHANGELOG.md; then
echo "::error::CHANGELOG.md has no [Unreleased] section. After promoting [Unreleased] to [${VERSION}], add a fresh empty [Unreleased] heading above it."
exit 1
fi
# Fixed-string match on the section header — avoids treating
# dots in the version as regex wildcards.
awk -v header="## [${VERSION}]" '
index($0, header) == 1 { capture=1; next }
capture && /^## \[/ { exit }
capture { print }
' CHANGELOG.md > release-notes.md
if [[ ! -s release-notes.md ]]; then
echo "::error::Extracted release notes are empty"
exit 1
fi
- name: Classify release
id: classify
env:
TAG: ${{ steps.tag.outputs.tag }}
VERSION: ${{ steps.tag.outputs.version }}
run: |
set -eu
PRERELEASE=false
if [[ "$TAG" == *-* ]]; then PRERELEASE=true; fi
{
echo "tag=$TAG"
echo "version=$VERSION"
echo "prerelease=$PRERELEASE"
echo "ref=$TAG"
} >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: release-notes
path: release-notes.md
if-no-files-found: error
build:
name: build ${{ matrix.target }}
needs: preflight
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { target: x86_64-unknown-linux-gnu, runner: ubuntu-22.04, cross: false }
- { target: aarch64-unknown-linux-gnu, runner: ubuntu-22.04, cross: true }
- { target: x86_64-unknown-linux-musl, runner: ubuntu-latest, cross: true }
- { target: aarch64-unknown-linux-musl, runner: ubuntu-latest, cross: true }
- { target: x86_64-unknown-freebsd, runner: ubuntu-latest, cross: true }
- { target: aarch64-apple-darwin, runner: macos-latest, cross: false }
- { target: x86_64-pc-windows-msvc, runner: windows-latest, cross: false }
- { target: aarch64-pc-windows-msvc, runner: windows-latest, cross: false }
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}
- uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 with:
toolchain: ${{ needs.preflight.outputs.toolchain }}
targets: ${{ matrix.target }}
- name: Install cross (Linux / FreeBSD cross-compile)
if: matrix.cross
uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad with:
tool: cross@${{ env.CROSS_VERSION }}
- name: Install cargo-about
uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad with:
tool: cargo-about@${{ env.CARGO_ABOUT_VERSION }}
- name: Build release binaries
shell: bash
env:
TARGET: ${{ matrix.target }}
USE_CROSS: ${{ matrix.cross }}
run: |
set -eu
if [[ "$USE_CROSS" == "true" ]]; then
cross build --release --locked --target "$TARGET" -p git-remote-object-store-cli
else
cargo build --release --locked --target "$TARGET" -p git-remote-object-store-cli
fi
- name: Stage archive contents
shell: bash
env:
VERSION: ${{ needs.preflight.outputs.version }}
TARGET: ${{ matrix.target }}
run: |
set -eu
STAGE="git-remote-object-store-${VERSION}-${TARGET}"
mkdir -p "dist/${STAGE}"
BIN_SUFFIX=""
if [[ "$TARGET" == *windows* ]]; then BIN_SUFFIX=".exe"; fi
for bin in git-remote-s3-https git-remote-s3-http \
git-remote-az-https git-remote-az-http \
git-remote-object-store git-lfs-object-store; do
cp "target/${TARGET}/release/${bin}${BIN_SUFFIX}" "dist/${STAGE}/${bin}${BIN_SUFFIX}"
done
cp README.md LICENSE CHANGELOG.md "dist/${STAGE}/"
# Transitive-dependency attributions. Apache-2.0 §4(d) and
# ring's `Apache-2.0 AND ISC` compound license require us to
# ship these alongside the binary.
cargo about generate --locked \
--config about.toml \
--target "$TARGET" \
--manifest-path cli/Cargo.toml \
about.hbs \
> "dist/${STAGE}/THIRD-PARTY-LICENSES.md"
echo "STAGE=$STAGE" >> "$GITHUB_ENV"
- name: Install llvm-objcopy (ELF-agnostic strip/objcopy)
if: ${{ !contains(matrix.target, 'windows') && !contains(matrix.target, 'apple') }}
run: |
set -eu
if ! command -v llvm-objcopy >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends llvm
fi
llvm-objcopy --version | head -n1
- name: Strip binaries and extract debug symbols (Unix)
if: ${{ !contains(matrix.target, 'windows') }}
shell: bash
env:
TARGET: ${{ matrix.target }}
run: |
set -eu
mkdir -p dist/debug
for bin in git-remote-s3-https git-remote-s3-http \
git-remote-az-https git-remote-az-http \
git-remote-object-store git-lfs-object-store; do
BIN="dist/${STAGE}/${bin}"
if [[ "$TARGET" == *apple* ]]; then
dsymutil "$BIN" -o "dist/${STAGE}/${bin}.dSYM"
strip -x "$BIN"
tar -czf "dist/debug/${STAGE}-${bin}.dSYM.tar.gz" -C "dist/${STAGE}" "${bin}.dSYM"
rm -rf "dist/${STAGE}/${bin}.dSYM"
else
llvm-objcopy --only-keep-debug "$BIN" "dist/debug/${STAGE}-${bin}.debug"
llvm-strip --strip-unneeded "$BIN"
llvm-objcopy --add-gnu-debuglink="dist/debug/${STAGE}-${bin}.debug" "$BIN"
fi
done
- name: Collect PDB debug symbols (Windows)
if: contains(matrix.target, 'windows')
shell: bash
env:
TARGET: ${{ matrix.target }}
run: |
set -eu
mkdir -p dist/debug
for bin in git-remote-s3-https git-remote-s3-http \
git-remote-az-https git-remote-az-http \
git-remote-object-store git-lfs-object-store; do
PDB="target/${TARGET}/release/${bin}.pdb"
if [[ -f "$PDB" ]]; then
cp "$PDB" "dist/debug/${STAGE}-${bin}.pdb"
fi
done
- name: Create archive (Unix)
if: ${{ !contains(matrix.target, 'windows') }}
shell: bash
run: |
set -eu
cd dist
tar -czf "${STAGE}.tar.gz" "${STAGE}"
rm -rf "${STAGE}"
- name: Create archive (Windows)
if: contains(matrix.target, 'windows')
shell: pwsh
run: |
Set-Location dist
Compress-Archive -Path $env:STAGE -DestinationPath "$env:STAGE.zip"
Remove-Item -Recurse -Force $env:STAGE
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: archive-${{ matrix.target }}
path: |
dist/*.tar.gz
dist/*.zip
if-no-files-found: error
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: debug-${{ matrix.target }}
path: dist/debug/
if-no-files-found: ignore
package-deb:
name: package .deb (${{ matrix.deb_arch }})
needs: [preflight, build]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { target: x86_64-unknown-linux-gnu, deb_arch: amd64 }
- { target: aarch64-unknown-linux-gnu, deb_arch: arm64 }
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}
- uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 with:
toolchain: ${{ needs.preflight.outputs.toolchain }}
targets: ${{ matrix.target }}
- uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad with:
tool: cargo-deb@${{ env.CARGO_DEB_VERSION }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: archive-${{ matrix.target }}
path: dist
- name: Unpack archive into cargo-deb target layout
env:
VERSION: ${{ needs.preflight.outputs.version }}
TARGET: ${{ matrix.target }}
run: |
set -eu
STAGE="git-remote-object-store-${VERSION}-${TARGET}"
tar -xzf "dist/${STAGE}.tar.gz" -C dist
# cargo-deb with --target looks in target/<triple>/release/.
mkdir -p "target/${TARGET}/release"
for bin in git-remote-s3-https git-remote-s3-http \
git-remote-az-https git-remote-az-http \
git-remote-object-store git-lfs-object-store; do
cp "dist/${STAGE}/${bin}" "target/${TARGET}/release/${bin}"
chmod 755 "target/${TARGET}/release/${bin}"
done
cp "dist/${STAGE}/THIRD-PARTY-LICENSES.md" THIRD-PARTY-LICENSES.md
- name: Build .deb
env:
TARGET: ${{ matrix.target }}
run: |
set -eu
mkdir -p out
cargo deb --no-build --no-strip \
-p git-remote-object-store-cli \
--target "$TARGET" \
--output "out/"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: deb-${{ matrix.deb_arch }}
path: out/*.deb
if-no-files-found: error
package-rpm:
name: package .rpm (${{ matrix.rpm_arch }})
needs: [preflight, build]
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
include:
- { target: x86_64-unknown-linux-gnu, rpm_arch: x86_64 }
- { target: aarch64-unknown-linux-gnu, rpm_arch: aarch64 }
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}
- uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 with:
toolchain: ${{ needs.preflight.outputs.toolchain }}
targets: ${{ matrix.target }}
- uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad with:
tool: cargo-generate-rpm@${{ env.CARGO_GENERATE_RPM_VERSION }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: archive-${{ matrix.target }}
path: dist
- name: Unpack archive into cargo-generate-rpm layout
env:
VERSION: ${{ needs.preflight.outputs.version }}
TARGET: ${{ matrix.target }}
run: |
set -eu
STAGE="git-remote-object-store-${VERSION}-${TARGET}"
tar -xzf "dist/${STAGE}.tar.gz" -C dist
# cargo-generate-rpm looks up `target/release/<bin>` assets
# relative to the workspace. When cross-building, the
# real binary lives under target/<triple>/release/. Populate
# both paths so the tool finds the binary regardless of
# whether its --target handling rewrites the asset path.
mkdir -p "target/${TARGET}/release" target/release
for bin in git-remote-s3-https git-remote-s3-http \
git-remote-az-https git-remote-az-http \
git-remote-object-store git-lfs-object-store; do
cp "dist/${STAGE}/${bin}" "target/${TARGET}/release/${bin}"
cp "dist/${STAGE}/${bin}" "target/release/${bin}"
done
cp "dist/${STAGE}/THIRD-PARTY-LICENSES.md" THIRD-PARTY-LICENSES.md
- name: Build .rpm
env:
TARGET: ${{ matrix.target }}
RPM_ARCH: ${{ matrix.rpm_arch }}
run: |
set -eu
mkdir -p out
# `cargo generate-rpm -p` takes a path to the crate
# directory (the CLI package lives at `cli/`), not the
# crate name. Cf. host-identity's
# `-p crates/host-identity-cli` invocation.
cargo generate-rpm \
-p cli \
--target "$TARGET" \
--arch "$RPM_ARCH" \
--payload-compress zstd \
--output "out/"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: rpm-${{ matrix.rpm_arch }}
path: out/*.rpm
if-no-files-found: error
package-apk:
name: package .apk (${{ matrix.apk_arch }})
needs: [preflight, build]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { target: x86_64-unknown-linux-musl, apk_arch: x86_64 }
- { target: aarch64-unknown-linux-musl, apk_arch: aarch64 }
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: archive-${{ matrix.target }}
path: dist
- name: Render APKBUILD
env:
VERSION: ${{ needs.preflight.outputs.version }}
TARGET: ${{ matrix.target }}
APK_ARCH: ${{ matrix.apk_arch }}
run: |
set -eu
STAGE="git-remote-object-store-${VERSION}-${TARGET}"
SHA512=$(sha512sum "dist/${STAGE}.tar.gz" | awk '{print $1}')
# Hyphen→underscore for abuild pkgver; see APKBUILD.in for rationale.
APK_PKGVER="${VERSION//-/_}"
mkdir -p build/git-remote-object-store
cp "dist/${STAGE}.tar.gz" "build/git-remote-object-store/"
sed -e "s|@@VERSION@@|${VERSION}|g" \
-e "s|@@APK_PKGVER@@|${APK_PKGVER}|g" \
-e "s|@@TARGET@@|${TARGET}|g" \
-e "s|@@ARCH@@|${APK_ARCH}|g" \
-e "s|@@SHA512@@|${SHA512}|g" \
packaging/alpine/APKBUILD.in > build/git-remote-object-store/APKBUILD
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 if: matrix.apk_arch != 'x86_64'
- name: Build .apk inside alpine:3.20 container
env:
APK_ARCH: ${{ matrix.apk_arch }}
ABUILD_PRIV: ${{ secrets.ALPINE_ABUILD_KEY_PRIV }}
ABUILD_PUB: ${{ secrets.ALPINE_ABUILD_KEY_PUB }}
run: |
set -eu
mkdir -p out/apk
DOCKER_PLATFORM=$([[ "$APK_ARCH" == "aarch64" ]] && echo linux/arm64 || echo linux/amd64)
# Pass secrets via mounted files, not env — keeps them out of
# `docker inspect` and `ps` for the container.
SECRETS_DIR=$(mktemp -d)
trap 'rm -rf "$SECRETS_DIR"' EXIT
if [[ -n "${ABUILD_PRIV:-}" && -n "${ABUILD_PUB:-}" ]]; then
printf '%s' "$ABUILD_PRIV" > "$SECRETS_DIR/git-remote-object-store.rsa"
printf '%s' "$ABUILD_PUB" > "$SECRETS_DIR/git-remote-object-store.rsa.pub"
SIGNED=1
else
echo "::warning::ALPINE_ABUILD_KEY_* secrets not set; abuild will generate an ephemeral key"
SIGNED=0
fi
# Pass SIGNED and APK_ARCH with explicit values. The value-less
# `-e FOO` form reads from the current process's exported
# environment, but these are plain shell locals, not exports.
docker run --rm --platform "$DOCKER_PLATFORM" \
-v "$PWD/build:/build" \
-v "$PWD/out/apk:/out" \
-v "$SECRETS_DIR:/keys:ro" \
-e APK_ARCH="$APK_ARCH" \
-e SIGNED="$SIGNED" \
alpine:3.20 sh -eu -c '
apk add --no-cache alpine-sdk
adduser -D -G abuild builder
if [ "$SIGNED" = "1" ]; then
mkdir -p /home/builder/.abuild /etc/apk/keys
install -m 600 -o builder -g abuild /keys/git-remote-object-store.rsa /home/builder/.abuild/git-remote-object-store.rsa
install -m 644 /keys/git-remote-object-store.rsa.pub /etc/apk/keys/git-remote-object-store.rsa.pub
install -m 644 -o builder -g abuild /keys/git-remote-object-store.rsa.pub /home/builder/.abuild/git-remote-object-store.rsa.pub
echo "PACKAGER_PRIVKEY=/home/builder/.abuild/git-remote-object-store.rsa" > /home/builder/.abuild/abuild.conf
chown builder:abuild /home/builder/.abuild/abuild.conf
else
su builder -c "abuild-keygen -a -n"
fi
chown -R builder:abuild /build
cp -r /build/git-remote-object-store /home/builder/git-remote-object-store
chown -R builder:abuild /home/builder/git-remote-object-store
# abuild defaults SRCDEST to /var/cache/distfiles and will
# wget from the `source=` URL when the tarball is not there.
# The GitHub Release for the tag does not exist yet at this
# stage — `publish` runs later — so the fetch 404s. Point
# SRCDEST at the pre-copied tarball alongside the APKBUILD.
su builder -c "cd /home/builder/git-remote-object-store && SRCDEST=/home/builder/git-remote-object-store abuild -F -r"
# Inside `sh -c '...'` (single-quoted), `*` is already
# literal — escaping the quotes around `*.apk` would pass
# the literal string `"*.apk"` to find, which would match
# nothing and silently leave /out empty.
find /home/builder/packages -name "*.apk" -exec cp {} /out/ \;
cp /home/builder/git-remote-object-store/APKBUILD /out/APKBUILD.${APK_ARCH}
'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: apk-${{ matrix.apk_arch }}
path: out/apk/**
if-no-files-found: error
smoke-deb:
name: smoke .deb (${{ matrix.image }} / ${{ matrix.arch }})
needs: [preflight, package-deb]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
image: [ubuntu:22.04, ubuntu:24.04, debian:12]
arch: [amd64, arm64]
steps:
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 if: matrix.arch == 'arm64'
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: deb-${{ matrix.arch }}
path: dist
- name: Install and run inside ${{ matrix.image }} (${{ matrix.arch }})
env:
VERSION: ${{ needs.preflight.outputs.version }}
IMAGE: ${{ matrix.image }}
ARCH: ${{ matrix.arch }}
run: |
set -eu
docker run --rm --platform "linux/${ARCH}" \
-v "$PWD/dist:/w" -e VERSION -e DEBIAN_FRONTEND=noninteractive \
"$IMAGE" bash -eux -c '
# NO APOSTROPHES allowed anywhere in this block.
# The outer wrapper is bash -eux -c single-quoted; any
# stray apostrophe here closes that quote and leaks the
# rest of the script onto the host shell, where it runs
# against the runner filesystem instead of the docker
# container. Earlier release runs masked this for weeks
# because the for-loop happened to run before the first
# stray apostrophe (a possessive in a comment), so the
# binary checks passed inside docker while the TPL check
# silently ran on the host and naturally failed.
# Write a dpkg path-include override so our package docs
# survive whatever path-exclude rules the runner image
# carries. The zz- prefix puts this last in cfg.d load
# order; dpkg uses the last matching rule per path.
mkdir -p /etc/dpkg/dpkg.cfg.d
echo "path-include=/usr/share/doc/git-remote-object-store/*" \
> /etc/dpkg/dpkg.cfg.d/zz-grobs-include-docs
apt-get update -qq
apt-get install -y -qq /w/*.deb
git-remote-object-store --version | grep -F "$VERSION"
for bin in git-remote-s3-https git-remote-s3-http \
git-remote-az-https git-remote-az-http \
git-remote-object-store git-lfs-object-store \
git-remote-s3+https git-remote-s3+http \
git-remote-az+https git-remote-az+http; do
test -e "/usr/bin/$bin" || {
echo "::error::missing /usr/bin/$bin" >&2
ls -la /usr/bin/git-* >&2 || true
exit 1
}
done
echo "smoke: all 6 binaries + 4 +-form symlinks present" >&2
# Content regression guard: ring ships Apache-2.0 AND ISC,
# both texts mandatory. If either drops out, the bundle is
# non-compliant. Run in one lane only — deb.
# The two ^### <name> greps couple to cargo-about display
# names (rendered via about.hbs as ### {{name}} per
# license group). If a future cargo-about bump renames
# either (ISC License -> ISC, Apache License 2.0 ->
# Apache-2.0), update these regexes in lockstep. The
# crate-name grep (ring) is the stable cross-version
# anchor — it would survive a template rename and still
# catch the absence of the dep entirely.
TPL=/usr/share/doc/git-remote-object-store/THIRD-PARTY-LICENSES.md
if [ ! -s "$TPL" ]; then
echo "::error::$TPL is missing or empty" >&2
ls -la /usr/share/doc/git-remote-object-store/ >&2 || true
echo "--- dpkg.cfg.d at install time: ---" >&2
ls -la /etc/dpkg/dpkg.cfg.d/ >&2 || true
exit 1
fi
echo "smoke: TPL present, size=$(stat -c %s "$TPL")" >&2
if ! grep -qF "ring" "$TPL"; then
echo "::error::$TPL has no reference to crate \`ring\`" >&2
exit 1
fi
# Section-header assertions — both license texts must
# appear at minimum as named groups in the rendered file.
if ! grep -qE "^### (ISC( License)?|ISC)$" "$TPL"; then
echo "::error::$TPL missing ISC license group heading" >&2
grep -E "^### " "$TPL" >&2 || true
exit 1
fi
if ! grep -qE "^### (Apache( License)?[- ]?2\.0|Apache-2\.0)$" "$TPL"; then
echo "::error::$TPL missing Apache-2.0 license group heading" >&2
grep -E "^### " "$TPL" >&2 || true
exit 1
fi
echo "smoke: all assertions passed" >&2
'
smoke-rpm:
name: smoke .rpm (${{ matrix.image }} / ${{ matrix.arch }})
needs: [preflight, package-rpm]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
image: [rockylinux:9, fedora:latest, amazonlinux:2023]
arch: [x86_64, aarch64]
steps:
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 if: matrix.arch == 'aarch64'
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: rpm-${{ matrix.arch }}
path: dist
- name: Install and run inside ${{ matrix.image }} (${{ matrix.arch }})
env:
VERSION: ${{ needs.preflight.outputs.version }}
IMAGE: ${{ matrix.image }}
ARCH: ${{ matrix.arch }}
run: |
set -eu
DOCKER_ARCH=$([[ "$ARCH" == "aarch64" ]] && echo arm64 || echo amd64)
docker run --rm --platform "linux/${DOCKER_ARCH}" \
-v "$PWD/dist:/w" -e VERSION \
"$IMAGE" bash -eu -c '
if command -v dnf >/dev/null; then PKG=dnf; else PKG=yum; fi
# Fedora base images ship /etc/dnf/dnf.conf with
# tsflags=nodocs to keep image sizes small. That filter
# strips /usr/share/doc, including the bundled
# THIRD-PARTY-LICENSES.md the smoke check asserts. Force
# docs back on via --setopt regardless of conf state —
# safer than editing the file (the value can be a
# comma-list, e.g. `tsflags=nodocs,test`).
$PKG install -y --setopt=tsflags= git
$PKG install -y --setopt=tsflags= /w/*.rpm
git-remote-object-store --version | grep -F "$VERSION"
for bin in git-remote-s3-https git-remote-s3-http \
git-remote-az-https git-remote-az-http \
git-remote-object-store git-lfs-object-store \
git-remote-s3+https git-remote-s3+http \
git-remote-az+https git-remote-az+http; do
test -e "/usr/bin/$bin" || { echo "missing /usr/bin/$bin"; exit 1; }
done
test -s /usr/share/doc/git-remote-object-store/THIRD-PARTY-LICENSES.md
'
smoke-apk:
name: smoke .apk (${{ matrix.arch }})
needs: [preflight, package-apk]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
arch: [x86_64, aarch64]
steps:
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 if: matrix.arch == 'aarch64'
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: apk-${{ matrix.arch }}
path: dist
- name: Install and run inside alpine:3.20 (${{ matrix.arch }})
env:
VERSION: ${{ needs.preflight.outputs.version }}
ARCH: ${{ matrix.arch }}
run: |
set -eu
DOCKER_ARCH=$([[ "$ARCH" == "aarch64" ]] && echo arm64 || echo amd64)
docker run --rm --platform "linux/${DOCKER_ARCH}" \
-v "$PWD/dist:/w" -e VERSION \
alpine:3.20 sh -eux -c '
# `path: out/apk/**` upload preserves the artifact directory
# structure, so the .apk may land at /w/*.apk or /w/apk/*.apk
# depending on upload-artifact version. Locate it instead of
# relying on a fixed glob.
APK=$(find /w -name "*.apk" | head -n1)
test -n "$APK" || { echo "no .apk found under /w"; find /w; exit 1; }
apk add --no-cache git
apk add --no-cache --allow-untrusted "$APK"
git-remote-object-store --version | grep -F "$VERSION"
test -s /usr/share/licenses/git-remote-object-store/THIRD-PARTY-LICENSES.md
'
smoke-macos:
name: smoke macOS tarball (${{ matrix.runner }})
needs: [preflight, build]
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { runner: macos-latest, target: aarch64-apple-darwin }
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: archive-${{ matrix.target }}
path: dist
- name: Extract and run
env:
VERSION: ${{ needs.preflight.outputs.version }}
TARGET: ${{ matrix.target }}
run: |
set -eu
STAGE="git-remote-object-store-${VERSION}-${TARGET}"
tar -xzf "dist/${STAGE}.tar.gz" -C dist
"dist/${STAGE}/git-remote-object-store" --version | grep -F "$VERSION"
test -s "dist/${STAGE}/THIRD-PARTY-LICENSES.md"
smoke-windows:
name: smoke Windows zip
needs: [preflight, build]
runs-on: windows-latest
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: archive-x86_64-pc-windows-msvc
path: dist
- shell: pwsh
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: |
Set-Location dist
$zip = Get-ChildItem *.zip | Select-Object -First 1
Expand-Archive $zip.FullName -DestinationPath extracted
$exe = Get-ChildItem -Recurse extracted -Filter git-remote-object-store.exe | Select-Object -First 1
$out = & $exe.FullName --version
if ($out -notmatch [regex]::Escape($env:VERSION)) {
throw "version mismatch: $out"
}
# $tpl is a FileInfo; .Length on FileInfo is file size in bytes.
$tpl = Get-ChildItem -Recurse extracted -Filter THIRD-PARTY-LICENSES.md | Select-Object -First 1
if (-not $tpl -or $tpl.Length -eq 0) {
throw "THIRD-PARTY-LICENSES.md missing or empty in zip"
}
sign-attest:
name: sign + attest
needs:
- preflight
- build
- package-deb
- package-rpm
- package-apk
- smoke-deb
- smoke-rpm
- smoke-apk
- smoke-macos
- smoke-windows
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}
- name: Gather all artefacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: "*"
path: incoming
- name: Flatten into release/
run: |
set -eu
mkdir -p release
# Every artefact's contents move to the top of release/. Duplicate
# file names across artefacts would collide, but our naming
# scheme (`git-remote-object-store-<ver>-<target>.<ext>`,
# APKBUILD.<arch>) is collision-free by construction.
# release-notes.md is explicitly excluded — it's consumed by the
# publish step as `body_path` and must not appear in SHA256SUMS
# (it isn't attached as a release asset, so verifiers running
# `sha256sum -c SHA256SUMS` would fail on a missing file).
find incoming -type f ! -name 'release-notes.md' \
-exec cp -n {} release/ \;
ls -la release/
- uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 with:
toolchain: ${{ needs.preflight.outputs.toolchain }}
- uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad with:
tool: cargo-cyclonedx@${{ env.CARGO_CYCLONEDX_VERSION }}
- name: Generate CycloneDX SBOMs
run: |
set -eu
cargo cyclonedx --format json --all --describe crate --spec-version 1.5
cp git-remote-object-store.cdx.json release/
cp cli/git-remote-object-store-cli.cdx.json release/
- name: Compute SHA256SUMS
working-directory: release
run: |
set -eu
find . -maxdepth 1 -type f ! -name 'SHA256SUMS*' -print0 \
| sort -z \
| xargs -0 sha256sum > SHA256SUMS
- name: Install minisign
run: sudo apt-get update && sudo apt-get install -y minisign
- name: Sign SHA256SUMS with minisign
env:
MINISIGN_SECRET: ${{ secrets.MINISIGN_SECRET_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
run: |
set -eu
if [[ -z "${MINISIGN_SECRET:-}" ]]; then
echo "::error::MINISIGN_SECRET_KEY not configured"
exit 1
fi
KEYFILE=$(mktemp)
trap 'rm -f "$KEYFILE"' EXIT
printf '%s' "$MINISIGN_SECRET" > "$KEYFILE"
chmod 600 "$KEYFILE"
printf '%s\n' "$MINISIGN_PASSWORD" | minisign -S -s "$KEYFILE" \
-m release/SHA256SUMS -x release/SHA256SUMS.minisig
- name: SLSA build provenance
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 with:
subject-path: |
release/*.tar.gz
release/*.zip
release/*.deb
release/*.rpm
release/*.apk
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: release-bundle
path: release/
if-no-files-found: error
publish:
name: publish
needs: [preflight, sign-attest]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: release-bundle
path: release
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: release-notes
path: .
- name: Attach artefacts to GitHub Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda with:
tag_name: ${{ needs.preflight.outputs.tag }}
body_path: release-notes.md
prerelease: ${{ needs.preflight.outputs.prerelease == 'true' }}
fail_on_unmatched_files: false
files: |
release/*.tar.gz
release/*.zip
release/*.deb
release/*.rpm
release/*.apk
release/*.debug
release/*.pdb
release/*.cdx.json
release/SHA256SUMS
release/SHA256SUMS.minisig
release/APKBUILD.*
- name: Compute Homebrew SHA-256
if: needs.preflight.outputs.prerelease != 'true'
id: hashes
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -eu
SHA=$(sha256sum "release/git-remote-object-store-${VERSION}-aarch64-apple-darwin.tar.gz" | awk '{print $1}')
echo "macos_aarch64=$SHA" >> "$GITHUB_OUTPUT"
- name: Render Homebrew formula
if: needs.preflight.outputs.prerelease != 'true'
env:
VERSION: ${{ needs.preflight.outputs.version }}
MACOS_AARCH64: ${{ steps.hashes.outputs.macos_aarch64 }}
run: |
set -eu
sed -e "s|@@VERSION@@|${VERSION}|g" \
-e "s|@@SHA256_AARCH64@@|${MACOS_AARCH64}|g" \
packaging/homebrew/git-remote-object-store.rb.tmpl > git-remote-object-store.rb
- name: Push Homebrew formula to tap repo
if: needs.preflight.outputs.prerelease != 'true'
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -eu
if [[ -z "${HOMEBREW_TAP_TOKEN:-}" ]]; then
echo "HOMEBREW_TAP_TOKEN not set — skipping tap push"
exit 0
fi
REPO="https://github.com/dekobon/homebrew-tap.git"
AUTH_HEADER="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$HOMEBREW_TAP_TOKEN" | base64 -w0)"
# Hard fail on unreachable tap: the repo is known to exist (it
# already hosts sibling formulae), so a failure here means the
# PAT is missing scope, expired, or revoked — a misconfiguration
# that would otherwise let the GitHub Release and crates.io
# publish land without the formula update and drift unnoticed.
if ! git -c "http.extraheader=$AUTH_HEADER" ls-remote "$REPO" &>/dev/null; then
echo "::error::tap repo $REPO not reachable — check HOMEBREW_TAP_TOKEN scope"
exit 1
fi
git -c "http.extraheader=$AUTH_HEADER" clone "$REPO" tap
mkdir -p tap/Formula
cp git-remote-object-store.rb tap/Formula/git-remote-object-store.rb
cd tap
git config user.name "git-remote-object-store release bot"
git config user.email "release-bot@git-remote-object-store"
git add Formula/git-remote-object-store.rb
if git diff --cached --quiet; then
echo "Formula unchanged — skipping push"
exit 0
fi
git commit -m "git-remote-object-store ${VERSION}"
# Push with rebase-on-reject: the tap is shared with sibling
# `dekobon/*` release pipelines, so `main` may move between our
# clone and push. Rebase our single-file commit onto the latest
# main and retry. Rebase is conflict-free as long as the racing
# commit touches a different formula; a genuine collision on
# Formula/git-remote-object-store.rb means two concurrent
# publishes of this crate, which should fail loudly.
MAX_PUSH_ATTEMPTS=5
attempt=1
while true; do
if git -c "http.extraheader=$AUTH_HEADER" push origin HEAD; then
break
fi
if [[ "$attempt" -ge "$MAX_PUSH_ATTEMPTS" ]]; then
echo "::error::tap push failed after $MAX_PUSH_ATTEMPTS attempts"
exit 1
fi
echo "tap push attempt $attempt rejected; fetching origin/main and rebasing"
git -c "http.extraheader=$AUTH_HEADER" fetch origin main
git rebase origin/main
attempt=$((attempt + 1))
done
publish-crates:
name: publish to crates.io
needs: [preflight, sign-attest]
if: ${{ needs.preflight.outputs.prerelease != 'true' }}
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}
- uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 with:
toolchain: ${{ needs.preflight.outputs.toolchain }}
- name: Authenticate to crates.io via Trusted Publishing
id: auth
uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe
- name: Publish git-remote-object-store
env:
VERSION: ${{ needs.preflight.outputs.version }}
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run: |
set -eu
# Query the sparse index (CDN-backed, no UA policing) rather
# than the HTML API, which has rate-limited and UA-gated CI
# runners in the past. Each line is one published version.
INDEX="https://index.crates.io/gi/t-/git-remote-object-store"
if curl -sfL "$INDEX" 2>/dev/null | grep -q "\"vers\":\"${VERSION}\""; then
echo "git-remote-object-store ${VERSION} already on crates.io — skipping"
else
cargo publish -p git-remote-object-store --locked
fi
- name: Publish git-remote-object-store-cli
env:
VERSION: ${{ needs.preflight.outputs.version }}
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run: |
set -eu
INDEX="https://index.crates.io/gi/t-/git-remote-object-store-cli"
if curl -sfL "$INDEX" 2>/dev/null | grep -q "\"vers\":\"${VERSION}\""; then
echo "git-remote-object-store-cli ${VERSION} already on crates.io — skipping"
else
cargo publish -p git-remote-object-store-cli --locked
fi
verify:
name: post-publish verify
needs: [preflight, publish]
runs-on: ubuntu-latest
permissions:
contents: read
attestations: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}
- name: Install minisign
run: sudo apt-get update && sudo apt-get install -y minisign
- name: Download artefact + signature from published release
env:
TAG: ${{ needs.preflight.outputs.tag }}
VERSION: ${{ needs.preflight.outputs.version }}
GH_TOKEN: ${{ github.token }}
run: |
set -eu
mkdir verify && cd verify
ART="git-remote-object-store-${VERSION}-x86_64-unknown-linux-musl.tar.gz"
gh release download "$TAG" -R "${{ github.repository }}" \
-p "$ART" -p SHA256SUMS -p SHA256SUMS.minisig
- name: Verify minisign signature and checksum
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -eu
cd verify
minisign -Vm SHA256SUMS -p ../minisign.pub
grep "git-remote-object-store-${VERSION}-x86_64-unknown-linux-musl.tar.gz" SHA256SUMS \
| sha256sum -c
- name: Verify SLSA provenance
env:
VERSION: ${{ needs.preflight.outputs.version }}
GH_TOKEN: ${{ github.token }}
run: |
set -eu
cd verify
gh attestation verify "git-remote-object-store-${VERSION}-x86_64-unknown-linux-musl.tar.gz" \
-R "${{ github.repository }}"