include:
- local: .gitlab/ci/mr-title.yml
stages:
- release
- provenance
- publish
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_PROJECT_ROOT_NAMESPACE == "OWNER" && ($CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH =~ /^release\//)'
.tooling:
stage: release
image: alpine:3
variables:
GIT_DEPTH: "0"
before_script:
- apk add --no-cache curl git make tar
- curl -fsSL "https://github.com/orhun/git-cliff/releases/download/v2.13.1/git-cliff-2.13.1-x86_64-unknown-linux-musl.tar.gz" | tar -xz --strip-components 1 -C /usr/local/bin
release-request:
extends: .tooling
rules:
- if: '$CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH =~ /^release\//'
resource_group: release-request
script:
- |
set -eu
api() { curl -fsS --header "PRIVATE-TOKEN: $RELEASE_BOT_TOKEN" "$@"; }
project="$CI_API_V4_URL/projects/$CI_PROJECT_ID"
current="$(cat VERSION)"
next="$(git cliff --bumped-version | sed 's/^v//')"
if [ "$next" = "$current" ]; then
echo "no release-worthy commits since v$current; no request to maintain."
exit 0
fi
if api "$project/repository/tags/v$next" >/dev/null 2>&1; then
echo "v$next is already tagged; no request to maintain."
exit 0
fi
branch="chore/release-v$next"
git switch -c "$branch"
printf '%s\n' "$next" > VERSION
git cliff --bump -o CHANGELOG.md
git config user.name "release-bot"
git config user.email "release-bot@invalid"
git add VERSION CHANGELOG.md
git commit -m "chore(release): v$next"
git push -f "https://release-bot:${RELEASE_BOT_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" "HEAD:refs/heads/$branch"
open="$(api "$project/merge_requests?state=opened&source_branch=chore%2Frelease-v$next&target_branch=$CI_COMMIT_BRANCH" | grep -c '"iid"' || true)"
if [ "$open" = "0" ]; then
api -X POST "$project/merge_requests" \
--data-urlencode "source_branch=$branch" \
--data-urlencode "target_branch=$CI_COMMIT_BRANCH" \
--data-urlencode "title=chore(release): v$next" \
--data-urlencode "description=Bumps VERSION to $next and rewrites the changelog. Merging this request is the release: the bump push tags v$next and attaches the tarball." >/dev/null
fi
tag-and-build:
extends: .tooling
rules:
- if: '$CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH =~ /^release\//'
resource_group: release
variables:
RUNNER_GENERATE_ARTIFACTS_METADATA: "true"
script:
- |
set -eu
api() { curl -fsS --header "PRIVATE-TOKEN: $RELEASE_BOT_TOKEN" "$@"; }
project="$CI_API_V4_URL/projects/$CI_PROJECT_ID"
# The marker gates the downstream jobs, so a stale or tracked copy in
# the workspace must not survive into the artifacts of a push that
# releases nothing: clear it before deciding.
rm -f release-version
version="$(cat VERSION)"
previous="$(git show HEAD^:VERSION 2>/dev/null || echo "")"
if [ "$previous" = "$version" ]; then
echo "this commit does not bump the version; nothing to release."
exit 0
fi
if ! api "$project/repository/tags/v$version" >/dev/null 2>&1; then
api -X POST "$project/repository/tags" \
--data-urlencode "tag_name=v$version" --data-urlencode "ref=$CI_COMMIT_SHA" >/dev/null
echo "tagged v$version at $CI_COMMIT_SHA."
fi
make dist
git cliff --latest -o notes.md
printf '%s\n' "$version" > release-version
artifacts:
paths:
- dist/
- notes.md
- release-version
expire_in: 7d
provenance:
stage: provenance
needs: ["tag-and-build"]
rules:
- if: '$CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH =~ /^release\//'
image: alpine:3
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
variables:
COSIGN_YES: "true"
COSIGN_VERSION: "3.1.3"
COSIGN_SHA256: "4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71"
before_script:
- apk add --no-cache curl jq
script:
- |
set -eu
if [ ! -f release-version ]; then
echo "no release in flight; nothing to attest."
exit 0
fi
if [ "$CI_SERVER_HOST" != "gitlab.com" ]; then
echo "keyless signing needs GitLab.com: Sigstore's public instance does not trust this instance as an OIDC issuer."
echo "continuing without provenance; the release page will carry the tarball and checksum alone."
exit 0
fi
# A tool whose version floats with a base image is not pinned: fetch
# the release binary at the pin and refuse to run it unless its digest
# matches the one authored here beside the version.
curl -fsSL -o /usr/local/bin/cosign "https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64"
echo "${COSIGN_SHA256} /usr/local/bin/cosign" | sha256sum -c -
chmod +x /usr/local/bin/cosign
tarball="$(ls dist/*.tar.gz | head -n 1)"
# The runner's statement wraps the predicate; cosign re-wraps it over
# the tarball's own digest, so the signed subject is the file itself.
jq -c .predicate artifacts-metadata.json > predicate.json
cosign attest-blob \
--predicate predicate.json \
--type slsaprovenance1 \
--bundle "$tarball.sigstore.json" \
"$tarball"
# Self-verify before anything publishes: the certificate identity is
# the CI configuration path at the ref this release was built from,
# which is what a consumer checks too.
cosign verify-blob-attestation \
--type slsaprovenance1 \
--bundle "$tarball.sigstore.json" \
--certificate-oidc-issuer "https://gitlab.com" \
--certificate-identity "https://gitlab.com/${CI_PROJECT_PATH}//${CI_CONFIG_PATH}@refs/heads/${CI_COMMIT_BRANCH}" \
"$tarball"
echo "provenance signed and verified for $tarball."
artifacts:
paths:
- dist/*.sigstore.json
expire_in: 7d
attach:
stage: publish
needs: ["tag-and-build", "provenance"]
rules:
- if: '$CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH =~ /^release\//'
resource_group: release
image: alpine:3
before_script:
- apk add --no-cache curl jq
script:
- |
set -eu
api() { curl -fsS --header "PRIVATE-TOKEN: $RELEASE_BOT_TOKEN" "$@"; }
project="$CI_API_V4_URL/projects/$CI_PROJECT_ID"
if [ ! -f release-version ]; then
echo "no release in flight; nothing to publish."
exit 0
fi
version="$(cat release-version)"
tarball="$(ls dist/*.tar.gz | head -n 1)"
file="$(basename "$tarball")"
package="$project/packages/generic/release/$version"
# package_name is a fuzzy filter on this API, so select the exact
# package rather than trusting the first row of a substring match.
id="$(api "$project/packages?package_type=generic&package_name=release&package_version=$version&per_page=100" \
| jq -r --arg v "$version" '[ .[] | select(.package_type == "generic" and .name == "release" and .version == $v) ][0].id // empty')"
present=""
if [ -n "$id" ]; then
present="$(api "$project/packages/$id/package_files" | jq -r '.[].file_name')"
fi
upload() {
if printf '%s\n' "$present" | grep -qxF "$(basename "$1")"; then
echo "$(basename "$1") is already in the package registry."
else
curl -fsS --header "PRIVATE-TOKEN: $RELEASE_BOT_TOKEN" --upload-file "$1" "$package/$(basename "$1")" >/dev/null
echo "uploaded $(basename "$1")."
fi
}
upload "$tarball"
upload "$tarball.sha256"
if [ -f "$tarball.sigstore.json" ]; then
upload "$tarball.sigstore.json"
fi
links="$file $file.sha256"
if [ -f "$tarball.sigstore.json" ]; then
links="$links $file.sigstore.json"
fi
if ! api "$project/releases/v$version" >/dev/null 2>&1; then
api -X POST "$project/releases" \
--data-urlencode "tag_name=v$version" \
--data-urlencode "name=v$version" \
--data-urlencode "description=$(cat notes.md)" >/dev/null
echo "released v$version."
fi
existing="$(api "$project/releases/v$version" | jq -r '.assets.links[].name')"
for name in $links; do
if printf '%s\n' "$existing" | grep -qxF "$name"; then
echo "the release already links $name."
else
api -X POST "$project/releases/v$version/assets/links" \
--data-urlencode "name=$name" \
--data-urlencode "url=$package/$name" >/dev/null
echo "linked $name."
fi
done
echo "v$version is published, every declared asset attached."