1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# The whole release pipeline for GitLab CI and the PyPI trusted publisher. # RK_IF_REGISTRY
# This pipeline file is registered at PyPI as the project's top-level # RK_IF_REGISTRY
# pipeline. # RK_IF_REGISTRY
# RK_IF_NO_REGISTRY # The whole release pipeline for GitLab CI. The package publishes to no
# RK_IF_NO_REGISTRY # registry: the tag and the release page with its signed distributions are
# RK_IF_NO_REGISTRY # the release.
#
# The release jobs run on every push to the trunk, and to a RK_LINE_PREFIX* line
# when a project keeps older lines. A last job, release-candidate, runs only by
# hand on a pipeline of a line, and mints or promotes that line's release
# candidate. Each job calls rk, which holds the release
# logic and reads the driver, the style, the integration, and the registry
# answer from the landing record. release-request runs rk release request,
# which keeps the one release merge request, pyproject.toml and uv.lock through
# uv version plus the changelog, against the pushed branch
# (release-engine:one-request-per-base). Merging that request is the release:
# build runs rk release build, which releases only the commit that merges the
# request (release-engine:a-final-version-releases-at-its-request) and builds
# the sdist and the wheel with uv build, provenance signs the runner's SLSA
# statement over each of them, pypi uploads them over OIDC, and publish runs # RK_IF_REGISTRY
# RK_IF_NO_REGISTRY # statement over each of them, and publish runs
# rk release publish, which creates the tag and the GitLab release last
# (release-engine:the-tag-is-created-last).
#
# The provenance and the upload stay in this pipeline, because only a CI job
# holds the OIDC identity that signs and publishes them. rk cannot mint
# either.
#
# Every job authenticates with RELEASE_BOT_TOKEN, the bot identity's token
# the setup stores as a masked CI variable: a project access token, or the
# token of a group service account on a GitLab.com Free group. A push made
# with the default CI job token starts no pipeline, which would silently
# skip the release half, so rk refuses it
# (release-engine:the-request-pushes-as-the-bot).
include:
- local: RK_GITLAB_GATE_FRAGMENT
stages:
- release
- provenance
- upload # RK_IF_REGISTRY
- publish
- test
# Merge request pipelines run the title gate; branch pipelines on the trunk
# and the release lines run the release jobs, whose own rules never match a
# merge request event.
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_PROJECT_ROOT_NAMESPACE == "OWNER" && ($CI_COMMIT_BRANCH == "RK_TRUNK_BRANCH" || $CI_COMMIT_BRANCH =~ /^RK_LINE_PREFIX_RE/)'
# The project's own jobs, in a pipeline of their own. This forge
# reverse-deep-merges an include, so a file the project owns would
# contribute every key the jobs above leave out, whatever the include order;
# a child pipeline is the only shape that isolates. Declare jobs in
# .gitlab/ci/project.yml and they run here, with their own stages and their
# own nested includes, reaching nothing this file declares. strategy: mirror
# carries the child's status into this pipeline, so a failing project job
# blocks the merge; it needs GitLab 18.2 or newer, which is this
# convention's minimum version. The exists: guard keeps this job out of the
# pipeline altogether while the file is absent, so no empty downstream
# pipeline appears. A job in that file needs no merge_request_event rule:
# this rule already scopes it, and inside a child pipeline
# CI_PIPELINE_SOURCE reads parent_pipeline; use CI_MERGE_REQUEST_ID where a
# rule is wanted. The forge document carries the rest.
project-jobs:
stage: test
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
exists:
- .gitlab/ci/project.yml
trigger:
include:
- local: .gitlab/ci/project.yml
strategy: mirror
# The base of the rk jobs. The rk archive targets x86_64-unknown-linux-gnu,
# so the image is a glibc one, pinned by digest, and it carries no uv. It
# installs git-cliff and glab at their pins, each download checked against
# the SHA-256 authored beside its version, uv through its installer at the
# version the project requires, and rk at the version of the release that
# landed this file.
.tooling:
stage: release
# RK_DEV_ENV_IMAGE_BEGIN
image: debian:13.7-slim@sha256:a72b4433de1cf7d6bae0c4cee74d0547f58601a643b3375f070c51600d63b6b2
# RK_DEV_ENV_IMAGE_END
variables:
GIT_DEPTH: "0"
# glab sends a token from its environment only to the host GITLAB_HOST
# names, so both name this instance and the bot.
GITLAB_HOST: $CI_SERVER_FQDN
GITLAB_TOKEN: $RELEASE_BOT_TOKEN
GIT_CLIFF_VERSION: "2.14.2"
GIT_CLIFF_SHA256: "fa21e2fc70046fcfd0e43e5e4613873ab799aff3d9dc8559c00d31b270e417d0"
GLAB_VERSION: "1.130.0"
GLAB_SHA256: "ce06313b2075af25180cd195ab3496d42bc5857c85a1c12ba996838dfe9d822c"
# The uv the project requires: its exact required-version, else the
# version release-kit pins. The installer of that release checks the
# archive it downloads against the digest it carries, and the job checks
# the installer itself against the digest authored beside the pin.
UV_PIN: "0.13.7"
UV_PIN_INSTALLER_SHA256: "d263ee68314cf5bea36b08e6bd9e2c4563240d7adb73756cf33af5c199b92db4"
UV_VERSION: "0.13.7" # RK_IF_UV_UNPINNED
# RK_IF_UV_REQUIRED UV_VERSION: "RK_UV_REQUIRED"
before_script:
# Every tool these jobs fetch goes into one directory outside the
# checkout, on PATH before rk runs, so an image needs no /usr/local and
# no fetched binary lands in the tree rk commits from.
- RELEASE_TOOLS="${TMPDIR:-/tmp}/rk-tools" && mkdir -p "$RELEASE_TOOLS/bin" && export PATH="$RELEASE_TOOLS/bin:$PATH"
# RK_DEV_ENV_SCRIPT_BEGIN
- apt-get update -qq && apt-get install -y --no-install-recommends ca-certificates curl git xz-utils
- curl -fsSL -o "$RELEASE_TOOLS/git-cliff.tar.gz" "https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-musl.tar.gz"
- echo "${GIT_CLIFF_SHA256} $RELEASE_TOOLS/git-cliff.tar.gz" | sha256sum -c -
- tar -xzf "$RELEASE_TOOLS/git-cliff.tar.gz" --strip-components 1 -C "$RELEASE_TOOLS/bin" "git-cliff-${GIT_CLIFF_VERSION}/git-cliff"
- curl -fsSL -o "$RELEASE_TOOLS/uv-installer.sh" "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-installer.sh"
- if [ "$UV_VERSION" = "$UV_PIN" ]; then echo "${UV_PIN_INSTALLER_SHA256} $RELEASE_TOOLS/uv-installer.sh" | sha256sum -c -; fi
- UV_UNMANAGED_INSTALL="$RELEASE_TOOLS/bin" sh "$RELEASE_TOOLS/uv-installer.sh"
# RK_DEV_ENV_SCRIPT_END
- curl -fsSL -o "$RELEASE_TOOLS/glab.tar.gz" "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/glab_${GLAB_VERSION}_linux_amd64.tar.gz"
- echo "${GLAB_SHA256} $RELEASE_TOOLS/glab.tar.gz" | sha256sum -c -
- tar -xzf "$RELEASE_TOOLS/glab.tar.gz" -C "$RELEASE_TOOLS" bin/glab
# RK_DEV_ENV_RK_BEGIN
- curl --proto '=https' --tlsv1.2 -LsSf "RK_SELF_RELEASES/vRK_SELF_VERSION/release-kit-installer.sh" | RELEASE_KIT_UNMANAGED_INSTALL="$RELEASE_TOOLS/bin" sh
# RK_DEV_ENV_RK_END
# The release request. rk opens it on rk-release/<base>, refreshes it in
# place on every push, and arms it where the landing arms it
# (release-engine:the-request-arms-where-the-projection-arms).
release-request:
extends: .tooling
rules:
- if: '$CI_COMMIT_BRANCH == "RK_TRUNK_BRANCH" || $CI_COMMIT_BRANCH =~ /^RK_LINE_PREFIX_RE/'
# One group per branch, here and in every release job below: a line's
# release never waits on another line's.
resource_group: release-request-$CI_COMMIT_REF_SLUG
script:
- RK_DEV_RUN rk release request
# The release build. rk decides from the commit and the forge whether this
# push releases, and skips every push that does not. Where it releases, it
# builds the sdist and the wheel with uv build into dist/, which holds nothing
# else, and creates no tag. Where PyPI already serves a file of the version, # RK_IF_REGISTRY
# it compares the rebuild with its digest and stops on a difference. # RK_IF_REGISTRY
# RUNNER_GENERATE_ARTIFACTS_METADATA makes the runner emit its SLSA v1
# provenance statement beside the exported artifacts, which is what the
# provenance job signs. The build report in .rk-release/ tells the jobs after
# this one whether a release is in flight.
build:
extends: .tooling
rules:
- if: '$CI_COMMIT_BRANCH == "RK_TRUNK_BRANCH" || $CI_COMMIT_BRANCH =~ /^RK_LINE_PREFIX_RE/'
resource_group: release-$CI_COMMIT_REF_SLUG
variables:
RUNNER_GENERATE_ARTIFACTS_METADATA: "true"
script:
- RK_DEV_RUN rk release build
artifacts:
paths:
- dist/
- .rk-release/
expire_in: 7d
# Provenance. The runner already wrote a SLSA v1 provenance statement for the
# build above; this job signs that statement keylessly with cosign over the
# sdist and over the wheel, producing the bundles a consumer verifies. It is a
# build-provenance attestation — the statement carries the source commit, the
# builder identity, and the build parameters — where a bare signature over a
# distribution would prove only that some identity signed a digest. The job is isolated from the build on
# purpose: the signing identity lives here and never in the job that ran the
# project's own build steps. No standing private key is managed anywhere —
# keyless signing mints an ephemeral key pair against the job's OIDC identity
# and discards it, which is what keeps this inside the one signing scheme the
# channel offers.
#
# Sigstore's public instance trusts gitlab.com as an OIDC issuer and no
# self-managed CI_SERVER_URL, so keyless signing works on GitLab.com only;
# elsewhere this job says so and the release continues without provenance
# rather than failing a pipeline that cannot mint certificates.
provenance:
stage: provenance
needs:
rules:
- if: '$CI_COMMIT_BRANCH == "RK_TRUNK_BRANCH" || $CI_COMMIT_BRANCH =~ /^RK_LINE_PREFIX_RE/'
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
state="$(jq -r .state .rk-release/build.json)"
case "$state" in
release|resume) ;;
*)
echo "the build reports $state; nothing to attest."
exit 0
;;
esac
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 distributions and their checksums 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
# The runner's statement wraps the predicate; cosign re-wraps it over
# each distribution's own digest, so the signed subject is the file
# itself.
jq -c .predicate artifacts-metadata.json > predicate.json
for artifact in $(jq -r '.artifacts[].path' .rk-release/build.json); do
cosign attest-blob \
--predicate predicate.json \
--type slsaprovenance1 \
--bundle "$artifact.sigstore.json" \
"$artifact"
# 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 "$artifact.sigstore.json" \
--certificate-oidc-issuer "https://gitlab.com" \
--certificate-identity "https://gitlab.com/${CI_PROJECT_PATH}//${CI_CONFIG_PATH}@refs/heads/${CI_COMMIT_BRANCH}" \
"$artifact"
echo "provenance signed and verified for $artifact."
done
artifacts:
paths:
- dist/*.sigstore.json
expire_in: 7d
# The upload to PyPI over OIDC, with the PEP 740 attestations PyPI serves # RK_IF_REGISTRY
# beside each file. pypi-attestations signs each distribution with this job's # RK_IF_REGISTRY
# Sigstore identity, and uv publish exchanges the job's PyPI token through # RK_IF_REGISTRY
# the trusted publisher and uploads each distribution with its attestation. # RK_IF_REGISTRY
# A rerun skips every file PyPI already holds with the same bytes, which the # RK_IF_REGISTRY
# build compared. # RK_IF_REGISTRY
# # RK_IF_REGISTRY
# PyPI's GitLab trusted publisher accepts projects on GitLab.com alone, so # RK_IF_REGISTRY
# elsewhere this job says so and uploads nothing, and the release goes on to # RK_IF_REGISTRY
# its page. The python binding's recovery section carries the upload by hand. # RK_IF_REGISTRY
pypi: # RK_IF_REGISTRY
extends: .tooling # RK_IF_REGISTRY
stage: upload # RK_IF_REGISTRY
needs: # RK_IF_REGISTRY
rules: # RK_IF_REGISTRY
- if: '$CI_COMMIT_BRANCH == "RK_TRUNK_BRANCH" || $CI_COMMIT_BRANCH =~ /^RK_LINE_PREFIX_RE/' # RK_IF_REGISTRY
resource_group: release-$CI_COMMIT_REF_SLUG # RK_IF_REGISTRY
id_tokens: # RK_IF_REGISTRY
SIGSTORE_ID_TOKEN: # RK_IF_REGISTRY
aud: sigstore # RK_IF_REGISTRY
PYPI_ID_TOKEN: # RK_IF_REGISTRY
aud: pypi # RK_IF_REGISTRY
variables: # RK_IF_REGISTRY
PYPI_ATTESTATIONS_VERSION: "0.0.31" # RK_IF_REGISTRY
script: # RK_IF_REGISTRY
- | # RK_IF_REGISTRY
set -eu # RK_IF_REGISTRY
state="$(sed -n 's/^ "state": "\([a-z]*\)",$/\1/p' .rk-release/build.json)" # RK_IF_REGISTRY
case "$state" in # RK_IF_REGISTRY
release|resume) ;; # RK_IF_REGISTRY
*) # RK_IF_REGISTRY
echo "the build reports $state; nothing to upload." # RK_IF_REGISTRY
exit 0 # RK_IF_REGISTRY
;; # RK_IF_REGISTRY
esac # RK_IF_REGISTRY
if [ "$CI_SERVER_HOST" != "gitlab.com" ]; then # RK_IF_REGISTRY
echo "PyPI trusted publishing needs GitLab.com: PyPI accepts no self-managed instance as a trusted publisher." # RK_IF_REGISTRY
echo "continuing without the upload; the python binding's recovery section uploads the distributions by hand." # RK_IF_REGISTRY
exit 0 # RK_IF_REGISTRY
fi # RK_IF_REGISTRY
RK_DEV_RUN uvx --from "pypi-attestations==${PYPI_ATTESTATIONS_VERSION}" pypi-attestations sign dist/*.tar.gz dist/*.whl # RK_IF_REGISTRY
RK_DEV_RUN uv publish --trusted-publishing always --check-url https://pypi.org/simple/ # RK_IF_REGISTRY
# Publication, last, so nothing publicly reachable exists before its
# provenance does. rk uploads every file to the GitLab package registry, then
# sends one release create that names the commit and every link, and GitLab
# creates the tag from it. A rerun uploads what the registry lacks, links what the
# page lacks, creates the release on a tag a failed save left at this commit,
# and stops on a tag at another commit (release-engine:every-step-converges).
# On a self-managed instance the bundles do not exist, and the page carries
# the distributions and their checksums alone.
publish:
extends: .tooling
stage: publish
needs: # RK_IF_REGISTRY
# RK_IF_NO_REGISTRY needs: ["build", "provenance"]
rules:
- if: '$CI_COMMIT_BRANCH == "RK_TRUNK_BRANCH" || $CI_COMMIT_BRANCH =~ /^RK_LINE_PREFIX_RE/'
resource_group: release-$CI_COMMIT_REF_SLUG
script:
- RK_DEV_RUN rk release publish
# A release candidate, minted on purpose. Run this job by hand on a pipeline
# of the line, with the variables LINE, the line's <major>.<minor>, and
# EXPECT, the full SHA of the line's tip you read. PROMOTE=true promotes the
# line's newest candidate instead. rk lines mint writes the candidate's
# version, pyproject.toml and uv.lock through uv version, and pushes the line as the bot, because a push
# made with the CI job token starts no pipeline. That push's pipeline
# releases the candidate: build releases a candidate at the commit that
# writes it, and publish creates its release, whose description names the
# candidate on its first line, because a GitLab release carries no
# prerelease badge (release-engine:a-candidate-is-a-prerelease). So this job
# publishes nothing and creates no tag. A candidate the line already carries
# with no tag yet is pending: rk reports it, writes and pushes nothing, and
# exits 0 (release-candidates:a-pending-candidate-is-published-not-reminted).
# A promotion writes the stable version alone, its push releases nothing,
# and the line's release request proposes it.
release-candidate:
extends: .tooling
rules:
- if: '$CI_COMMIT_BRANCH =~ /^RK_LINE_PREFIX_RE/'
when: manual
allow_failure: true
resource_group: release-candidate-$CI_COMMIT_REF_SLUG
script:
- |
set -eu
if [ "$CI_COMMIT_BRANCH" != "RK_LINE_PREFIX${LINE:-}" ]; then
echo "this pipeline runs $CI_COMMIT_BRANCH, and LINE names RK_LINE_PREFIX${LINE:-}; run the job on a pipeline of the line it names." >&2
exit 1
fi
# rk commits under the account the bot's token names on the forge,
# as the release request's commit does, and the push uses that token.
git remote set-url origin "https://oauth2:${RELEASE_BOT_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
if [ "${PROMOTE:-false}" = "true" ]; then
RK_DEV_RUN rk lines mint "$LINE" --promote --expect "${EXPECT:-}" --as-bot --apply
else
RK_DEV_RUN rk lines mint "$LINE" --expect "${EXPECT:-}" --as-bot --apply
fi