|
20 | 20 | - '' # without this, it's technically "required" 🙃
|
21 | 21 | - 2022
|
22 | 22 | - 2019
|
| 23 | + pruneArtifact: |
| 24 | + required: true |
| 25 | + type: boolean |
| 26 | + default: true |
23 | 27 | run-name: '${{ inputs.bashbrewArch }}: ${{ inputs.firstTag }} (${{ inputs.buildId }})'
|
24 | 28 | permissions:
|
25 | 29 | contents: read
|
|
36 | 40 |
|
37 | 41 | # the image we'll run to access the signing tool
|
38 | 42 | # https://explore.ggcr.dev/?repo=docker/image-signer-verifier
|
39 |
| - IMAGE_SIGNER: 'docker/image-signer-verifier:0.6.3@sha256:d7930e03b48064b6c2d9f9c0421f65a5dacc6aba7d91b9d2e320d2976becfeac' |
| 43 | + IMAGE_SIGNER: 'docker/image-signer-verifier:0.6.9@sha256:e38be2b9e6f010cf3432a2772b0e800feee572f7733c6df81e21293cb3e977e0' |
40 | 44 |
|
41 | 45 | # Docker Hub repository we'll push the (signed) attestation artifacts to
|
42 | 46 | REFERRERS_REPO: oisupport/referrers
|
43 | 47 | jobs:
|
44 | 48 | build:
|
45 | 49 | name: Build ${{ inputs.buildId }}
|
46 | 50 | outputs:
|
47 |
| - shouldSign: ${{ steps.json.outputs.shouldSign }} |
| 51 | + json: ${{ steps.json.outputs.json }} |
| 52 | + artifactUrl: ${{ steps.oci.outputs.artifact-url }} |
| 53 | + artifactSha256: ${{ steps.sha256.outputs.sha256 }} |
48 | 54 | runs-on: ${{ inputs.bashbrewArch == 'windows-amd64' && format('windows-{0}', inputs.windowsVersion) || 'ubuntu-latest' }}
|
49 | 55 | steps:
|
50 | 56 |
|
@@ -156,135 +162,243 @@ jobs:
|
156 | 162 | fi
|
157 | 163 | eval "$shell"
|
158 | 164 |
|
159 |
| - # TODO signing prototype (see above where "shouldSign" is populated) |
| 165 | + # save the build as an "artifact" so we can sign it in a separate step (and minimize the exposure of the signing credentials); also save a checksum so we can be sure it transmits between the steps accurately |
| 166 | + # TODO what do we do here for "classic" builds like Windows? (no "temp" OCI layout currently) |
| 167 | + - name: Generate Artifact |
| 168 | + id: sha256 |
| 169 | + run: | |
| 170 | + tar -cvf temp.tar -C build temp |
| 171 | + sha256="$(sha256sum temp.tar | cut -d' ' -f1)" |
| 172 | + echo "sha256=$sha256" >> "$GITHUB_OUTPUT" |
| 173 | + - name: Upload Artifact |
| 174 | + id: oci |
| 175 | + uses: actions/upload-artifact@v4 |
| 176 | + with: |
| 177 | + name: build-oci |
| 178 | + path: | |
| 179 | + temp.tar |
| 180 | + retention-days: 5 |
| 181 | + |
| 182 | + sign: |
| 183 | + name: Sign |
| 184 | + needs: build |
| 185 | + if: fromJSON(needs.build.outputs.json).shouldSign |
| 186 | + runs-on: ubuntu-latest |
| 187 | + permissions: |
| 188 | + contents: read |
| 189 | + id-token: write # for AWS KMS signing (see usage below) |
| 190 | + steps: |
| 191 | + - uses: actions/checkout@v4 |
| 192 | + with: |
| 193 | + sparse-checkout-cone-mode: 'false' |
| 194 | + sparse-checkout: | |
| 195 | + .scripts/oci.jq |
| 196 | + .scripts/provenance.jq |
| 197 | + - name: Download Artifact |
| 198 | + uses: actions/download-artifact@v4 |
| 199 | + with: |
| 200 | + name: build-oci |
| 201 | + - name: Extract Artifact |
| 202 | + env: |
| 203 | + sha256: ${{ needs.build.outputs.artifactSha256 }} |
| 204 | + run: | |
| 205 | + sha256sum <<<"$sha256 *temp.tar" --strict --check - |
| 206 | + tar -xvf temp.tar |
| 207 | + [ -d temp ] # basic "valid JSON" check |
160 | 208 | - name: Configure AWS (for signing)
|
161 |
| - if: fromJSON(steps.json.outputs.json).shouldSign |
162 | 209 | # https://github.com/aws-actions/configure-aws-credentials/releases
|
163 | 210 | uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1
|
164 | 211 | with:
|
165 | 212 | aws-region: ${{ github.ref_name == 'main' && secrets.AWS_KMS_PROD_REGION || secrets.AWS_KMS_STAGE_REGION }}
|
166 | 213 | role-to-assume: ${{ github.ref_name == 'main' && secrets.AWS_KMS_PROD_ROLE_ARN || secrets.AWS_KMS_STAGE_ROLE_ARN }}
|
167 |
| - # TODO figure out if there's some way we could make our secrets ternaries here more DRY without major headaches 🙈 |
| 214 | + - name: Generate Provenance |
| 215 | + env: |
| 216 | + json: ${{ needs.build.outputs.json }} |
| 217 | + GITHUB_CONTEXT: ${{ toJson(github) }} |
| 218 | + run: | |
| 219 | + image-digest() { |
| 220 | + local dir="$1/blobs" |
| 221 | + img=$( |
| 222 | + grep -R --include "*" '"mediaType":\s"application/vnd.oci.image.layer.' "$dir" \ |
| 223 | + | head -n 1 \ |
| 224 | + | cut -d ':' -f1 |
| 225 | + ) |
| 226 | + [ "$(cat $img | jq -r '.mediaType')" = "application/vnd.oci.image.manifest.v1+json" ] || exit 1 |
| 227 | + echo $img | rev | cut -d '/' -f2,1 --output-delimiter ':' | rev |
| 228 | + } |
| 229 | +
|
| 230 | + digest=$(image-digest temp) |
| 231 | +
|
| 232 | + echo $json | jq -L.scripts --argjson github '${{ env.GITHUB_CONTEXT }}' --argjson runner '${{ toJson(runner) }}' --arg digest ${digest} ' |
| 233 | + include "provenance"; |
| 234 | + github_actions_provenance($github; $runner; $digest) |
| 235 | + ' >> provenance.json |
168 | 236 | - name: Sign
|
169 |
| - if: fromJSON(steps.json.outputs.json).shouldSign |
170 | 237 | env:
|
171 | 238 | AWS_KMS_REGION: ${{ github.ref_name == 'main' && secrets.AWS_KMS_PROD_REGION || secrets.AWS_KMS_STAGE_REGION }}
|
172 | 239 | AWS_KMS_KEY_ARN: ${{ github.ref_name == 'main' && secrets.AWS_KMS_PROD_KEY_ARN || secrets.AWS_KMS_STAGE_KEY_ARN }}
|
| 240 | + |
| 241 | + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_SIGNING_USERNAME }} |
| 242 | + DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_SIGNING_PASSWORD }} |
173 | 243 | run: |
|
174 |
| - cd build |
| 244 | + validate-oci-layout() { |
| 245 | + local dir="$1" |
| 246 | + jq -L.scripts -s ' |
| 247 | + include "oci"; |
| 248 | + validate_oci_layout | true |
| 249 | + ' "$dir/oci-layout" "$dir/index.json" || return "$?" |
| 250 | + local manifest |
| 251 | + manifest="$dir/blobs/$(jq -r '.manifests[0].digest | sub(":"; "/")' "$dir/index.json")" || return "$?" |
| 252 | + jq -L.scripts -s ' |
| 253 | + include "oci"; |
| 254 | + if length != 1 then |
| 255 | + error("unexpected image index document count: \(length)") |
| 256 | + else .[0] end |
| 257 | + | validate_oci_index |
175 | 258 |
|
176 |
| - args=( |
| 259 | + # TODO more validation? |
| 260 | + ' "$manifest" || return "$?" |
| 261 | + } |
| 262 | +
|
| 263 | + dockerArgs=( |
177 | 264 | --interactive
|
178 | 265 | --rm
|
179 | 266 | --read-only
|
180 | 267 | --workdir /tmp # see "--tmpfs" below (TODO the signer currently uses PWD as TMPDIR -- something to fix in the future so we can drop this --workdir and only keep --tmpfs perhaps adding --env TMPDIR=/tmp if necessary)
|
181 | 268 | )
|
182 | 269 | if [ -t 0 ] && [ -t 1 ]; then
|
183 |
| - args+=( --tty ) |
| 270 | + dockerArgs+=( --tty ) |
184 | 271 | fi
|
185 | 272 |
|
186 | 273 | user="$(id -u)"
|
187 |
| - args+=( --tmpfs "/tmp:uid=$user" ) |
| 274 | + dockerArgs+=( --tmpfs "/tmp:uid=$user" ) |
188 | 275 | user+=":$(id -g)"
|
189 |
| - args+=( --user "$user" ) |
| 276 | + dockerArgs+=( --user "$user" ) |
190 | 277 |
|
191 | 278 | awsEnvs=( "${!AWS_@}" )
|
192 |
| - args+=( "${awsEnvs[@]/#/--env=}" ) |
193 |
| -
|
194 |
| - # some very light assumption verification (see TODO in --mount below) |
195 |
| - validate-oci-layout() { |
196 |
| - local dir="$1" |
197 |
| - jq -s ' |
198 |
| - if length != 1 then |
199 |
| - error("unexpected 'oci-layout' document count: " + length) |
200 |
| - else .[0] end |
201 |
| - | if .imageLayoutVersion != "1.0.0" then |
202 |
| - error("unsupported imageLayoutVersion: " + .imageLayoutVersion) |
203 |
| - else . end |
204 |
| - ' "$dir/oci-layout" || return "$?" |
205 |
| - jq -s ' |
206 |
| - if length != 1 then |
207 |
| - error("unexpected 'index.json' document count: " + length) |
208 |
| - else .[0] end |
209 |
| -
|
210 |
| - | if .schemaVersion != 2 then |
211 |
| - error("unsupported schemaVersion: " + .schemaVersion) |
212 |
| - else . end |
213 |
| - | if .mediaType != "application/vnd.oci.image.index.v1+json" and .mediaType then # TODO drop the second half of this validation: https://github.com/moby/buildkit/issues/4595 |
214 |
| - error("unsupported index mediaType: " + .mediaType) |
215 |
| - else . end |
216 |
| - | if .manifests | length != 1 then |
217 |
| - error("expected only one manifests entry, not " + (.manifests | length)) |
218 |
| - else . end |
219 |
| -
|
220 |
| - | .manifests[0] |= ( |
221 |
| - if .mediaType != "application/vnd.oci.image.index.v1+json" then |
222 |
| - error("unsupported descriptor mediaType: " + .mediaType) |
223 |
| - else . end |
224 |
| - # TODO validate .digest somehow (`crane validate`?) - would also be good to validate all descriptors recursively |
225 |
| - | if .size < 0 then |
226 |
| - error("invalid descriptor size: " + .size) |
227 |
| - else . end |
228 |
| - ) |
229 |
| - ' "$dir/index.json" || return "$?" |
230 |
| - local manifest |
231 |
| - manifest="$dir/blobs/$(jq -r '.manifests[0].digest | sub(":"; "/")' "$dir/index.json")" || return "$?" |
232 |
| - jq -s ' |
233 |
| - if length != 1 then |
234 |
| - error("unexpected image index document count: " + length) |
235 |
| - else .[0] end |
236 |
| - | if .schemaVersion != 2 then |
237 |
| - error("unsupported schemaVersion: " + .schemaVersion) |
238 |
| - else . end |
239 |
| - | if .mediaType != "application/vnd.oci.image.index.v1+json" then |
240 |
| - error("unsupported image index mediaType: " + .mediaType) |
241 |
| - else . end |
| 279 | + dockerArgs+=( "${awsEnvs[@]/#/--env=}" ) |
242 | 280 |
|
243 |
| - # TODO more validation? |
244 |
| - ' "$manifest" || return "$?" |
245 |
| - } |
246 | 281 | validate-oci-layout temp
|
247 | 282 |
|
248 |
| - mkdir signed |
| 283 | + # Login to Docker Hub |
| 284 | + export DOCKER_CONFIG="$PWD/.docker" |
| 285 | + mkdir "$DOCKER_CONFIG" |
| 286 | + trap 'find "$DOCKER_CONFIG" -type f -exec shred -fuvz "{}" + || :; rm -rf "$DOCKER_CONFIG"' EXIT |
| 287 | + docker login --username "$DOCKER_HUB_USERNAME" --password-stdin <<<"$DOCKER_HUB_PASSWORD" |
| 288 | + unset DOCKER_HUB_USERNAME DOCKER_HUB_PASSWORD |
249 | 289 |
|
250 |
| - args+=( |
251 |
| - --mount "type=bind,src=$PWD/temp,dst=/doi-build/unsigned" # TODO this currently assumes normalized_builder == "buildkit" and !should_use_docker_buildx_driver -- we need to factor that in later (although this signs the attestations, not the image, so buildkit/buildx is the only builder whose output we *can* sign right now) |
252 |
| - --mount "type=bind,src=$PWD/signed,dst=/doi-build/signed" |
| 290 | + # Create signatures |
| 291 | + dockerArgs+=( |
| 292 | + --mount "type=bind,src=$PWD/temp,dst=/doi-build/image,ro" # TODO this currently assumes normalized_builder == "buildkit" and !should_use_docker_buildx_driver -- we need to factor that in later (although this signs the attestations, not the image, so buildkit/buildx is the only builder whose output we *can* sign right now) |
| 293 | + --mount "type=bind,src=$PWD/provenance.json,dst=/doi-build/provenance.json,ro" |
| 294 | + --mount "type=bind,src=$DOCKER_CONFIG,dst=/docker-config,ro" |
| 295 | + --env DOCKER_CONFIG=/docker-config # TODO verify that image-signer supports this environment variable correctly |
253 | 296 |
|
254 | 297 | # https://explore.ggcr.dev/?repo=docker/image-signer-verifier
|
255 |
| - docker/image-signer-verifier:0.3.3@sha256:a5351e6495596429bacea85fbf8f41a77ce7237c26c74fd7c3b94c3e6d409c82 |
256 |
| -
|
257 |
| - sign |
| 298 | + "$IMAGE_SIGNER" |
| 299 | + ) |
258 | 300 |
|
259 |
| - --envelope-style oci-content-descriptor |
| 301 | + kmsArg=( |
| 302 | + # kms key used to sign attestation artifacts |
| 303 | + --kms="AWS" |
| 304 | + --kms-region="$AWS_KMS_REGION" |
| 305 | + --kms-key-ref="$AWS_KMS_KEY_ARN" |
260 | 306 |
|
261 |
| - --aws_region "$AWS_KMS_REGION" |
262 |
| - --aws_arn "awskms:///$AWS_KMS_KEY_ARN" |
| 307 | + --referrers-dest="$REFERRERS_REPO" # repo to store attestation artifacts and provenance |
| 308 | + ) |
263 | 309 |
|
264 |
| - --input oci:///doi-build/unsigned |
265 |
| - --output oci:///doi-build/signed |
| 310 | + # Sign buildkit statements |
| 311 | + signArgs=( |
| 312 | + "${kmsArg[@]}" |
| 313 | + --input=oci:///doi-build/image |
| 314 | + --keep=true # keep preserves the unsigned attestations generated by buildkit |
266 | 315 | )
|
267 | 316 |
|
268 |
| - docker run "${args[@]}" |
| 317 | + docker run "${dockerArgs[@]}" sign "${signArgs[@]}" |
269 | 318 |
|
270 |
| - validate-oci-layout signed |
| 319 | + # Attach and sign provenance |
| 320 | + provArgs=( |
| 321 | + "${kmsArg[@]}" |
| 322 | + --image=oci:///doi-build/image |
| 323 | + --statement="/doi-build/provenance.json" |
| 324 | + ) |
| 325 | + docker run "${dockerArgs[@]}" attest "${provArgs[@]}" |
| 326 | +
|
| 327 | + push: |
| 328 | + name: Push |
| 329 | + needs: |
| 330 | + - build |
| 331 | + - sign |
| 332 | + # - verify |
| 333 | + if: ${{ always() }} |
| 334 | + runs-on: ubuntu-latest |
| 335 | + steps: |
| 336 | + - run: ${{ (needs.sign.result == 'skipped' && needs.build.result == 'success') || needs.verify.result == 'success' || 'exit 1' }} |
| 337 | + - name: Download Artifact |
| 338 | + uses: actions/download-artifact@v4 |
| 339 | + with: |
| 340 | + name: build-oci |
| 341 | + - name: Extract Artifact |
| 342 | + env: |
| 343 | + sha256: ${{ needs.build.outputs.artifactSha256 }} |
| 344 | + run: | |
| 345 | + sha256sum <<<"$sha256 *temp.tar" --strict --check - |
| 346 | + tar -xvf temp.tar |
| 347 | + [ -d temp ] # basic "valid JSON" check |
| 348 | + - name: Tools |
| 349 | + run: | |
| 350 | + mkdir .gha-bin |
| 351 | + echo "$PWD/.gha-bin" >> "$GITHUB_PATH" |
271 | 352 |
|
272 |
| - # TODO validate that "signed" still has all the original layer blobs from "temp" (ie, that the attestation manifest *just* has some new layers and everything else is unchanged) |
| 353 | + case "${RUNNER_ARCH}" in \ |
| 354 | + X64) ARCH='amd64';; \ |
| 355 | + esac |
273 | 356 |
|
274 |
| - rm -rf temp |
275 |
| - mv signed temp |
| 357 | + _download() { |
| 358 | + local target="$1"; shift |
| 359 | + local url="$1"; shift |
| 360 | + wget --timeout=5 -O "$target" "$url" --progress=dot:giga |
| 361 | + } |
276 | 362 |
|
| 363 | + # https://doi-janky.infosiftr.net/job/wip/job/crane |
| 364 | + _download ".gha-bin/crane" "https://doi-janky.infosiftr.net/job/wip/job/crane/lastSuccessfulBuild/artifact/crane-$ARCH" |
| 365 | + # TODO checksum verification ("checksums.txt") |
| 366 | + chmod +x ".gha-bin/crane" |
| 367 | + ".gha-bin/crane" version |
277 | 368 | - name: Push
|
278 | 369 | env:
|
279 | 370 | DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
280 | 371 | DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
| 372 | + json: ${{ needs.build.outputs.json }} |
281 | 373 | run: |
|
282 | 374 | export DOCKER_CONFIG="$PWD/.docker"
|
283 | 375 | mkdir "$DOCKER_CONFIG"
|
284 | 376 | trap 'find "$DOCKER_CONFIG" -type f -exec shred -fuvz "{}" + || :; rm -rf "$DOCKER_CONFIG"' EXIT
|
285 | 377 | docker login --username "$DOCKER_HUB_USERNAME" --password-stdin <<<"$DOCKER_HUB_PASSWORD"
|
286 | 378 | unset DOCKER_HUB_USERNAME DOCKER_HUB_PASSWORD
|
287 | 379 |
|
288 |
| - cd build |
289 | 380 | shell="$(jq <<<"$json" -r '.commands.push')"
|
290 | 381 | eval "$shell"
|
| 382 | +
|
| 383 | + clean: |
| 384 | + name: Cleanup |
| 385 | + needs: |
| 386 | + - build |
| 387 | + # - verify |
| 388 | + - push |
| 389 | + if: ${{ inputs.pruneArtifact }} |
| 390 | + runs-on: ubuntu-latest |
| 391 | + steps: |
| 392 | + - name: Clean Up Artifact |
| 393 | + env: |
| 394 | + ARTIFACT_URL: ${{ needs.build.outputs.artifactUrl }} |
| 395 | + TOKEN: ${{ github.token }} |
| 396 | + run: | |
| 397 | + url="${ARTIFACT_URL/\/\/github\.com///api.github.com/repos}" |
| 398 | + url="${url/runs\/*\/artifacts/artifacts}" # Translate web URL to API url |
| 399 | + curl -L \ |
| 400 | + -X DELETE \ |
| 401 | + -H "Accept: application/vnd.github+json" \ |
| 402 | + -H "Authorization: Bearer $TOKEN" \ |
| 403 | + -H "X-GitHub-Api-Version: 2022-11-28" \ |
| 404 | + "$url" |
0 commit comments