Skip to content

docs(upgrading): say which kustomize panics on an invalid images: nam… #175

docs(upgrading): say which kustomize panics on an invalid images: nam…

docs(upgrading): say which kustomize panics on an invalid images: nam… #175

Workflow file for this run

# Trusted pipeline for pushes to main: run the full validation workflow
# (ci.yml, the exact same jobs a PR gets), and only after everything is green
# let release-please version, publish, sign, and attest. Nothing here runs for
# pull requests, so write tokens never meet unreviewed code.
#
# Why release-please lives here instead of a tag-triggered workflow: tags
# created with GITHUB_TOKEN never trigger other workflows (GitHub's recursion
# guard), so publishing must be chained behind release-please in the same run.
# That is also what guarantees "the whole pipeline passed before we release".
# See docs/ci-overview.md for the full CI/CD design.
name: Release
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: configbutler/gitops-reverser
CHART_REGISTRY: ghcr.io/configbutler/charts
permissions:
contents: read
# Never cancel a release mid-publish; a follow-up push queues behind it.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
ci:
name: CI
uses: ./.github/workflows/ci.yml
# Upper bound for the called workflow's jobs; each job inside ci.yml
# still requests only what it needs.
permissions:
contents: read
packages: write
id-token: write
security-events: write
secrets: inherit
release-please:
name: Release Please
runs-on: ubuntu-latest
timeout-minutes: 10
needs: ci
permissions:
contents: write
pull-requests: write
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- name: Run release-please
id: release
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
# Cut releases here, but do NOT open the next release PR yet.
# release-please creates the release as a draft (immutable-releases
# requires every asset attached before publish — see publish-manifest/
# publish-helm), and a draft is invisible to release-please's own
# "latest release" lookup. Opening the next PR in this same invocation
# would compute it against the whole history and propose a bogus
# "release everything" version. The release-please-pr job opens/refreshes
# the PR *after* publish-release makes the release visible.
skip-github-pull-request: true
# Merge the per-arch release-grade digests — already built and pushed by the
# `ci` run of *this* workflow (see build-release-* in ci.yml) — into one
# multi-arch manifest, then sign it and attach SLSA build provenance and an
# SPDX SBOM as registry attestations. ZERO builds happen here: the release
# tail retags the CI digests instead of rebuilding them.
publish-manifest:
name: Publish Multi-Arch Manifest
runs-on: ubuntu-latest
timeout-minutes: 20
# Its own release gate: with the `publish` rebuild job deleted,
# publish-manifest no longer inherits the gate transitively. Without this an
# ungated run on every main push would move latest/semver tags onto
# non-release candidate digests.
needs: [ci, release-please]
if: needs.release-please.outputs.release_created == 'true'
permissions:
contents: write # append release notes + SBOM asset
packages: write
id-token: write # cosign keyless signing + attestation OIDC
attestations: write
steps:
- name: Download digests
# Same-run artifacts uploaded by the called ci.yml (build-release-*).
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Verify release digests are present
# Fail loudly if build-release-* produced nothing (e.g. a bad gate
# skipped them): a retag with no digests must never silently no-op.
working-directory: /tmp/digests
run: |
count="$(find . -type f | wc -l)"
if [ "${count}" -eq 0 ]; then
echo "No release-grade digests found — build-release-* did not publish" >&2
exit 1
fi
echo "Found ${count} release digest(s) to retag"
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Login to registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
type=semver,pattern={{major}},value=${{ needs.release-please.outputs.version }}
type=raw,value=latest
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Resolve multi-arch manifest digest
id: digest
run: |
docker buildx imagetools inspect "${REGISTRY}/${IMAGE_NAME}:${VERSION}"
DIGEST="$(docker buildx imagetools inspect "${REGISTRY}/${IMAGE_NAME}:${VERSION}" \
--format '{{json .Manifest.Digest}}' | tr -d '"')"
echo "Release digest: ${DIGEST}"
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
env:
VERSION: ${{ steps.meta.outputs.version }}
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
# Keep in sync with COSIGN_VERSION in .devcontainer/Dockerfile — this
# is the only other job that signs release artifacts (publish-helm),
# and it gets cosign from that image. Without this input the action
# defaults to its own (older) pinned cosign release.
cosign-release: v3.1.3
- name: Sign the multi-arch image (cosign keyless)
# One signature on the digest covers every tag pointing at it.
run: cosign sign --yes "${REGISTRY}/${IMAGE_NAME}@${{ steps.digest.outputs.digest }}"
- name: Attest SLSA build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.digest.outputs.digest }}
push-to-registry: true
- name: Generate SBOM (SPDX)
uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.digest.outputs.digest }}
format: spdx-json
output-file: sbom.spdx.json
upload-artifact: false
upload-release-assets: false
- name: Attest SBOM
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.digest.outputs.digest }}
sbom-path: sbom.spdx.json
push-to-registry: true
# The image itself is already signed + attested above, but OpenSSF
# Scorecard's Signed-Releases check only looks at the GitHub *release
# assets*, not the OCI registry. Sign and attest the SBOM asset directly
# so the release itself carries a signature (*.sigstore.json) and SLSA
# provenance (*.intoto.jsonl) next to it.
- name: Sign the SBOM asset (cosign keyless)
run: cosign sign-blob --bundle sbom.spdx.json.sigstore.json --yes sbom.spdx.json
- name: Attest SLSA provenance for the SBOM asset
id: attest-sbom-asset
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: sbom.spdx.json
- name: Rename provenance bundle to the Scorecard-recognized suffix
run: cp "${{ steps.attest-sbom-asset.outputs.bundle-path }}" sbom.spdx.json.intoto.jsonl
- name: Update release info
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ needs.release-please.outputs.tag_name }}
# Keep the release a draft: GitHub's immutable-releases enforcement
# forbids adding assets once a release is published, so every asset
# must land while it is still a draft. The publish-release job flips
# it to published after all assets are attached.
draft: true
append_body: true
files: |
sbom.spdx.json
sbom.spdx.json.sigstore.json
sbom.spdx.json.intoto.jsonl
body: |
## Installation
### Quick Install (plain manifests)
The CRDs ship as their own file and must be applied **first** — the bundle
contains a custom resource that cannot be created before its CRD exists:
```bash
kubectl apply -f https://github.com/ConfigButler/gitops-reverser/releases/download/${{ needs.release-please.outputs.tag_name }}/crds.yaml
kubectl apply -f https://github.com/ConfigButler/gitops-reverser/releases/download/${{ needs.release-please.outputs.tag_name }}/install.yaml
```
### Helm Chart
```bash
helm install gitops-reverser oci://${{ env.CHART_REGISTRY }}/gitops-reverser --version ${{ needs.release-please.outputs.version }}
```
## Docker Images
This release is available as a Docker image:
```bash
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-please.outputs.version }}
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
```
**Platforms:** linux/amd64, linux/arm64
**Digest:** `${{ steps.digest.outputs.digest }}`
## Standalone analyzer
`manifest-analyzer` answers "which folders in this repository could become
GitTargets?" without a cluster. Binaries are attached for linux/amd64,
linux/arm64 and darwin/arm64, with a `manifest-analyzer.sha256sums` file:
```bash
curl -fsSLO https://github.com/ConfigButler/gitops-reverser/releases/download/${{ needs.release-please.outputs.tag_name }}/manifest-analyzer_linux_amd64
curl -fsSL https://github.com/ConfigButler/gitops-reverser/releases/download/${{ needs.release-please.outputs.tag_name }}/manifest-analyzer.sha256sums | sha256sum -c --ignore-missing
```
## Verify this release
Images are signed with cosign (keyless) and carry SLSA build provenance
and an SPDX SBOM as registry attestations:
```bash
cosign verify \
--certificate-identity-regexp '^https://github.com/ConfigButler/gitops-reverser/\.github/workflows/release\.yml@refs/heads/main$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-please.outputs.version }}
gh attestation verify \
oci://${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-please.outputs.version }} \
--repo ConfigButler/gitops-reverser
```
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Publish the Helm chart (packaged and tested by the CI run above) to GHCR.
publish-helm:
name: Publish Helm Chart
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [ci, release-please]
if: needs.release-please.outputs.release_created == 'true'
permissions:
contents: write # upload install.yaml release asset
packages: write
id-token: write # cosign keyless signing + attestation OIDC
attestations: write
container:
image: ${{ needs.ci.outputs.ci-image }}
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Configure Git safe directory
run: git config --global --add safe.directory /__w/gitops-reverser/gitops-reverser
- name: Download tested release bundle artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-bundle
path: .
- name: Login to GitHub Container Registry
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ${{ env.REGISTRY }} --username ${{ github.actor }} --password-stdin
- name: Push Helm chart to GHCR
id: chart
run: |
helm push ./gitops-reverser.tgz "oci://${CHART_REGISTRY}" 2>&1 | tee chart-push.log
DIGEST="$(awk '/Digest:/ {print $2}' chart-push.log)"
if [ -z "${DIGEST}" ]; then
echo "Could not extract chart digest from helm push output" >&2
exit 1
fi
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
# cosign ships baked into the ci-image this job runs in (see
# .devcontainer/Dockerfile) — no installer step needed here.
- name: Log in to registry for cosign
# `helm registry login` writes helm's own registry config; cosign reads
# ~/.docker/config.json, so it needs its own login or the signature
# upload fails with UNAUTHORIZED.
env:
COSIGN_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COSIGN_USER: ${{ github.actor }}
run: cosign login "${REGISTRY}" --username "${COSIGN_USER}" --password "${COSIGN_TOKEN}"
- name: Sign the Helm chart (cosign keyless)
run: cosign sign --yes "${CHART_REGISTRY}/gitops-reverser@${{ steps.chart.outputs.digest }}"
# Artifact Hub reads the repository metadata (verified-publisher flag,
# ownership claim) from a plain OCI artifact stored in the chart
# repository under the fixed `artifacthub.io` tag, identified by the
# layer media type. `helm push` cannot produce that shape, so oras does
# it — see https://artifacthub.io/docs/topics/repositories/helm-charts/.
# Re-pushed on every release so the file in Git stays the source of truth.
- name: Push Artifact Hub repository metadata
working-directory: charts
env:
ORAS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ORAS_USER: ${{ github.actor }}
run: |
echo "${ORAS_TOKEN}" | oras login "${REGISTRY}" --username "${ORAS_USER}" --password-stdin
oras push "${CHART_REGISTRY}/gitops-reverser:artifacthub.io" \
--config /dev/null:application/vnd.cncf.artifacthub.config.v1+yaml \
artifacthub-repo.yml:application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml
# The chart is already signed above via its OCI digest, but OpenSSF
# Scorecard's Signed-Releases check only looks at the GitHub *release
# assets*, not the OCI registry. Sign and attest BOTH installer files
# directly so the release carries a signature (*.sigstore.json) and SLSA
# provenance (*.intoto.jsonl) next to each. The installer ships as two
# files — crds.yaml is applied first, then install.yaml (see the release
# notes); each is signed independently so either can be verified alone.
- name: Sign install.yaml (cosign keyless)
run: cosign sign-blob --bundle install.yaml.sigstore.json --yes dist/install.yaml
- name: Sign crds.yaml (cosign keyless)
run: cosign sign-blob --bundle crds.yaml.sigstore.json --yes dist/crds.yaml
- name: Attest SLSA provenance for install.yaml
id: attest-install-yaml
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: dist/install.yaml
- name: Rename provenance bundle to the Scorecard-recognized suffix
run: cp "${{ steps.attest-install-yaml.outputs.bundle-path }}" install.yaml.intoto.jsonl
- name: Attest SLSA provenance for crds.yaml
id: attest-crds-yaml
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: dist/crds.yaml
- name: Rename crds provenance bundle to the Scorecard-recognized suffix
run: cp "${{ steps.attest-crds-yaml.outputs.bundle-path }}" crds.yaml.intoto.jsonl
- name: Upload installer bundle as release assets
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ needs.release-please.outputs.tag_name }}
# Attach to the still-draft release; publish-release publishes it once
# every asset is in place (immutable releases reject post-publish
# uploads).
draft: true
files: |
dist/crds.yaml
crds.yaml.sigstore.json
crds.yaml.intoto.jsonl
dist/install.yaml
install.yaml.sigstore.json
install.yaml.intoto.jsonl
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Build, checksum, sign and attest the manifest-analyzer binary, and attach it to the
# draft release. A tool that execs the analyzer to decide which folders it may offer a
# tenant is pinning us by binary, and `go install` from source is the one unverifiable
# link in a devcontainer that pins every other tool from a release asset.
#
# Both verification mechanisms ship, because the two consumers asking for this do not
# agree on one: `gh attestation verify` is the stronger claim, and a sha256sums file is
# what a `curl | sha256sum -c` line in a Dockerfile can consume without a GitHub token.
publish-analyzer:
name: Publish manifest-analyzer
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [ci, release-please]
if: needs.release-please.outputs.release_created == 'true'
permissions:
contents: write # upload release assets
id-token: write # cosign keyless signing + attestation OIDC
attestations: write
container:
image: ${{ needs.ci.outputs.ci-image }}
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# A container job defaults to `sh`, not the `bash` a runner-hosted job gets, and dash
# rejects `set -o pipefail` outright: the build step below died on its own first line
# with "Illegal option -o pipefail", before compiling anything. Declaring the shell for
# the whole job keeps a later step from silently inheriting dash again.
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Configure Git safe directory
run: git config --global --add safe.directory /__w/gitops-reverser/gitops-reverser
# linux/amd64 + linux/arm64 cover the devcontainers that consume this; darwin/arm64
# covers a developer running the same scan locally. The version is stamped into the
# library, not into main, so the string the binary prints is the same one every
# report it emits carries.
- name: Build the analyzer for every published platform
env:
VERSION: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
mkdir -p dist/analyzer
for target in linux/amd64 linux/arm64 darwin/arm64; do
os="${target%/*}"
arch="${target#*/}"
CGO_ENABLED=0 GOOS="${os}" GOARCH="${arch}" go build \
-trimpath \
-ldflags "-s -w -X github.com/ConfigButler/gitops-reverser/pkg/manifestanalyzer.version=${VERSION}" \
-o "dist/analyzer/manifest-analyzer_${os}_${arch}" \
./cmd/manifest-analyzer
done
ls -l dist/analyzer
- name: Verify the built binary reports the release it was built from
env:
VERSION: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
printed="$(./dist/analyzer/manifest-analyzer_linux_amd64 --version)"
echo "${printed}"
echo "${printed}" | grep -qF "${VERSION}" || {
echo "the binary does not report ${VERSION}; the ldflags path is broken" >&2
exit 1
}
- name: Write the checksums file
working-directory: dist/analyzer
run: sha256sum manifest-analyzer_* > manifest-analyzer.sha256sums
- name: Sign the checksums file (cosign keyless)
working-directory: dist/analyzer
run: |
cosign sign-blob --bundle manifest-analyzer.sha256sums.sigstore.json --yes manifest-analyzer.sha256sums
- name: Attest SLSA provenance for the analyzer binaries
id: attest-analyzer
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: dist/analyzer/manifest-analyzer_*
- name: Rename provenance bundle to the Scorecard-recognized suffix
run: cp "${{ steps.attest-analyzer.outputs.bundle-path }}" dist/analyzer/manifest-analyzer.intoto.jsonl
- name: Upload the analyzer as release assets
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ needs.release-please.outputs.tag_name }}
# Attach to the still-draft release; publish-release publishes it once every
# asset is in place (immutable releases reject post-publish uploads).
draft: true
files: |
dist/analyzer/manifest-analyzer_linux_amd64
dist/analyzer/manifest-analyzer_linux_arm64
dist/analyzer/manifest-analyzer_darwin_arm64
dist/analyzer/manifest-analyzer.sha256sums
dist/analyzer/manifest-analyzer.sha256sums.sigstore.json
dist/analyzer/manifest-analyzer.intoto.jsonl
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Publish the release only after every asset is attached. release-please
# creates the GitHub release as a draft (see release-please-config.json);
# GitHub's immutable-releases enforcement freezes a release the moment it is
# published, so publish-manifest and publish-helm attach their assets to the
# draft and this job flips it to published at the very end. If either publish
# job fails, the release stays an unpublished draft instead of a
# half-populated immutable release.
publish-release:
name: Publish GitHub Release
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [release-please, publish-manifest, publish-helm, publish-analyzer]
if: needs.release-please.outputs.release_created == 'true'
permissions:
contents: write # publish the draft release (creates the tag)
steps:
- name: Publish the draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ needs.release-please.outputs.tag_name }}
run: gh release edit "${TAG_NAME}" --draft=false --repo "${{ github.repository }}"
# The PR half of release-please, run as a separate invocation so it sees an
# already-published release instead of the draft the release-please job just
# created. skip-github-release: true means it only opens/refreshes the next
# release PR — it never tags or releases.
#
# Gating: this must run AFTER publish-release when a release was cut (so the
# release is published and visible), but also on ordinary pushes that cut no
# release (so the pending release PR keeps tracking new commits). The `if`
# therefore runs whenever CI + release-please succeeded and EITHER no release
# was created (publish-release was skipped) OR publish-release succeeded — so a
# failed publish (release still a draft) will NOT open a PR against the draft.
release-please-pr:
name: Release Please (PR)
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [ci, release-please, publish-release]
if: >-
${{ !cancelled()
&& needs.ci.result == 'success'
&& needs.release-please.result == 'success'
&& (needs.publish-release.result == 'success'
|| needs.release-please.outputs.release_created != 'true') }}
permissions:
contents: write
pull-requests: write
steps:
- name: Open or refresh the next release PR
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
skip-github-release: true