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: 1 addition & 1 deletion .github/scripts/stage_ghd_release_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def validate_ghd_config(config: dict[str, Any], *, binary_name: str, repository:
if not isinstance(provenance, dict):
raise StageError("ghd.toml must define a [provenance] table")

expected_signer = f"{repository}/.github/workflows/release.yml"
expected_signer = f"{repository}/.github/workflows/attest.yml"
actual_signer = provenance.get("signer_workflow")
if actual_signer != expected_signer:
raise StageError(
Expand Down
4 changes: 2 additions & 2 deletions .github/scripts/test_stage_ghd_release_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def test_fails_on_checksum_mismatch(self) -> None:
self.assertIn("checksum mismatch for template-mcp_1.2.3_linux_amd64", stderr)

def test_fails_on_wrong_signer_workflow(self) -> None:
with fixture(signer="other/repo/.github/workflows/release.yml") as root:
with fixture(signer="other/repo/.github/workflows/attest.yml") as root:
result, _, stderr = run_script(root)

self.assertEqual(result, 1)
Expand Down Expand Up @@ -126,7 +126,7 @@ def run_script(root: Path) -> tuple[int, str, str]:
@contextlib.contextmanager
def fixture(
*,
signer: str = "meigma/template-mcp/.github/workflows/release.yml",
signer: str = "meigma/template-mcp/.github/workflows/attest.yml",
missing_checksum: str | None = None,
checksum_override: tuple[str, str] | None = None,
omit_artifact: tuple[str, str, str] | None = None,
Expand Down
84 changes: 84 additions & 0 deletions .github/workflows/attest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Reusable workflow that generates artifact PROVENANCE in isolation from the build
# jobs that call it. A reusable workflow runs in its own execution context with its
# own OIDC identity — the caller can inject neither steps nor secrets — so the
# signing material is unreachable by the build's user-defined steps. That isolation
# is the SLSA Build L3 requirement; GitHub artifact attestations generated in-job
# are only L2. Provenance is still written to GitHub's attestation API, so
# `gh attestation verify` keeps working — but the signer-workflow is now this file:
#
# gh attestation verify <artifact-or-oci-ref> --repo <repo> \
# --signer-workflow <repo>/.github/workflows/attest.yml --source-ref refs/tags/<tag>
#
# SLSA note: L3 makes the signed provenance unforgeable; it does not, by itself,
# make the build hermetic. The build job still computes the checksums/digest it
# passes in — L3's guarantee is that the signature cannot be tampered with because
# the key lives only in this isolated workflow.

name: Attest (reusable)

on:
workflow_call:
inputs:
checksums-artifact:
description: Name of an uploaded artifact containing checksums.txt (binary provenance).
type: string
required: false
default: ''
subject-name:
description: Fully-qualified image name without tag/digest (container provenance).
type: string
required: false
default: ''
subject-digest:
description: Image digest in sha256:... form (container provenance).
type: string
required: false
default: ''
push-to-registry:
description: Attach the image provenance attestation to the registry.
type: boolean
required: false
default: false

permissions: {}

jobs:
attest:
name: Attest
runs-on: ubuntu-24.04
permissions:
id-token: write # OIDC token minted HERE, isolated from the build job
attestations: write # write provenance to GitHub's attestation API
contents: read
packages: write # used only for the image attestation (push-to-registry)
steps:
- name: Download checksums
if: ${{ inputs.checksums-artifact != '' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ inputs.checksums-artifact }}

- name: Attest binary checksums
if: ${{ inputs.checksums-artifact != '' }}
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-checksums: checksums.txt

# The image provenance is attached to the registry as a referrer, so this
# isolated job needs its own GHCR login — the build job's docker login does
# not carry into the reusable workflow's separate runner.
- name: Log in to GitHub Container Registry
if: ${{ inputs.subject-digest != '' }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}

- name: Attest image provenance
if: ${{ inputs.subject-digest != '' }}
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ${{ inputs.subject-name }}
subject-digest: ${{ inputs.subject-digest }}
push-to-registry: ${{ inputs.push-to-registry }}
61 changes: 46 additions & 15 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,6 @@ jobs:
- resolve-release
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Check out repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
Expand Down Expand Up @@ -153,10 +151,32 @@ jobs:
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
run: gh release upload "$RELEASE_TAG" dist/release-assets/* --clobber

- name: Attest release checksums
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
- name: Upload checksums for isolated attestation
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
subject-checksums: dist/release-assets/checksums.txt
name: release-checksums
path: dist/release-assets/checksums.txt
if-no-files-found: error
retention-days: 1

# Generate binary provenance in an ISOLATED reusable workflow (SLSA L3): the
# signing OIDC token is minted in attest.yml, unreachable by the build steps.
attest-binaries:
needs:
- binary-release-assets
permissions:
id-token: write
attestations: write
contents: read
# attest.yml is shared with attest-image, whose attest job declares
# packages: write for the image attestation's push-to-registry. A reusable
# workflow cannot request more permissions than its caller grants, so this
# caller must also grant packages even though the binary attestation never
# pushes to a registry (unused here, but required to satisfy the shared job).
packages: write
uses: ./.github/workflows/attest.yml
with:
checksums-artifact: release-checksums

# Build the per-arch signed apk on a native runner (no QEMU). Each runner mints
# its own ephemeral melange key (distinct filename) and uploads its apk plus its
Expand Down Expand Up @@ -368,14 +388,23 @@ jobs:
sbom-path: image.spdx.json
push-to-registry: true

# apko has no BuildKit provenance, so generate it explicitly (replaces the
# former Dockerfile/buildx `provenance: mode=max`).
- name: Attest container image provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ${{ steps.publish.outputs.name }}
subject-digest: ${{ steps.publish.outputs.digest }}
push-to-registry: true
# Generate container provenance in the ISOLATED reusable workflow (SLSA L3).
# apko has no BuildKit provenance, so attest.yml generates it explicitly with the
# signing key isolated from this build job (replaces the former in-job
# attest-build-provenance / Dockerfile-buildx `provenance: mode=max`).
attest-image:
needs:
- container-image-release
permissions:
id-token: write
attestations: write
packages: write
contents: read
uses: ./.github/workflows/attest.yml
with:
subject-name: ${{ needs.container-image-release.outputs.image-name }}
subject-digest: ${{ needs.container-image-release.outputs.image-digest }}
push-to-registry: true

release-inspection-summary:
name: Release Inspection Summary
Expand All @@ -384,6 +413,8 @@ jobs:
- resolve-release
- binary-release-assets
- container-image-release
- attest-binaries
- attest-image
permissions: {}
steps:
- name: Write inspection summary
Expand All @@ -403,7 +434,7 @@ jobs:
echo '```sh'
echo "gh release view $RELEASE_TAG --repo $GITHUB_REPOSITORY --json isDraft,assets"
echo "asset=\"template-mcp_${RELEASE_VERSION}_\$(go env GOOS)_\$(go env GOARCH)\""
echo "gh attestation verify \"dist/release-assets/\${asset}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/release.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners"
echo "gh attestation verify \"dist/release-assets/\${asset}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/attest.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners"
echo "ghd download \"$GITHUB_REPOSITORY/template-mcp@${RELEASE_VERSION}\" --output \"\$(mktemp -d)\""
echo '```'
echo
Expand All @@ -413,7 +444,7 @@ jobs:
echo "docker login ghcr.io"
echo "docker pull \"${IMAGE_NAME}:${RELEASE_TAG}\""
echo "docker run --rm \"${IMAGE_NAME}:${RELEASE_TAG}\" --version"
echo "gh attestation verify \"oci://${IMAGE_NAME}@${IMAGE_DIGEST}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/release.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners"
echo "gh attestation verify \"oci://${IMAGE_NAME}@${IMAGE_DIGEST}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/attest.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners"
echo "cosign verify \"${IMAGE_NAME}@${IMAGE_DIGEST}\" --certificate-identity-regexp \"^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@.*\" --certificate-oidc-issuer https://token.actions.githubusercontent.com"
echo '```'
echo
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,9 @@ The release path is:
- Release Please creates a draft GitHub release and tag after merge.
- Release Dry Run rehearses the GoReleaser binary path and the native-runner melange/apko container build path on pull requests.
- GoReleaser builds binaries, checksums, and SBOMs without publishing directly.
- The release workflow uploads assets to the draft release and creates a GitHub-hosted attestation for `checksums.txt`.
- The release workflow builds per-arch signed apks with melange on native GitHub-hosted runners, assembles and publishes `ghcr.io/meigma/template-mcp:vX.Y.Z` as a multi-platform apko manifest, signs it with keyless cosign, and attaches GitHub-native SBOM and provenance attestations for the manifest digest.
- The release workflow uploads assets to the draft release; binary checksum provenance is attested from an isolated reusable workflow (`.github/workflows/attest.yml`).
- The release workflow builds per-arch signed apks with melange on native GitHub-hosted runners, assembles and publishes `ghcr.io/meigma/template-mcp:vX.Y.Z` as a multi-platform apko manifest, signs it with keyless cosign, attaches a GitHub-native SBOM attestation, and attests image provenance from the same isolated `attest.yml` workflow.
- Provenance is generated in `attest.yml` so its signing key is unreachable by the build jobs (SLSA Build L3). Verify with `gh attestation verify <artifact-or-oci-ref> --signer-workflow <repo>/.github/workflows/attest.yml`; the keyless cosign image signature is still issued by `release.yml`.
- A human inspects the draft release before publication.

The root `ghd.toml` matches the default GoReleaser output so generated projects can be installed with `ghd` once the release workflow runs.
Expand Down
2 changes: 1 addition & 1 deletion ghd.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
version = 1

[provenance]
signer_workflow = "meigma/template-mcp/.github/workflows/release.yml"
signer_workflow = "meigma/template-mcp/.github/workflows/attest.yml"

[[packages]]
name = "template-mcp"
Expand Down