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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ jobs:
runs-on: [self-hosted, Linux, X64, clean-room, intel]
steps:
- uses: actions/checkout@v6
- name: Sigstore signer guards (RED->GREEN)
run: bash scripts/check-sigstore-sign.sh --self-test && bash scripts/check-sigstore-sign.sh && bash scripts/sigstore-verify.sh --self-test
- name: Lint workflow YAML
shell: bash
run: |
Expand Down
146 changes: 146 additions & 0 deletions .github/workflows/sigstore-sign.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Reusable keyless signing (cosign + GitHub OIDC), the ONE copy aprender and rmedia call (Noah 17:13Z ruling, item 2).
# Extracted from paiml/aprender#5562. No stored key, no typed credential: the job's OIDC token gets a short-lived
# Fulcio certificate and the signature lands in the Rekor transparency log.
#
# Signer identity: a called workflow signs as ITS OWN ref, so the certificate identity is
# https://github.com/paiml/.github/.github/workflows/sigstore-sign.yml@<ref the caller pinned>
# and the issuer is https://token.actions.githubusercontent.com. Verify with scripts/sigstore-verify.sh (item 5).
#
# Verify pins the SAN (this workflow ref), the issuer, the CALLER repo and the caller workflow name (sigstore-verify.sh):
# paiml/.github is public, so the SAN alone is the same for any repo that calls this file.
# Caller (tag builds from main, in the clean-room release lane only):
# sign:
# needs: build
# if: github.ref_type == 'tag'
# permissions: { id-token: write, contents: write }
# uses: paiml/.github/.github/workflows/sigstore-sign.yml@<sha>
# with: { artifact-name: release-assets, runs-on: '["self-hosted","Linux","X64","clean-room"]' }
name: sigstore-sign
on:
workflow_call:
inputs:
artifact-name:
description: 'actions/upload-artifact name holding the files to sign (the caller uploaded them)'
required: true
type: string
runs-on:
description: 'JSON array of runner labels; REQUIRED, no default (hosted runners are banned, so there is no safe one to assume)'
required: true
type: string
upload-to-release:
description: 'attach each .sigstore.json bundle to the tag release (needs contents: write)'
required: false
type: boolean
default: true
outputs:
sha256:
description: 'sha256sum manifest of the signed files (one "<hex> <name>" line each)'
value: ${{ jobs.sign.outputs.sha256 }}
bundle:
description: 'name of the artifact holding the <file>.sigstore.json bundles'
value: ${{ jobs.sign.outputs.bundle }}
tag:
description: 'the tag that was signed (github.ref_name)'
value: ${{ jobs.sign.outputs.tag }}
identity:
description: 'the certificate identity (the SAN): this workflow at the ref the caller pinned; pass it to sigstore-verify.sh as <identity>'
value: ${{ jobs.sign.outputs.identity }}
rekor-index:
description: 'Rekor log index of each bundle, "<name>=<index>" per line'
value: ${{ jobs.sign.outputs.rekor-index }}

permissions: {}

