Skip to content

fix(git): open absolute-path files via osfs root "/" (fixes "public k… #733

fix(git): open absolute-path files via osfs root "/" (fixes "public k…

fix(git): open absolute-path files via osfs root "/" (fixes "public k… #733

Workflow file for this run

name: Build simple-container-com CLI
on:
workflow_dispatch:
push:
branches:
- 'main'
# allow only one concurrent build
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
# Least-privilege root (CIS GHA 1.2). Only `docker-finalize` (welder
# tag-release + deploy) and `finalize` (notify) need `contents: write`;
# both grant it per-job below.
permissions:
contents: read
jobs:
prepare:
name: Prepare build
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
# Trust-root ref guard. push.yaml signs production artifacts under
# the cosign keyless identity `push.yaml@refs/heads/main` (documented
# in docs/SECURITY.md). A `workflow_dispatch` from any non-main ref
# would sign + publish artifacts that fail consumer verification —
# the artifact ships looking-signed but no documented identity regex
# accepts it. Fail fast instead.
- name: Refuse to run on non-main ref
if: github.ref != 'refs/heads/main'
run: |
echo "::error title=push.yaml restricted to main::Got github.ref=$GITHUB_REF; this workflow signs artifacts under the production trust root (refs/heads/main only). Re-dispatch from main or remove workflow_dispatch from the trigger."
exit 1
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Get next version
uses: reecetech/version-increment@a29aa752dc3b8118a2dc2ed93faf0e95a73a9c7e # 2024.10.1
id: version
with:
scheme: "calver"
increment: "patch"
use_api: "true"
build-setup:
name: Build Setup (clean, tools, schemas, lint, fmt)
runs-on: blacksmith-8vcpu-ubuntu-2204
needs: prepare
outputs:
cicd-bot-telegram-token: ${{ steps.telegram-secrets.outputs.cicd-bot-telegram-token }}
cicd-bot-telegram-chat-id: ${{ steps.telegram-secrets.outputs.cicd-bot-telegram-chat-id }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: fregante/setup-git-user@024bc0b8e177d7e77203b48dab6fb45666854b35 # v2.0.2
- name: Set up Go with Blacksmith caching
uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6.0.1
with:
go-version: '1.25'
- name: install sc + welder tools
uses: simple-container-com/actions/setup-sc-tooling@0af5a697f24ea484991660619d0ae42d50343b9d # main
- name: prepare secrets for build
run: |
cat << EOF > ./.sc/cfg.default.yaml
${{ secrets.SC_CONFIG }}
EOF
cat << EOF > ./.sc/cfg.test.yaml
${{ secrets.SC_CONFIG }}
EOF
sc secrets reveal
- name: get openai key
id: get-openai-key
run: |
echo "openai-key=$(sc stack secret-get -s dist openai-api-key 2>/dev/null || echo '')" >> $GITHUB_OUTPUT
- name: prepare sc tool (rebuild)
shell: bash
env:
OPENAI_API_KEY: ${{ steps.get-openai-key.outputs.openai-key }}
SKIP_EMBEDDINGS: "true"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |-
gh auth setup-git
welder run rebuild
- name: clean
run: |
mkdir -p dist
rm -fR dist/*
mkdir -p .sc/stacks/dist/bundle
rm -fR .sc/stacks/dist/bundle/*
mkdir -p docs/site
rm -fR docs/site/*
mkdir -p docs/schemas
rm -fR docs/schemas/*
- name: tools
run: |
cat tools.go | grep _ | awk -F'"' '{print $2}' | xargs -tI % go get %
go mod download
go generate -tags tools
go mod tidy
- name: generate-schemas
run: |
echo "Generating JSON Schema files for Simple Container resources..."
go build -o bin/schema-gen ./cmd/schema-gen
bin/schema-gen docs/schemas
echo "Successfully generated JSON Schema files in docs/schemas/"
- name: fmt
run: |
go mod tidy
bin/gofumpt -l -w ./
bin/golangci-lint run --fix --timeout 3m -v
- name: get telegram secrets
id: telegram-secrets
run: |
echo "cicd-bot-telegram-token=$(./bin/sc stack secret-get -s dist cicd-bot-telegram-token)" >> $GITHUB_OUTPUT
echo "cicd-bot-telegram-chat-id=$(./bin/sc stack secret-get -s dist cicd-bot-telegram-chat-id)" >> $GITHUB_OUTPUT
- name: save schemas cache
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: docs/schemas
key: schemas-${{ github.run_id }}
- name: upload bin directory artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bin-tools
path: bin
retention-days: 1
build-platforms:
name: Build sc for ${{ matrix.os }}/${{ matrix.arch }}
runs-on: blacksmith-8vcpu-ubuntu-2204
needs: [prepare, build-setup]
permissions:
contents: read
id-token: write # OIDC for keyless cosign + attest-build-provenance
attestations: write
strategy:
matrix:
include:
- os: linux
arch: amd64
- os: darwin
arch: arm64
- os: darwin
arch: amd64
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Go with Blacksmith caching
uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6.0.1
with:
go-version: '1.25'
- name: create build directories
run: |
mkdir -p dist
mkdir -p .sc/stacks/dist/bundle
- name: build sc for ${{ matrix.os }}/${{ matrix.arch }}
env:
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
CGO_ENABLED: "0"
VERSION: ${{ needs.prepare.outputs.version }}
run: |
echo "Building for ${GOOS}/${GOARCH}..."
if [ "${GOOS}" = "windows" ]; then export EXT=".exe"; else export EXT=""; fi
go build -trimpath -buildvcs=false -ldflags "-s -w -X=github.com/simple-container-com/api/internal/build.Version=${VERSION}" -o dist/${GOOS}-${GOARCH}/sc${EXT} ./cmd/sc
# Deterministic tarball: fixed entry order, zeroed mtime/owner, a
# forced mode (independent of the builder umask) and gzip -9 -n
# (no embedded timestamp) so a rebuild reproduces it bit-for-bit.
tar --sort=name --mtime='@0' --owner=0 --group=0 --numeric-owner --mode='u=rwx,go=rx' -cf - -C dist/${GOOS}-${GOARCH} sc${EXT} | gzip -9 -n > .sc/stacks/dist/bundle/sc-${GOOS}-${GOARCH}.tar.gz
cp .sc/stacks/dist/bundle/sc-${GOOS}-${GOARCH}.tar.gz .sc/stacks/dist/bundle/sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz
# Phase 2: per-tarball SHA-256, CycloneDX SBOM, cosign keyless signature
# (self-contained bundle), SLSA provenance. Soft-fail per step — the tarball
# itself is the publish artifact; sidecars are best-effort during the 14-day
# bake-in. See HARDENING.md Phase 2 plan + verify-attestations.yml gate.
- name: SHA-256 sidecars for ${{ matrix.os }}/${{ matrix.arch }}
id: sha256
env:
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
cd .sc/stacks/dist/bundle
sha256sum "sc-${GOOS}-${GOARCH}.tar.gz" > "sc-${GOOS}-${GOARCH}.tar.gz.sha256"
sha256sum "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz" > "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz.sha256"
- name: Install attestation tools
id: install_attest_tools
continue-on-error: true # Best-effort during Phase 2 bake-in. If the
# installer flakes, downstream sign/SBOM/SLSA
# steps soft-skip via their own outcome
# guards; the tarball + .sha256 still publish.
uses: simple-container-com/actions/install-attest-tools@0af5a697f24ea484991660619d0ae42d50343b9d # main
- name: Generate CycloneDX SBOM for sc-${{ matrix.os }}-${{ matrix.arch }}
id: sbom_tarball
continue-on-error: true
env:
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
cd .sc/stacks/dist/bundle
syft scan "dir:../../../../dist/${GOOS}-${GOARCH}" \
-o "cyclonedx-json=sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz.sbom.cdx.json"
cp "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz.sbom.cdx.json" \
"sc-${GOOS}-${GOARCH}.tar.gz.sbom.cdx.json"
- name: Cosign sign-blob (keyless, bundle) for sc-${{ matrix.os }}-${{ matrix.arch }}
id: cosign_sign_tarball
continue-on-error: true
env:
COSIGN_EXPERIMENTAL: "1"
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
cd .sc/stacks/dist/bundle
for tarball in "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz" "sc-${GOOS}-${GOARCH}.tar.gz"; do
cosign sign-blob --yes \
--bundle "${tarball}.cosign-bundle" \
"${tarball}"
done
- name: SLSA build provenance for sc-${{ matrix.os }}-${{ matrix.arch }}
id: slsa_tarball
continue-on-error: true
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: |
.sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v${{ needs.prepare.outputs.version }}.tar.gz
.sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}.tar.gz
- name: Materialize SLSA provenance bundle next to tarballs
if: steps.slsa_tarball.outcome == 'success'
continue-on-error: true
env:
BUNDLE_PATH: ${{ steps.slsa_tarball.outputs.bundle-path }}
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
dest_dir=".sc/stacks/dist/bundle"
# attest-build-provenance emits a single Sigstore bundle covering all
# subjects; publish it under both tarball names so consumers can find
# it via the same prefix as the tarball they downloaded.
# Naming: .sigstore.json matches the actual format (Sigstore bundle, a
# single JSON object) — historical .intoto.jsonl is misleading because
# the file is not line-delimited.
for tarball in "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz" "sc-${GOOS}-${GOARCH}.tar.gz"; do
cp "$BUNDLE_PATH" "${dest_dir}/${tarball}.sigstore.json"
done
- name: Soft-fail aggregator for sc-${{ matrix.os }}-${{ matrix.arch }} attestation
if: always()
env:
SBOM_OUTCOME: ${{ steps.sbom_tarball.outcome }}
SIGN_OUTCOME: ${{ steps.cosign_sign_tarball.outcome }}
SLSA_OUTCOME: ${{ steps.slsa_tarball.outcome }}
run: |
fail=0
for v in "$SBOM_OUTCOME" "$SIGN_OUTCOME" "$SLSA_OUTCOME"; do
if [ "$v" != "success" ]; then fail=1; fi
done
if [ "$fail" -eq 1 ]; then
echo "::warning title=Attestation incomplete::sc-${{ matrix.os }}-${{ matrix.arch }} tarball published but one or more attestation steps failed (sbom=$SBOM_OUTCOME sign=$SIGN_OUTCOME slsa=$SLSA_OUTCOME)."
else
echo "All tarball attestation steps succeeded for ${{ matrix.os }}/${{ matrix.arch }}."
fi
- name: upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sc-${{ matrix.os }}-${{ matrix.arch }}
# Match both versioned (sc-os-arch-vX.Y.Z.tar.gz) AND unversioned (sc-os-arch.tar.gz).
# The old glob `sc-os-arch-*.tar.gz` required a hyphen before `*`, so it only matched
# the versioned tarball — the unversioned "latest" pointer was silently dropped from
# artifacts and therefore never re-uploaded to dist.simple-container.com. Result:
# sc.sh (which downloads the unversioned tarball when SIMPLE_CONTAINER_VERSION is
# empty) served stale v2026.3.6 for ~4 weeks after PR #186 merged.
# Phase 2: also captures .sha256, .sbom.cdx.json, .cosign-bundle, .sigstore.json sidecars.
path: |
.sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz
.sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.sha256
.sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.cosign-bundle
.sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.sigstore.json
.sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.sbom.cdx.json
retention-days: 1
build-binaries:
name: Build ${{ matrix.target }}
runs-on: blacksmith-8vcpu-ubuntu-2204
needs: [prepare, build-setup]
strategy:
matrix:
include:
- target: github-actions
cmd: github-actions
output: dist/github-actions
- target: cloud-helpers
cmd: cloud-helpers
output: dist/cloud-helpers
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Go with Blacksmith caching
uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6.0.1
with:
go-version: '1.25'
- name: create build directories
run: |
mkdir -p dist
- name: build ${{ matrix.target }}
env:
VERSION: ${{ needs.prepare.outputs.version }}
CGO_ENABLED: "0"
run: |
go build -trimpath -buildvcs=false -a -installsuffix cgo -ldflags "-s -w -X=github.com/simple-container-com/api/internal/build.Version=${VERSION}" -o ${{ matrix.output }} ./cmd/${{ matrix.cmd }}
- name: upload ${{ matrix.target }} binary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.target }}-binary
path: ${{ matrix.output }}
retention-days: 1
# Note (PR #257): the `build-github-actions-staging` job was removed
# alongside its sole consumer — the `github-actions-staging` matrix
# entry in docker-build. The `:staging` image tag is now exclusively
# published by build-staging.yml under the staging trust root.
test:
name: Run tests
runs-on: blacksmith-8vcpu-ubuntu-2204
needs: [prepare, build-setup]
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Go with Blacksmith caching
uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6.0.1
with:
go-version: '1.25'
- name: test
run: |
go test ./...
build-docs:
name: Build documentation
runs-on: ubuntu-latest
needs: [prepare, build-setup]
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: restore cached schemas
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: docs/schemas
key: schemas-${{ github.run_id }}
- name: build docs
run: |
# --require-hashes pins every transitive dep to a known SHA from
# docs/requirements.txt (generated via `pip-compile --generate-hashes`
# against docs/requirements.in). Closes Scorecard Pinned-Dependencies
# warning on this line.
#
# python:3.12-slim, SHA-pinned digest. Required because the
# patched versions of `requests` (>=2.33.0) and `urllib3`
# (>=2.7.0) used to close GHSA-gc5v / -mf9v / -qccp transitive-
# dep advisories declare `requires_python: >=3.10`. Python 3.9
# base would force keeping the vulnerable transitives.
# Digest is multi-platform (OCI image index) — covers amd64
# / arm64 / arm/v5 / arm/v7 / 386 / ppc64le / riscv64 / s390x.
docker run --rm -v $PWD/docs:/docs -w /docs python@sha256:401f6e1a67dad31a1bd78e9ad22d0ee0a3b52154e6bd30e90be696bb6a3d7461 sh -c "pip install --require-hashes -r requirements.txt && PATH=\$PATH:~/.local/bin mkdocs build"
- name: upload docs artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docs-site
path: docs/site
retention-days: 1
docker-build:
name: Docker build and push ${{ matrix.image }}
runs-on: blacksmith-8vcpu-ubuntu-2204
needs: [prepare, build-setup, build-platforms, build-binaries, test, build-docs]
permissions:
contents: read
id-token: write # OIDC token for keyless cosign + attest-build-provenance
attestations: write # required by actions/attest-build-provenance
strategy:
matrix:
include:
- image: kubectl
dockerfile: kubectl.Dockerfile
image_repo: simplecontainer/kubectl
tags: |
simplecontainer/kubectl:latest
simplecontainer/kubectl:${{ needs.prepare.outputs.version }}
- image: caddy
dockerfile: caddy.Dockerfile
image_repo: simplecontainer/caddy
tags: |
simplecontainer/caddy:latest
simplecontainer/caddy:${{ needs.prepare.outputs.version }}
- image: github-actions
dockerfile: github-actions.Dockerfile
image_repo: simplecontainer/github-actions
tags: |
simplecontainer/github-actions:latest
simplecontainer/github-actions:${{ needs.prepare.outputs.version }}
# The :staging tag is intentionally NOT published from this workflow.
# build-staging.yml (triggered on the `staging` branch) is the
# canonical owner of `simplecontainer/github-actions:staging` and signs
# it under the staging trust root. Publishing :staging here from main
# would either (a) double-sign with the production identity — breaking
# the SECURITY.md identity-regex contract — or (b) leave it unsigned,
# so consumers following the documented verification path would fail.
- image: cloud-helpers-aws
dockerfile: cloud-helpers.aws.Dockerfile
image_repo: simplecontainer/cloud-helpers
tags: |
simplecontainer/cloud-helpers:aws-latest
simplecontainer/cloud-helpers:aws-${{ needs.prepare.outputs.version }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: download github-actions binary
if: matrix.image == 'github-actions'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: github-actions-binary
path: dist
- name: download cloud-helpers binary
if: matrix.image == 'cloud-helpers-aws'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: cloud-helpers-binary
path: dist
- name: install sc tool
uses: simple-container-com/actions/install-sc@0af5a697f24ea484991660619d0ae42d50343b9d # main
- name: prepare secrets for build
run: |
cat << EOF > ./.sc/cfg.default.yaml
${{ secrets.SC_CONFIG }}
EOF
cat << EOF > ./.sc/cfg.test.yaml
${{ secrets.SC_CONFIG }}
EOF
sc secrets reveal
- name: Setup Docker Buildx with advanced caching
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
with:
driver-opts: |
image=moby/buildkit:buildx-stable-1
- name: Docker login using SC secrets
run: |
sc stack secret-get -s dist dockerhub-cicd-token | docker login --username simplecontainer --password-stdin
- name: Build and push ${{ matrix.image }} image
id: build_and_push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: linux/amd64
tags: ${{ matrix.tags }}
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
# Phase 2 attestation: keyless cosign sign + CycloneDX SBOM + SLSA L3
# provenance. The publish step above is the gating job. Sign/attest steps
# below are continue-on-error so a sigstore-public outage does not block
# releases. A trailing aggregator emits ::warning:: on any failure. After
# 14 days of clean post-publish verification (see verify-attestations),
# flip continue-on-error to false (tracked in HARDENING.md Phase 2 plan).
#
# `github-actions-staging` is intentionally skipped from attestation here.
# The `:staging` tag is the canonical output of build-staging.yml under
# the staging trust root (build-staging.yml@refs/heads/staging) and is
# signed there. Signing it again from push.yaml@refs/heads/main would
# mint a second valid signature under the production identity, which
# contradicts the split identity-regex contract in SECURITY.md and
# would cause consumer verification to flap between identities on
# successive main / staging pushes.
- name: Sign + attest ${{ matrix.image }} (SBOM + cosign + SLSA)
id: signattest
if: steps.build_and_push.outcome == 'success'
uses: simple-container-com/actions/sign-and-attest@0af5a697f24ea484991660619d0ae42d50343b9d # main
with:
image-ref: ${{ matrix.image_repo }}@${{ steps.build_and_push.outputs.digest }}
image-name: ${{ matrix.image }}
subject-name: index.docker.io/${{ matrix.image_repo }}
subject-digest: ${{ steps.build_and_push.outputs.digest }}
# Phase 2 bake-in: keep soft-fail until 14 days of clean
# verify-attestations on `main`. Flip to 'false' tracked in
# HARDENING.md Phase 2.
soft-fail: 'true'
# Record the immutable digest so verify-attestations can verify by
# @sha256:... instead of by mutable tag (closes the verify-by-tag TOCTOU
# window flagged in PR #257 round-1 review).
- name: Record image digest for verify-attestations
if: steps.build_and_push.outcome == 'success'
env:
DIGEST: ${{ steps.build_and_push.outputs.digest }}
IMAGE_REPO: ${{ matrix.image_repo }}
run: |
set -euo pipefail
mkdir -p digests
printf '%s@%s\n' "$IMAGE_REPO" "$DIGEST" > "digests/${{ matrix.image }}.txt"
- name: Upload digest artifact for ${{ matrix.image }}
if: steps.build_and_push.outcome == 'success'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: image-digest-${{ matrix.image }}
path: digests/${{ matrix.image }}.txt
retention-days: 1
- name: Soft-fail aggregator for ${{ matrix.image }} attestation
if: always() && steps.build_and_push.outcome == 'success'
env:
SIGN_OUTCOME: ${{ steps.signattest.outputs.sign-outcome }}
SBOM_OUTCOME: ${{ steps.signattest.outputs.sbom-outcome }}
ATTEST_SBOM_OUTCOME: ${{ steps.signattest.outputs.attest-outcome }}
SLSA_OUTCOME: ${{ steps.signattest.outputs.slsa-outcome }}
run: |
fail=0
for v in "$SIGN_OUTCOME" "$SBOM_OUTCOME" "$ATTEST_SBOM_OUTCOME" "$SLSA_OUTCOME"; do
if [ "$v" != "success" ]; then fail=1; fi
done
if [ "$fail" -eq 1 ]; then
echo "::warning title=Attestation incomplete::${{ matrix.image }} published but one or more attestation steps failed (sign=$SIGN_OUTCOME sbom=$SBOM_OUTCOME attest-sbom=$ATTEST_SBOM_OUTCOME slsa=$SLSA_OUTCOME). See verify-attestations workflow."
else
echo "All attestation steps succeeded for ${{ matrix.image }}."
fi
docker-finalize:
name: Docker finalize (tag-release, deploy)
runs-on: blacksmith-8vcpu-ubuntu-2204
needs: [prepare, build-setup, build-platforms, build-binaries, test, build-docs, docker-build]
permissions:
contents: write # `welder run tag-release` pushes the release git tag
id-token: write # OIDC for keyless cosign sign-blob of sc.sh
attestations: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: fregante/setup-git-user@024bc0b8e177d7e77203b48dab6fb45666854b35 # v2.0.2
- name: install sc + welder tools
uses: simple-container-com/actions/setup-sc-tooling@0af5a697f24ea484991660619d0ae42d50343b9d # main
- name: Install attestation tools (cosign for sc.sh sign-blob)
uses: simple-container-com/actions/install-attest-tools@0af5a697f24ea484991660619d0ae42d50343b9d # main
- name: prepare secrets for build
run: |
cat << EOF > ./.sc/cfg.default.yaml
${{ secrets.SC_CONFIG }}
EOF
cat << EOF > ./.sc/cfg.test.yaml
${{ secrets.SC_CONFIG }}
EOF
sc secrets reveal
- name: download sc tarball artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
# Filter to sc-* artifacts only — without a pattern, this step
# also pulls the build-push-action's internal `*.dockerbuild`
# cache artifacts (one per matrix item, often 100+ MB each) and
# the `image-digest-*` artifacts we publish for
# verify-attestations.yml to consume. None of those are needed
# here, and the .dockerbuild ones flake the parallel-download
# path: PR #257's first prod release (run 25959575686) failed
# at this step with "Artifact download failed after 5 retries"
# because the unfiltered download saturated the runner.
pattern: sc-*
path: artifacts
- name: download bin tools artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bin-tools
path: bin
- name: fix bin tools permissions
run: chmod +x bin/*
- name: download docs artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: docs-site
path: docs/site
- name: copy build artifacts to dist bundle
run: |
set -euo pipefail
mkdir -p .sc/stacks/dist/bundle
# Phase 2: copy tarballs + every sidecar (sha256, sbom, cosign-bundle,
# sigstore.json). Sidecars may be absent if sign/SBOM steps soft-failed
# in build-platforms — that is by design during the 14-day bake-in; the
# count-assertion step below records the gap rather than blocks the run.
shopt -s nullglob
for f in artifacts/sc-*/*.tar.gz \
artifacts/sc-*/*.tar.gz.sha256 \
artifacts/sc-*/*.tar.gz.cosign-bundle \
artifacts/sc-*/*.tar.gz.sigstore.json \
artifacts/sc-*/*.tar.gz.sbom.cdx.json; do
cp "$f" .sc/stacks/dist/bundle/
done
- name: Sidecar count assertion (Phase 2 bake-in — warn-only)
run: |
set -euo pipefail
cd .sc/stacks/dist/bundle
shopt -s nullglob
arr_tarballs=( sc-*.tar.gz ); tarballs=${#arr_tarballs[@]}
arr_shas=( sc-*.tar.gz.sha256 ); shas=${#arr_shas[@]}
arr_bundles=( sc-*.tar.gz.cosign-bundle ); bundles=${#arr_bundles[@]}
arr_provs=( sc-*.tar.gz.sigstore.json ); provs=${#arr_provs[@]}
arr_sboms=( sc-*.tar.gz.sbom.cdx.json ); sboms=${#arr_sboms[@]}
echo "tarballs=$tarballs sha256=$shas cosign-bundle=$bundles slsa=$provs sbom=$sboms"
if [ "$tarballs" -eq 0 ]; then
echo "::error title=No tarballs to publish::dist bundle has zero tarballs"
exit 1
fi
missing=0
# Every tarball has matching .sha256 / .cosign-bundle / .sigstore.json
# / .sbom.cdx.json sidecars; build-platforms duplicates the per-build
# outputs across the versioned and unversioned tarball names.
for kind_count in "sha256=$shas" \
"cosign-bundle=$bundles" \
"slsa=$provs" \
"sbom=$sboms"; do
label="${kind_count%%=*}"
count="${kind_count#*=}"
if [ "$count" -lt "$tarballs" ]; then
echo "::warning title=Sidecar count mismatch::${label} has $count of $tarballs expected sidecars (Phase 2 soft-fail)"
missing=1
fi
done
if [ "$missing" -eq 0 ]; then
echo "All sidecars present for every published tarball."
fi
- name: finalize dist bundle setup
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
cp sc.sh .sc/stacks/dist/bundle/sc.sh
sed -i -e "s/VERSION=\"0\\.0\\.0\"/VERSION=\"${VERSION}\"/" .sc/stacks/dist/bundle/sc.sh
echo "${VERSION}" > .sc/stacks/dist/bundle/version
# Sign the post-template sc.sh that will land on dist.simple-container.com.
# Signing the *post-sed* artifact is intentional: consumers verify the
# bytes they actually fetch. Identity matches the tarball regex in
# docs/SECURITY.md ("Verifying sc.sh itself") — push.yaml@refs/heads/main.
#
# Hard-fail on purpose (no continue-on-error): if signing breaks, the
# `welder deploy` step that publishes sc.sh + sc.sh.cosign-bundle never
# runs, so consumers never observe an sc.sh whose bundle is missing or
# stale. Atomic w.r.t. the script — both publish or neither.
- name: Cosign sign-blob (keyless, bundle) for sc.sh
env:
COSIGN_EXPERIMENTAL: "1"
run: |
set -euo pipefail
cd .sc/stacks/dist/bundle
cosign sign-blob --yes \
--bundle sc.sh.cosign-bundle \
sc.sh
- name: Run tag-release task after images are built
env:
VERSION: ${{ needs.prepare.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |-
gh auth setup-git
welder run tag-release
- name: publish sc tool
shell: bash
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |-
welder deploy -e prod --timestamps
# Create a GitHub Release on production tags only (skip preview /
# release-candidate tags that include `-pre.` or `-preview.`).
#
# Why this matters: OpenSSF Scorecard's Signed-Releases check
# queries GitHub's Releases API, NOT the tag list or the dist
# CDN. We've been signing tarballs all along (Phase 2 / PR #257),
# but the .sigstore.json + .cosign-bundle + .sha256 + .sbom.cdx.json
# sidecars only live on dist.simple-container.com. Scorecard
# returns score -1 ("no releases found") because the Releases API
# is empty.
#
# Attaching the existing signed sidecars as Release assets makes
# Scorecard discover them. The accepted signature suffixes per
# the upstream check (probes/releasesAreSigned/impl.go) are:
# .asc .minisig .sig .sign .sigstore .sigstore.json
# We attach .sigstore.json — the SLSA build provenance bundle
# emitted by actions/attest-build-provenance@v4. The .cosign-bundle
# is attached too for verification flexibility (not recognised by
# Scorecard's regex but useful for `cosign verify-blob --bundle`
# consumers).
- name: Create GitHub Release on production tags
if: needs.prepare.outputs.version != '' && !contains(needs.prepare.outputs.version, '-pre.') && !contains(needs.prepare.outputs.version, '-preview.')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: ./scripts/create-github-release.sh
finalize:
name: Finalize build and deploy for ${{ needs.prepare.outputs.stack-name }}
runs-on: ubuntu-latest
if: ${{ always() }}
permissions:
contents: write
needs:
- prepare
- build-setup
- docker-finalize
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
if: ${{ always() }}
with:
persist-credentials: false
- name: Extract git reference
id: extract_git_ref
if: ${{ always() }}
shell: bash
env:
COMMIT_MESSAGE: ${{ github.event.head_commit.message || github.event.workflow_run.head_commit.message }}
run: |-
message="$(printf '%s' "$COMMIT_MESSAGE" | tr -d '\n')"
# Truncate message if too long for Telegram (max ~200 chars to leave room for other content)
if [ ${#message} -gt 200 ]; then
# Take first 80 chars and last 80 chars with separator
truncated_message="${message:0:80}...${message: -80}"
message="$truncated_message"
fi
echo "branch=$GITHUB_REF_NAME" >> $GITHUB_OUTPUT
echo "message=$message" >> $GITHUB_OUTPUT
echo "author=$GITHUB_ACTOR" >> $GITHUB_OUTPUT
echo "url=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >> $GITHUB_OUTPUT
- name: Notify Telegram (success)
if: ${{ success() && !contains(needs.*.result, 'failure') }}
continue-on-error: true
uses: simple-container-com/actions/notify-telegram@0af5a697f24ea484991660619d0ae42d50343b9d # main
with:
chat-id: ${{ needs.build-setup.outputs.cicd-bot-telegram-chat-id }}
token: ${{ needs.build-setup.outputs.cicd-bot-telegram-token }}
text: '🚀 Released: '
link-url: ${{ github.event.head_commit.url || format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }}
link-text: 'v${{ needs.prepare.outputs.version }}'
suffix: ' by ${{ steps.extract_git_ref.outputs.author }}'
- name: Notify Telegram (failure)
if: ${{ failure() || contains(needs.*.result, 'failure') }}
continue-on-error: true
uses: simple-container-com/actions/notify-telegram@0af5a697f24ea484991660619d0ae42d50343b9d # main
with:
chat-id: ${{ needs.build-setup.outputs.cicd-bot-telegram-chat-id }}
token: ${{ needs.build-setup.outputs.cicd-bot-telegram-token }}
text: '❗ Release failed: '
link-url: ${{ github.event.head_commit.url || format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }}
link-text: 'v${{ needs.prepare.outputs.version }}'
suffix: ' by ${{ steps.extract_git_ref.outputs.author }}'
- name: Build failed due to previously failed steps
id: fail_if_needed
if: ${{ failure() || contains(needs.*.result, 'failure') }}
shell: bash
run: |-
exit 1