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
variables:
RELEASE_STYLE: RK_STYLE
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
# The trunk style's standing arm, re-applied on every refresh under the
# bot token — a merge under the job token would land a bump that
# releases nothing — and filtered by the exact request branch this job
# owns. An arm the forge cannot take yet — the request's first pipeline
# not started — waits for the next push rather than failing the job;
# the lines style merges by hand and arms nothing.
if [ "$RELEASE_STYLE" != "trunk" ]; then
echo "the lines style merges each release by hand; not arming."
exit 0
fi
if [ "$CI_COMMIT_BRANCH" != "master" ]; then
echo "a line's request is never armed; its candidate is what a human validated."
exit 0
fi
iid="$(api "$project/merge_requests?state=opened&source_branch=chore%2Frelease-v$next&target_branch=$CI_COMMIT_BRANCH" | grep -o '"iid":[0-9]*' | head -n 1 | cut -d : -f 2)"
if [ -z "$iid" ]; then
echo "no release request to arm."
exit 0
fi
if api -X PUT "$project/merge_requests/$iid/merge?auto_merge=true&merge_when_pipeline_succeeds=true&squash=true&should_remove_source_branch=true" >/dev/null 2>&1; then
echo "armed merge request !$iid."
else
echo "could not arm !$iid yet — its pipeline has not started; the next trunk push re-arms."
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."