Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 6 additions & 72 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -249,83 +249,17 @@ jobs:
readme-filepath: dockerhub-description.md
short-description: A self-hosted homelab dashboard you actually want to look at.

# A failed or hung Scout call must not stop the jobs that describe the release.
vex:
needs: build-and-push
if: needs.build-and-push.outputs.dockerhub == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: true
permissions:
contents: read
env:
IMAGE: docker.io/${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }}/stackyard
DIGEST: ${{ needs.build-and-push.outputs.digest }}
VERSION: ${{ needs.build-and-push.outputs.version }}
TAG: ${{ github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Log in to Docker Hub
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: docker.io
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

# Every statement must hold for the image it is attached to. A false one
# hides a real vulnerability from users. Keep the musl check.
- name: Prepare the VEX statements
run: |
for arch in amd64 arm64; do
digest=$(docker buildx imagetools inspect --raw "${IMAGE}@${DIGEST}" |
jq -r --arg a "$arch" '.manifests[] | select(.platform.os == "linux" and .platform.architecture == $a) | .digest')
[ -n "$digest" ] || { echo "no linux/$arch image in $DIGEST"; exit 1; }
cid=$(docker create --platform "linux/$arch" "${IMAGE}@${digest}")
musl=$(docker cp "$cid:/lib/apk/db/installed" - | tar -xO |
awk '/^P:/ { p = substr($0, 3) } /^V:/ && p == "musl" { print substr($0, 3) }')
docker rm "$cid" > /dev/null
if [ -z "$musl" ] || [ "$(printf '%s\n' "$MUSL_FIXED" "$musl" | sort -V | head -n 1)" != "$MUSL_FIXED" ]; then
echo "linux/$arch ships musl '${musl}', older than $MUSL_FIXED. Not attaching."
exit 1
fi
jq --arg id "https://github.com/SandObserver/Stackyard/releases/tag/${TAG}#vex-${arch}" \
--arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg product "pkg:docker/${IMAGE#docker.io/}@${VERSION}" \
--arg musl "$musl" --arg fixed "$MUSL_FIXED" \
'."@id" = $id | .timestamp = $now
| .statements[] |= (.products = [{"@id": $product}] | .timestamp = $now
| .impact_statement = "This image ships musl \($musl). Alpine 3.24 ships the fix in musl \($fixed).")' \
.github/vex/stackyard.vex.json > "$RUNNER_TEMP/stackyard-$arch.vex.json"
echo "$digest" > "$RUNNER_TEMP/digest-$arch"
done
env:
MUSL_FIXED: 1.2.6-r3

# Keep --referrer. Attaching into the index changes the image digest, and
# the signature no longer matches it.
- name: Attach the VEX statements
run: |
for arch in amd64 arm64; do
docker run --rm --user "$(id -u):$(id -g)" \
-e HOME=/tmp -e DOCKER_SCOUT_NO_CACHE=true \
-e DOCKER_SCOUT_HUB_USER -e DOCKER_SCOUT_HUB_PASSWORD \
-e DOCKER_CONFIG=/docker-config -v "$HOME/.docker:/docker-config:ro" \
-v "$RUNNER_TEMP:/vex:ro" \
"$SCOUT_IMAGE" attestation add \
--file "/vex/stackyard-$arch.vex.json" \
--predicate-type https://openvex.dev/ns/v0.2.0 \
--referrer \
"registry://${IMAGE#docker.io/}@$(cat "$RUNNER_TEMP/digest-$arch")"
done
env:
# Pin by digest. This program holds the Docker Hub token.
SCOUT_IMAGE: docker.io/docker/scout-cli:1.26.0@sha256:ae97866ffbcc23a9bdb28beb3f11b46fa5f8b77380121f74b0eabfb637d63360
DOCKER_SCOUT_HUB_USER: ${{ vars.DOCKERHUB_USERNAME }}
DOCKER_SCOUT_HUB_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
uses: ./.github/workflows/vex.yml
with:
digest: ${{ needs.build-and-push.outputs.digest }}
version: ${{ needs.build-and-push.outputs.version }}
secrets:
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}

# The only gate that reads the published image. It cannot unpublish a bad one,
# but it stops the release page and the downstream updates describing it.
Expand Down
120 changes: 120 additions & 0 deletions .github/workflows/vex.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
name: VEX

on:
workflow_call:
inputs:
digest:
type: string
required: true
version:
type: string
required: true
secrets:
DOCKERHUB_TOKEN:
required: true
workflow_dispatch:
inputs:
version:
description: Published version, for example 1.20.0
type: string
required: true

permissions:
contents: read

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
# A failed or hung Scout call must not fail a published release.
attach:
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: ${{ github.event_name != 'workflow_dispatch' }}
permissions:
contents: read
env:
NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }}
IMAGE: docker.io/${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }}/stackyard
VERSION: ${{ inputs.version }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Log in to Docker Hub
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: docker.io
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Resolve the image
run: |
case "$VERSION" in
*[!0-9A-Za-z.-]* | '' | -* ) echo "not a version: '$VERSION'"; exit 1 ;;
esac
if [ -z "$DIGEST" ]; then
DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${VERSION}" --format '{{json .Manifest}}' | jq -r .digest)
fi
case "$DIGEST" in
sha256:*) ;;
*) echo "no image for ${IMAGE}:${VERSION}"; exit 1 ;;
esac
echo "DIGEST=$DIGEST" >> "$GITHUB_ENV"
echo "TAG=v$VERSION" >> "$GITHUB_ENV"
env:
DIGEST: ${{ inputs.digest }}

