Repository navigation
Expand file tree
/
Copy pathgitlab-ci.yml
More file actions
215 lines (207 loc) · 10.7 KB
/
Copy pathgitlab-ci.yml
File metadata and controls
215 lines (207 loc) · 10.7 KB
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
# GitLab CI: compare on every merge request, then assess the result.
#
# Copy to .gitlab-ci.yml, or `include:` it, with the layout docs/ci.md
# describes:
#
# ci/piace/targets.yaml committed, reviewable policy
# ci/piace/services.yaml committed, endpoints and credential references
# ci/piace/policy-notes.md committed, optional site policy for the model
#
# Three files, and none of them is rendered, copied or rewritten by a job.
# The services file names its TLS material through `*_env`, so the committed
# file is read in place: see examples/ci/services.yaml.
#
# Two jobs, not one, because they need different credentials: `piace-compare`
# holds the catalog-reader identity and never sees the inference token,
# `piace-explain` holds the inference token and never sees a private key.
# That separation lives in which variables each job is granted, not in which
# file it reads.
#
# `image:` is a plain Alpine, not example42/piace. The docker executor runs a
# job script by passing `sh` or `bash` to the image, so a job image has to
# provide a shell and an entrypoint that runs it. The published PIACE image is
# distroless: no shell, and its entrypoint is the binary itself.
variables:
PIACE_VERSION: "0.4.0"
# cosign is downloaded rather than installed from a package mirror, so it
# is pinned by version and by digest. That digest, reviewed in this file,
# is the trust anchor for everything below it: it is what makes the
# signature check a check rather than another unverified download. Take the
# value from cosign_checksums.txt in the release you pin, not from here,
# if you pin a different one.
COSIGN_VERSION: "3.1.3"
COSIGN_SHA256: "4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71"
# `piace change-context` takes a merge base. GitLab clones shallow by
# default, and a shallow clone does not have one.
GIT_DEPTH: "0"
stages:
- assess
.piace-install: &piace-install
# Pinned to a version and verified against the manifest published with it.
# A runner that fetches "the latest binary" on every run is a supply chain
# you do not control. Alpine's busybox has wget and sha256sum built in, so
# this needs no apk and works on a runner with no package mirror.
#
# Skip this entirely if the binary is already on the runner: baked into a
# custom job image, installed by the host's own configuration management, or
# mirrored into an internal artifact repository. Nothing here resolves a
# dependency at run time, so a `piace` on PATH is the whole install.
- |
set -eu
# cosign first, pinned by digest, because it is what establishes where
# the PIACE binaries came from. A checksum manifest published beside the
# binaries it lists proves only that a download is intact: whoever could
# replace one could replace the other.
wget -q -O cosign "https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64"
echo "${COSIGN_SHA256} cosign" | sha256sum -c -
install -m 0755 cosign /usr/local/bin/cosign
base="https://github.com/example42/piace/releases/download/v${PIACE_VERSION}"
wget -q -O "piace-${PIACE_VERSION}-linux-amd64" "$base/piace-${PIACE_VERSION}-linux-amd64"
wget -q -O SHA256SUMS "$base/SHA256SUMS"
wget -q -O SHA256SUMS.sigstore.json "$base/SHA256SUMS.sigstore.json"
# Origin first. The identity is the exact workflow, at the exact tag,
# that published this version: a signature from any other workflow,
# repository or ref fails here, which is why it is pinned rather than
# accepting any Sigstore certificate. cosign reaches
# tuf-repo-cdn.sigstore.dev to refresh its trust root; on a runner with
# no egress to it, cosign falls back to the roots built into the binary
# and prints a warning, and the verification still holds.
cosign verify-blob SHA256SUMS \
--bundle SHA256SUMS.sigstore.json \
--certificate-identity "https://github.com/example42/piace/.github/workflows/ci.yml@refs/tags/v${PIACE_VERSION}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# Then integrity, against the manifest whose origin is now established.
# One line of it, not the whole file: the other platforms were not
# downloaded, and a line matching nothing on disk must fail rather than
# pass quietly.
grep " piace-${PIACE_VERSION}-linux-amd64\$" SHA256SUMS | sha256sum -c -
install -m 0755 "piace-${PIACE_VERSION}-linux-amd64" /usr/local/bin/piace
piace-compare:
stage: assess
image: alpine:3.21
rules:
# Merge requests from a fork do not get protected variables, so the job
# would fail at TLS load with exit 30 rather than doing anything useful.
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_PROJECT_PATH == $CI_PROJECT_PATH
before_script:
- *piace-install
script:
# PIACE_CA_BUNDLE, PIACE_CLIENT_CERT and PIACE_PRIVATE_KEY are **file
# type** CI/CD variables: GitLab writes each value to a temporary file
# outside the checkout and puts that file's absolute path in the variable.
# ci/piace/services.yaml names those three variables with ca_bundle_env,
# client_cert_env and private_key_env, so the identity reaches PIACE
# without the job copying, rendering or writing anything.
#
# Use file type rather than variable type for all three. A PEM is
# multi-line, and a multi-line value cannot be masked, so a variable-type
# key is one `echo` away from a job log. Mark all three Protected as well,
# so an unprotected branch never sees them.
#
# The candidate environment is the one this pipeline deployed: a
# per-pipeline value, passed at the invocation so the committed target
# file stays reviewable policy and the job never rewrites it. It overrides
# candidate.environment for every target, so the file may omit the field
# entirely. The environment maps to the compiler by branch name: the
# source branch of this merge request is the Puppet environment the
# compiler has deployed, so use CI_MERGE_REQUEST_SOURCE_BRANCH_NAME, not
# the merge request number. A branch name is not always a Puppet
# environment name: environments cannot contain a dash, and r10k, when
# configured to, deploys `feature-x` as `feature_x`. Rewrite dashes the
# same way so the requested environment equals the one deployed; match
# whatever mapping your deploy stage applies, not one invented here.
# `tr` is busybox, so this needs no package installation.
- |
candidate_environment="$(printf '%s' "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" | tr '-' '_')"
piace compare \
--targets ci/piace/targets.yaml \
--services ci/piace/services.yaml \
--candidate-environment "$candidate_environment" \
--json-out report.json \
--html-out report.html
allow_failure:
# 10 is a policy difference: a real finding, reported as a warning so the
# assessment job still runs and a reviewer sees the report. 20 (the
# candidate did not compile) and 30 (the run did not complete) stay hard
# failures. Drop this block to block the merge request on 10 as well.
exit_codes: 10
artifacts:
when: always
expire_in: 1 week
# Both reports carry catalog values, redacted per targets.yaml but not
# otherwise sanitized. `access:` keeps them away from roles that can see
# the pipeline but have no business reading a catalog.
access: 'developer'
paths:
- report.json
- report.html
piace-explain:
stage: assess
image: alpine:3.21
needs:
# piace-compare is `allow_failure: exit_codes: 10`, so a policy difference
# counts as success for the DAG and this job still runs: that is the
# pipeline most worth assessing, and `compare` writes report.json before
# it exits 10. A hard failure (20 or 30) skips this job, which is right,
# because there is no complete result document to assess.
- job: piace-compare
artifacts: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_PROJECT_PATH == $CI_PROJECT_PATH
before_script:
- *piace-install
# `piace change-context` execs git. The base Alpine image has no git, and
# this is the only step in either job that needs a package mirror: the
# comparison job installs nothing. On a runner with no route to one, give
# this job an image that already carries git.
- apk add --no-cache git
script:
# PIACE contacts no VCS during a comparison; `change-context` is the one
# subcommand that runs git, and it is optional. Read
# examples/change-context.yaml before enabling it: everything in the
# generated file is forwarded to the inference service as data, and the
# change context is not pseudonymized.
#
# Every untrusted value is named, never passed. A merge request title is
# written by whoever opened it, and there is deliberately no --title flag
# to substitute one into a command line.
#
# Both refs are fetched explicitly and named through their
# remote-tracking form. A merge request pipeline checks out a detached
# commit with no local branch for either side, and a bare branch name
# does not resolve there: git looks for refs/heads/NAME and
# refs/remotes/NAME, never refs/remotes/origin/NAME. `git merge-base`
# then fails with "Not a valid object name" and the assessment runs with
# no change context at all.
- |
set -eu
git fetch --no-tags origin \
"+refs/heads/${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}:refs/remotes/origin/${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" \
"+refs/heads/${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}:refs/remotes/origin/${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"
BASE_REF="origin/${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" \
HEAD_REF="origin/${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" \
piace change-context \
--base-ref-env BASE_REF \
--head-ref-env HEAD_REF \
--title-env CI_MERGE_REQUEST_TITLE \
--description-env CI_MERGE_REQUEST_DESCRIPTION \
> change-context.yaml
# PIACE_INFERENCE_TOKEN is a masked, protected variable of type variable:
# a bearer token is single-line, so unlike a PEM it can be masked. The
# services file references it by name; there is no field that inlines one.
# This job is granted the token and not the three PEM variables, which is
# where the separation between the two jobs actually lives.
- |
piace explain \
--json-in report.json \
--services ci/piace/services.yaml \
--change change-context.yaml \
--ai-out assessment.json \
--html-out report.html
artifacts:
when: always
expire_in: 1 week
access: 'developer'
paths:
- assessment.json
- report.html