jobs:
sign:
name: Sign (sigstore, keyless)
runs-on: ${{ fromJSON(inputs.runs-on) }}
timeout-minutes: 15
permissions:
id-token: write # the keyless OIDC identity
contents: write # attach the bundles to the release
outputs:
sha256: ${{ steps.out.outputs.sha256 }}
tag: ${{ steps.out.outputs.tag }}
bundle: ${{ steps.out.outputs.bundle }}
identity: ${{ steps.out.outputs.identity }}
rekor-index: ${{ steps.out.outputs.rekor-index }}
steps:
- name: Refuse anything but a tag build
env:
REF_TYPE: ${{ github.ref_type }}
REF_NAME: ${{ github.ref_name }}
run: |
if [ "$REF_TYPE" != tag ]; then echo "::error::sigstore-sign signs tag builds only (ref_type=$REF_TYPE, $REF_NAME)"; exit 1; fi
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
path: caller-src
- name: Refuse a tag that is not on main
working-directory: caller-src
env:
TAG_SHA: ${{ github.sha }}
run: |
set -euo pipefail
# checkout with fetch-depth 0 already fetched every branch; no credential is kept to fetch again
git rev-parse --verify --quiet refs/remotes/origin/main >/dev/null || { echo "::error::origin/main is not in the checkout"; exit 1; }
git merge-base --is-ancestor "$TAG_SHA" refs/remotes/origin/main \
|| { echo "::error::$TAG_SHA is not an ancestor of origin/main; release signing is for tag builds from main only"; exit 1; }
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: ${{ inputs.artifact-name }}
path: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/sign
- uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
with:
cosign-release: v2.5.3 # pinned: sign-blob below asks for the new bundle format explicitly
install-dir: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/bin # per-run: nothing is installed under the runner's $HOME
- name: Sign every file, keyless
id: out
working-directory: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/sign
env:
# per-run, per-attempt scratch: a reused runner never carries a previous run's files or bundles into this one
WORK: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}
TUF_ROOT: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/tuf # cosign's TUF cache stays in the per-run dir, not $HOME
TAG: ${{ github.ref_name }}
ARTIFACT_NAME: ${{ inputs.artifact-name }}
run: |
set -euo pipefail
# flat, regular, unsigned inputs only: nothing is silently skipped, and the attach glob below sees every bundle
x=$(find . -mindepth 1 \( -type d -o ! -type f -o -name "*.sigstore.json" \) -print -quit)
[ -z "$x" ] || { echo "::error::artifact $ARTIFACT_NAME holds a nested path, a non-regular file or a *.sigstore.json ($x); refusing before anything is signed"; exit 1; }
case "$TAG" in */*) echo "::error::tag $TAG contains a slash"; exit 1;; esac
mkdir "$WORK/bundles"; : > "$WORK/manifest.txt"; : > "$WORK/rekor.txt"
n=0
while IFS= read -r f; do
f="${f#./}"
sha256sum -- "$f" >> "$WORK/manifest.txt"
cosign sign-blob --yes --new-bundle-format --bundle "$WORK/bundles/$f.sigstore.json" -- "$f" > /dev/null
idx=$(jq -r '.verificationMaterial.tlogEntries[0].logIndex // empty' "$WORK/bundles/$f.sigstore.json")
[ -n "$idx" ] || { echo "::error::no Rekor log index in the bundle for $f"; exit 1; }
echo "$f=$idx" >> "$WORK/rekor.txt"
n=$((n + 1))
done < <(find . -type f | LC_ALL=C sort)
[ "$n" -ge 1 ] || { echo "::error::artifact $ARTIFACT_NAME held no files to sign"; exit 1; }
b=$(find "$WORK/bundles" -name "*.sigstore.json" | LC_ALL=C sort | head -1)
# the identity is READ from the Fulcio certificate, never typed: it is what a verifier will see
id=$(jq -r ".verificationMaterial.certificate.rawBytes // .verificationMaterial.x509CertificateChain.certificates[0].rawBytes" "$b" | base64 -d | openssl x509 -inform DER -noout -ext subjectAltName | grep -o "https://[^, ]*" | head -1)
[ -n "$id" ] || { echo "::error::no signer identity in the certificate of $b"; exit 1; }
{
echo "sha256<<EOF_SHA"; cat "$WORK/manifest.txt"; echo "EOF_SHA"
echo "rekor-index<<EOF_REK"; cat "$WORK/rekor.txt"; echo "EOF_REK"
echo "bundle=sigstore-bundles-$TAG"
echo "tag=$TAG"
echo "identity=$id"
} >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sigstore-bundles-${{ github.ref_name }}
path: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/bundles/
- name: Attach the bundles to the release
if: ${{ inputs.upload-to-release }}
env:
GH_TOKEN: ${{ github.token }}
WORK: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}
TAG: ${{ github.ref_name }}
run: gh release upload "$TAG" "$WORK"/bundles/*.sigstore.json --clobber --repo "$GITHUB_REPOSITORY"
Loading
Loading