# Every statement must hold for the image it is attached to. A false one
# hides a real vulnerability from users. Keep the musl check.
- name: Prepare the VEX statements
run: |
for arch in amd64 arm64; do
digest=$(docker buildx imagetools inspect --raw "${IMAGE}@${DIGEST}" |
jq -r --arg a "$arch" '.manifests[] | select(.platform.os == "linux" and .platform.architecture == $a) | .digest')
[ -n "$digest" ] || { echo "no linux/$arch image in $DIGEST"; exit 1; }
cid=$(docker create --platform "linux/$arch" "${IMAGE}@${digest}")
musl=$(docker cp "$cid:/lib/apk/db/installed" - | tar -xO |
awk '/^P:/ { p = substr($0, 3) } /^V:/ && p == "musl" { print substr($0, 3) }')
docker rm "$cid" > /dev/null
if [ -z "$musl" ] || [ "$(printf '%s\n' "$MUSL_FIXED" "$musl" | sort -V | head -n 1)" != "$MUSL_FIXED" ]; then
echo "linux/$arch ships musl '${musl}', older than $MUSL_FIXED. Not attaching."
exit 1
fi
jq --arg id "https://github.com/SandObserver/Stackyard/releases/tag/${TAG}#vex-${arch}" \
--arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg product "pkg:docker/${IMAGE#docker.io/}@${VERSION}" \
--arg musl "$musl" --arg fixed "$MUSL_FIXED" \
'."@id" = $id | .timestamp = $now
| .statements[] |= (.products = [{"@id": $product}] | .timestamp = $now
| .impact_statement = "This image ships musl \($musl). Alpine 3.24 ships the fix in musl \($fixed).")' \
.github/vex/stackyard.vex.json > "$RUNNER_TEMP/stackyard-$arch.vex.json"
echo "$digest" > "$RUNNER_TEMP/digest-$arch"
done
env:
MUSL_FIXED: 1.2.6-r3

# Keep --referrer. Attaching into the index changes the image digest, and
# the signature no longer matches it.
- name: Attach the VEX statements
run: |
for arch in amd64 arm64; do
docker run --rm --user "$(id -u):$(id -g)" \
-e HOME=/tmp -e DOCKER_SCOUT_NO_CACHE=true \
-e DOCKER_SCOUT_HUB_USER -e DOCKER_SCOUT_HUB_PASSWORD \
-e DOCKER_CONFIG=/docker-config -v "$HOME/.docker:/docker-config:ro" \
-v "$RUNNER_TEMP:/vex:ro" \
"$SCOUT_IMAGE" attestation add \
--org "$NAMESPACE" \
--file "/vex/stackyard-$arch.vex.json" \
--predicate-type https://openvex.dev/ns/v0.2.0 \
--referrer \
"registry://${IMAGE#docker.io/}@$(cat "$RUNNER_TEMP/digest-$arch")"
done
env:
# Pin by digest. This program holds the Docker Hub token.
SCOUT_IMAGE: docker.io/docker/scout-cli:1.26.0@sha256:ae97866ffbcc23a9bdb28beb3f11b46fa5f8b77380121f74b0eabfb637d63360
DOCKER_SCOUT_HUB_USER: ${{ vars.DOCKERHUB_USERNAME }}
DOCKER_SCOUT_HUB_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
30 changes: 26 additions & 4 deletions test/repo/release-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,26 +278,47 @@ test('the identity pattern matches the identity the release signs with', () => {
}
});

