diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7fa3b49..0385c22 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -130,6 +130,16 @@ jobs: app-name: app dry-run: true + test-publish-manifests: + name: "[Test] Publish Manifests - Dry Run" + uses: ./.github/workflows/publish-manifests.yaml + permissions: + contents: read + packages: write + id-token: write + with: + dry-run: true + test-scan-for-todo-comments: name: "[Test] Scan for TODO Comments - Dry Run" uses: ./.github/workflows/scan-for-todo-comments.yaml @@ -576,6 +586,7 @@ jobs: test-deploy-github-pages, test-publish-dotnet-library, test-publish-app, + test-publish-manifests, test-scan-for-todo-comments, test-sync-cluster-policies, test-update-agent-skills, @@ -613,6 +624,7 @@ jobs: ${{ needs.test-deploy-github-pages.result }} ${{ needs.test-publish-dotnet-library.result }} ${{ needs.test-publish-app.result }} + ${{ needs.test-publish-manifests.result }} ${{ needs.test-scan-for-todo-comments.result }} ${{ needs.test-sync-cluster-policies.result }} ${{ needs.test-update-agent-skills.result }} diff --git a/.github/workflows/publish-manifests.yaml b/.github/workflows/publish-manifests.yaml new file mode 100644 index 0000000..34b73e6 --- /dev/null +++ b/.github/workflows/publish-manifests.yaml @@ -0,0 +1,106 @@ +name: πŸ“¦ Publish Manifests +on: + workflow_call: + inputs: + oci-name: + description: >- + OCI repository name (`/`) the manifests artifact is + published under, WITHOUT the registry prefix or the trailing + `/manifests` suffix. Defaults to the caller's `github.repository`. + Override when the repo name is an invalid OCI path component β€” e.g. the + org `.github` repo (leading dot) publishes under + `devantler-tech/github-config`. + required: false + default: "" + type: string + deploy-path: + description: "Path to the Kubernetes manifests directory packaged as the OCI artifact." + required: false + default: ./deploy + type: string + dry-run: + description: "Skip publish (validate workflow interface only)." + required: false + default: false + type: boolean + +permissions: {} + +jobs: + publish-manifests: + name: Publish manifests + if: ${{ !inputs.dry-run }} + runs-on: ubuntu-latest + permissions: + contents: read # checkout + packages: write # push manifests OCI artifact + id-token: write # keyless cosign signing (Fulcio/Rekor via GitHub OIDC) + env: + REGISTRY: ghcr.io # auth uses GITHUB_TOKEN, which is GHCR-scoped + steps: + - name: πŸ›‘οΈ Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: πŸ”’ Require a v* tag + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + run: | + if [ "$REF_TYPE" != "tag" ] || ! printf '%s' "$REF_NAME" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+'; then + echo "::error::publish-manifests must be invoked from a semver tag vX.Y.Z (got $REF_TYPE/$REF_NAME); Flux OCIRepository semver selection depends on it." + exit 1 + fi + + - name: πŸ“‘ Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: πŸ” Log in to registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ env.GH_TOKEN }} + + - name: βš™οΈ Setup Flux CLI + uses: fluxcd/flux2/action@1fd61a06264d71cf445ed55c4f14d401d26a1c64 # v2.8.8 + + - name: ✍️ Setup Cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: πŸ“¦ Push & sign manifests artifact + env: + REGISTRY: ${{ env.REGISTRY }} + OCI_NAME: ${{ inputs.oci-name }} + DEPLOY_PATH: ${{ inputs.deploy-path }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + ACTOR: ${{ github.actor }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # Default the OCI name to the caller repo; override (oci-name) for repos + # whose name is an invalid OCI path component, e.g. `.github`. + NAME="${OCI_NAME:-$REPOSITORY}" + VERSION="${REF_NAME#v}" + ARTIFACT="${REGISTRY}/${NAME}/manifests" + flux push artifact "oci://${ARTIFACT}:${VERSION}" \ + --path="${DEPLOY_PATH}" \ + --source="${SERVER_URL}/${REPOSITORY}" \ + --revision="${REF_NAME}@sha1:${SHA}" \ + --creds "${ACTOR}:${GH_TOKEN}" + flux tag artifact "oci://${ARTIFACT}:${VERSION}" --tag latest --creds "${ACTOR}:${GH_TOKEN}" + # Resolve tag β†’ digest and sign by digest (keyless via OIDC): cosign + # signs exactly the resolved bytes β€” no tagβ†’digest TOCTOU if the tag + # moves β€” and stays forward-compatible with cosign dropping tag-based + # signing. docker buildx imagetools is preinstalled on ubuntu-latest; + # cosign's own `cosign manifest digest` was removed in cosign v3.0. + DIGEST=$(docker buildx imagetools inspect "${ARTIFACT}:${VERSION}" --format '{{ .Manifest.Digest }}') + cosign sign --yes "${ARTIFACT}@${DIGEST}" diff --git a/README.md b/README.md index 555db9b..6e1cd50 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,46 @@ jobs: +### πŸ“¦ Publish Manifests + +
+Click to expand + +[.github/workflows/publish-manifests.yaml](.github/workflows/publish-manifests.yaml) is a workflow used to publish a Kubernetes manifests directory to GHCR as a cosign-signed OCI artifact β€” **with no container image build**. Use it for repos that ship only manifests (e.g. GitOps/Crossplane desired-state) rather than an application: it pushes the manifests directory as a Flux-compatible OCI artifact (`ghcr.io///manifests`, tagged with the semantic version derived from the git tag β€” e.g. `1.2.3` from a `v1.2.3` tag β€” plus `latest`), then signs the artifact by digest with keyless cosign (Fulcio/Rekor via GitHub OIDC). It is the manifests-only sibling of `publish-app.yaml` (which additionally builds and signs a container image). + +Because the signing happens inside this reusable workflow, the cosign certificate identity (OIDC `subject`) is this workflow's path β€” `https://github.com/devantler-tech/reusable-workflows/.github/workflows/publish-manifests.yaml@` β€” not the caller's. Verifiers (e.g. a Flux `OCIRepository` `verify.matchOIDCIdentity`) must match that. + +#### Usage + +```yaml +on: + push: + tags: + - "v*" + +jobs: + publish-manifests: + uses: devantler-tech/reusable-workflows/.github/workflows/publish-manifests.yaml@{ref} # ref + permissions: + contents: read # checkout + packages: write # push manifests OCI artifact + id-token: write # keyless cosign signing + with: + oci-name: devantler-tech/github-config # optional override; defaults to github.repository + deploy-path: ./deploy # optional +``` + +> **Note:** Must be invoked from a semver tag (`vX.Y.Z`) β€” Flux `OCIRepository` semver selection depends on it. The calling job must grant `packages: write` and `id-token: write` (and `contents: read` for checkout); no secrets are required (auth uses the GHCR-scoped `GITHUB_TOKEN`). Override `oci-name` when the repo name is an invalid OCI path component (e.g. `.github` β†’ `devantler-tech/github-config`). + +#### Secrets and Inputs + +| Key | Type | Default | Required | Description | +|---------------|----------------|----------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------| +| `oci-name` | Input (string) | `${{ github.repository }}` | No | OCI repository name (`/`) the artifact is published under, without the registry prefix or trailing `/manifests`. Override for invalid OCI path components | +| `deploy-path` | Input (string) | `./deploy` | No | Path to the Kubernetes manifests directory packaged as the OCI artifact | + +
+ ### πŸ“¦ Publish .NET Library