const vexJob = workflow.jobs.vex;
const vexCall = workflow.jobs.vex;
const vexWorkflow = yaml.load(fs.readFileSync(path.join(root, '.github/workflows/vex.yml'), 'utf8'));
const vexJob = vexWorkflow.jobs.attach;
const vexStep = name => {
const step = vexJob.steps.find(s => s.name === name);
assert.ok(step, `the ${name} step is gone`);
return step;
};

test('the VEX statements are attached in their own job, which cannot hold up the release', () => {
assert.deepEqual(vexJob.needs, 'build-and-push');
assert.deepEqual(vexCall.needs, 'build-and-push');
assert.equal(job.outputs.dockerhub, '${{ steps.registries.outputs.dockerhub }}');
assert.equal(vexJob.if, "needs.build-and-push.outputs.dockerhub == 'true'");
assert.equal(vexJob['continue-on-error'], true, 'a Scout failure must not fail a published release');
assert.equal(vexCall.if, "needs.build-and-push.outputs.dockerhub == 'true'");
assert.equal(vexCall.uses, './.github/workflows/vex.yml');
assert.equal(vexCall.with.digest, '${{ needs.build-and-push.outputs.digest }}');
assert.equal(vexCall.with.version, '${{ needs.build-and-push.outputs.version }}');
assert.deepEqual(Object.keys(vexCall.secrets), ['DOCKERHUB_TOKEN']);
assert.equal(
vexJob['continue-on-error'],
"${{ github.event_name != 'workflow_dispatch' }}",
'a Scout failure must not fail a published release, and must fail a run by hand',
);
assert.ok(vexJob['timeout-minutes'] <= 10, 'a hung Scout call must end on its own');
assert.deepEqual(vexCall.permissions, { contents: 'read' });
assert.deepEqual(vexJob.permissions, { contents: 'read' });
for (const [name, other] of Object.entries(workflow.jobs)) {
assert.ok(![other.needs].flat().includes('vex'), `${name} must not wait for the VEX job`);
}
assert.equal(steps.filter(s => /scout/i.test(`${s.name} ${s.uses || ''}`)).length, 0);
});

test('a published version can be given its VEX statements by hand', () => {
assert.deepEqual(Object.keys(vexWorkflow.on.workflow_dispatch.inputs), ['version']);
const resolve = vexStep('Resolve the image');
assert.ok(vexJob.steps.indexOf(resolve) < vexJob.steps.indexOf(vexStep('Prepare the VEX statements')));
assert.equal(resolve.env.DIGEST, '${{ inputs.digest }}');
assert.match(resolve.run, /\*\[!0-9A-Za-z\.-\]\*/, 'the version lands in an image reference');
assert.match(resolve.run, /imagetools inspect "\$\{IMAGE\}:\$\{VERSION\}"/);
assert.match(resolve.run, /TAG=v\$VERSION/);
});

test('a statement is attached only to an image that ships the patched musl', () => {
const prepare = vexStep('Prepare the VEX statements');
assert.match(prepare.env.MUSL_FIXED, /^\d+\.\d+\.\d+-r\d+$/);
Expand All @@ -315,6 +336,7 @@ test('the Scout program is pinned by digest and attaches by referrer', () => {
assert.ok(prepare < vexJob.steps.indexOf(attach), 'nothing to attach yet');
assert.match(attach.env.SCOUT_IMAGE, /^docker\.io\/docker\/scout-cli:[\d.]+@sha256:[0-9a-f]{64}$/);
assert.match(attach.run, /--referrer\b/, 'attaching into the index changes the signed digest');
assert.match(attach.run, /--org "\$NAMESPACE"/, 'Scout refuses to attach without the organisation');
assert.match(attach.run, /--predicate-type https:\/\/openvex\.dev\/ns\/v0\.2\.0/);
assert.match(attach.run, /--file "\/vex\/stackyard-\$arch\.vex\.json"/);
assert.match(vexStep('Prepare the VEX statements').run, /"\$RUNNER_TEMP\/stackyard-\$arch\.vex\.json"/);
Expand Down
Loading