diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a62652c..bbfb01a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,21 +1,28 @@ name: Release -# The contract is docs/guides/release.md. Pushing a tag publishes nothing: this -# workflow runs only when dispatched by hand on a `vX.Y.Z` tag that is already on -# GitHub, with an explicit confirmation, and only for a version the owner has -# approved. The command, run from any directory: +# The contract is docs/guides/release.md. A release runs in two ways, and no other: # -# gh workflow run release.yml --repo simtabi/dbpantry --ref vX.Y.Z -f confirm=release-go +# git push origin vX.Y.Z releases vX.Y.Z +# gh workflow run release.yml --repo simtabi/dbpantry -f tag=vX.Y.Z the same, for a tag already pushed # -# It publishes for real: every image to ghcr.io/simtabi and the GitHub release. -# Nothing is published from a branch, a pull request or a tag push. -# src/internal/release's TestReleaseRunsOnlyOnAConfirmedDispatch fails if this -# gains an automatic trigger or loses the confirmation. +# Push a version tag only for final code: tags are final, and a pushed tag releases. +# A release publishes the GitHub release only: the notes and the source tarball, with +# its checksum and provenance. Container images are no longer published (owner, +# 2026-11-13: GitHub release only); the images on ghcr.io/simtabi are frozen. Every +# image is still built and scanned, and `make verify` still runs, before the release. +# src/internal/release's TestReleaseRunsOnAVersionTagOrOnRequest fails if this gains +# any other trigger, stops gating or checking out the tag, or publishes an image. +# +# dbpantry builds no release binaries, so it keeps its own jobs rather than calling +# simtabi/.github's shared go-release.yml, which runs GoReleaser. on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' workflow_dispatch: inputs: - confirm: - description: Type release-go to publish for the tag this run is dispatched on + tag: + description: An existing vX.Y.Z tag to release required: true type: string @@ -29,10 +36,10 @@ env: GOPRIVATE: github.com/simtabi/* GONOSUMDB: github.com/simtabi/* -# Two runs for one tag would race to push the same manifests. A release is -# never cancelled half-way: a partly pushed release is worse than a slow one. +# One run per tag, never cancelled: two runs must not publish the same release, +# and a partly published release is worse than a slow one. concurrency: - group: release-${{ github.ref }} + group: release-${{ inputs.tag || github.ref_name }} cancel-in-progress: false jobs: @@ -40,43 +47,44 @@ jobs: # What is being released, and whether it may be. # # Everything that can refuse cheaply refuses here, before a single image is - # built: a tag that is not on main, a version with no CHANGELOG section, or a - # matrix that would publish MongoDB. + # built: a tag that is not a version on GitHub, a tag that is not on main, a + # version with no CHANGELOG section, or a matrix that includes MongoDB. # --------------------------------------------------------------------------- prepare: name: resolve release runs-on: ubuntu-latest timeout-minutes: 5 + env: + TAG: ${{ inputs.tag || github.ref_name }} outputs: + tag: ${{ steps.release.outputs.tag }} version: ${{ steps.release.outputs.version }} images: ${{ steps.matrix.outputs.images }} steps: # The release gate, before anything is checked out or built. Every other - # job needs this one, so a refusal here publishes nothing. The ref must be - # a version tag: dispatched on a branch, github.ref_name below would be the - # branch name and the tag checks would be checking the wrong thing. - - name: Refuse without confirm=release-go on a vX.Y.Z tag + # job needs this one, so a refusal here publishes nothing. The tag comes + # from the pushed ref or the dispatch's tag input, and it must be a version + # tag already on GitHub: every later job checks out that tag, never a branch. + - name: Refuse anything but a vX.Y.Z tag on GitHub env: - CONFIRM: ${{ inputs.confirm }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - if [ "$CONFIRM" != "release-go" ]; then - echo "::error::refusing to publish: dispatch with -f confirm=release-go" + if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::refusing to publish: '${TAG}' is not a vX.Y.Z tag" + exit 1 + fi + if ! gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --silent; then + echo "::error::refusing to publish: ${TAG} is not a tag on ${GITHUB_REPOSITORY}; push it first" exit 1 fi - case "$GITHUB_REF" in - refs/tags/v[0-9]*) ;; - *) - echo "::error::refusing to publish: dispatch on a vX.Y.Z tag with --ref, not ${GITHUB_REF}" - exit 1 - ;; - esac # GitHub's own actions float on their major tag, per the Simtabi action # rule: a commit pin is for actions whose tag a third party controls. # Everything third-party here is a checksum-verified binary instead. - uses: actions/checkout@v7 with: + ref: refs/tags/${{ inputs.tag || github.ref_name }} # Full history: the tag must be checked against main below. fetch-depth: 0 # No step in this job pushes, so the token is not kept in .git/config. @@ -84,22 +92,24 @@ jobs: - id: release name: Resolve the version from the tag - env: - TAG: ${{ github.ref_name }} run: | set -euo pipefail - # The gate's glob is looser than semver; this is the real test. + # The gate admits a pre-release suffix; a dbpantry release is a final + # version only, so this is the real test. [[ "$TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] \ || { echo "::error::tag $TAG is not vX.Y.Z"; exit 1; } + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" # "Nothing is published from a branch": a tag pushed on a feature branch # would otherwise release code that main never reviewed. + # HEAD is the tag's commit (checked out above). GITHUB_SHA is not: on a + # dispatch it is the branch the run was started from. - name: Refuse a tag that is not on main run: | set -euo pipefail - git merge-base --is-ancestor "$GITHUB_SHA" origin/main \ - || { echo "::error::$GITHUB_REF_NAME points at a commit that is not on main"; exit 1; } + git merge-base --is-ancestor HEAD origin/main \ + || { echo "::error::$TAG points at a commit that is not on main"; exit 1; } # The release body is that version's CHANGELOG section. Checked here, so a # missing section fails in seconds rather than after an hour of builds. @@ -108,16 +118,16 @@ jobs: VERSION: ${{ steps.release.outputs.version }} run: scripts/release-notes "$VERSION" > /dev/null - # The same descriptor-derived matrix ci.yml builds, plus the cli image. - # MongoDB is referenced, never derived (docs/guides/licensing.md), so ci-matrix - # leaves it out; this step fails rather than trusts that. + # The same descriptor-derived matrix ci.yml builds, plus the cli image, for + # the scan gate. MongoDB is referenced, never derived (docs/guides/licensing.md), + # so ci-matrix leaves it out; this step fails rather than trusts that. - id: matrix - name: Resolve the images to publish + name: Resolve the images to scan run: | set -euo pipefail images="$(scripts/ci-matrix versions | jq -c '. + [{"engine":"cli","version":"dev"}]')" if jq -e 'any(.[]; .engine == "mongodb")' <<<"$images" >/dev/null; then - echo "::error::the matrix would publish MongoDB, which is SSPL-licensed and never published" + echo "::error::the matrix includes MongoDB, which is SSPL-licensed and never derived" exit 1 fi echo "images=$images" >> "$GITHUB_OUTPUT" @@ -126,10 +136,9 @@ jobs: # --------------------------------------------------------------------------- # Gate 1: every image builds and passes the Trivy gate on both architectures. # - # Nothing pushes until every image has passed, so a finding in one engine - # cannot leave the others half-published under this version. publish rebuilds - # and scans again rather than carrying images across jobs; the second scan is - # what guards the image that is actually pushed. + # Images are no longer published, but they are the toolkit's product and every + # user builds them with `make build`, so a release still refuses a tag whose + # images do not build or carry a fixable HIGH or CRITICAL finding. # --------------------------------------------------------------------------- scan: name: scan ${{ matrix.entry.engine }} ${{ matrix.entry.version }} (${{ matrix.arch }}) @@ -152,6 +161,7 @@ jobs: steps: - uses: actions/checkout@v7 with: + ref: refs/tags/${{ needs.prepare.outputs.tag }} persist-credentials: false - name: Build ${{ matrix.entry.engine }} ${{ matrix.entry.version }} @@ -224,6 +234,7 @@ jobs: steps: - uses: actions/checkout@v7 with: + ref: refs/tags/${{ needs.prepare.outputs.tag }} persist-credentials: false # Same pinned version as lint.yml; see the note there. @@ -262,187 +273,8 @@ jobs: done # --------------------------------------------------------------------------- - # Publish: build, scan again, push one image per architecture, and attach its - # SBOM and build provenance to it. - # - # Each architecture is pushed under its own tag (`-amd64`, `-arm64`); - # the manifest job joins them under the tags people pull. Docker login and - # SBOM generation use the CLI and a checksum-verified syft binary, not - # third-party actions, for the same reason trivy is installed that way. - # --------------------------------------------------------------------------- - publish: - name: publish ${{ matrix.entry.engine }} ${{ matrix.entry.version }} (${{ matrix.arch }}) - needs: [prepare, scan, verify] - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - permissions: - contents: read - packages: write # push to ghcr.io/simtabi - id-token: write # sign the attestations (Sigstore) - attestations: write # store them - artifact-metadata: write # the storage record for a pushed attestation - strategy: - fail-fast: true - matrix: - entry: ${{ fromJSON(needs.prepare.outputs.images) }} - arch: [amd64, arm64] - include: - - arch: amd64 - runner: ubuntu-latest - - arch: arm64 - runner: ubuntu-24.04-arm - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false - - - name: Build ${{ matrix.entry.engine }} ${{ matrix.entry.version }} - env: - ENGINE: ${{ matrix.entry.engine }} - VERSION: ${{ matrix.entry.version }} - run: | - set -euo pipefail - cp .env.example .env - if [ "$ENGINE" != "cli" ]; then - upper="$(printf '%s' "$ENGINE" | tr '[:lower:]-' '[:upper:]_')" - sed -i "s/^MDB_${upper}_VERSION=.*/MDB_${upper}_VERSION=${VERSION}/" .env - fi - scripts/build "$ENGINE" - - - name: Install trivy and syft - env: - TRIVY_VERSION: 0.73.0 - SYFT_VERSION: 1.55.0 - run: | - set -euo pipefail - case "$(uname -m)" in - x86_64) t_arch=64bit; s_arch=amd64 ;; - aarch64) t_arch=ARM64; s_arch=arm64 ;; - *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; - esac - base="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}" - tarball="trivy_${TRIVY_VERSION}_Linux-${t_arch}.tar.gz" - curl -fsSL -o "$tarball" "${base}/${tarball}" - curl -fsSL -o trivy-checksums.txt "${base}/trivy_${TRIVY_VERSION}_checksums.txt" - grep " ${tarball}\$" trivy-checksums.txt | sha256sum -c - - tar -xzf "$tarball" trivy - sudo mv trivy /usr/local/bin/ - - base="https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}" - tarball="syft_${SYFT_VERSION}_linux_${s_arch}.tar.gz" - curl -fsSL -o "$tarball" "${base}/${tarball}" - curl -fsSL -o syft-checksums.txt "${base}/syft_${SYFT_VERSION}_checksums.txt" - grep " ${tarball}\$" syft-checksums.txt | sha256sum -c - - tar -xzf "$tarball" syft - sudo mv syft /usr/local/bin/ - trivy --version - syft version - - - name: Scan the image about to be pushed - env: - ENGINE: ${{ matrix.entry.engine }} - VERSION: ${{ matrix.entry.version }} - run: | - trivy image --severity HIGH,CRITICAL --ignore-unfixed \ - --ignorefile .trivyignore.yaml --exit-code 1 --format table \ - "ghcr.io/simtabi/dbpantry-${ENGINE}:${VERSION}" - - - id: push - name: Push ${{ matrix.entry.engine }} ${{ matrix.entry.version }} (${{ matrix.arch }}) - env: - ENGINE: ${{ matrix.entry.engine }} - VERSION: ${{ matrix.entry.version }} - RELEASE: ${{ needs.prepare.outputs.version }} - ARCH: ${{ matrix.arch }} - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - repo="ghcr.io/simtabi/dbpantry-${ENGINE}" - # An engine's immutable tag is - (17-0.1.1); the cli - # image has no database version, so its tag is the release itself. - if [ "$ENGINE" = "cli" ]; then immutable="$RELEASE"; else immutable="${VERSION}-${RELEASE}"; fi - printf '%s' "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin - docker tag "${repo}:${VERSION}" "${repo}:${immutable}-${ARCH}" - # The digest is read from what the registry acknowledged, not from - # RepoDigests, whose meaning differs under the containerd image store. - docker push "${repo}:${immutable}-${ARCH}" | tee push.log - digest="$(grep -oE 'digest: sha256:[0-9a-f]{64}' push.log | tail -1 | cut -d' ' -f2)" - [ -n "$digest" ] || { echo "::error::no digest in the push output"; exit 1; } - echo "pushed ${repo}:${immutable}-${ARCH} @ ${digest}" - echo "repo=$repo" >> "$GITHUB_OUTPUT" - echo "digest=$digest" >> "$GITHUB_OUTPUT" - syft scan "docker:${repo}:${immutable}-${ARCH}" -o "spdx-json=sbom.spdx.json" - - - name: Attach the SBOM - uses: actions/attest@v4 - with: - subject-name: ${{ steps.push.outputs.repo }} - subject-digest: ${{ steps.push.outputs.digest }} - sbom-path: sbom.spdx.json - push-to-registry: true - - - name: Attach the build provenance - uses: actions/attest@v4 - with: - subject-name: ${{ steps.push.outputs.repo }} - subject-digest: ${{ steps.push.outputs.digest }} - push-to-registry: true - - # --------------------------------------------------------------------------- - # Join the two architectures under the tags people pull. - # - # Engines get `` (17), which follows the newest release, and - # `-` (17-0.1.1), which never moves. The cli image gets - # ``. `latest` appears nowhere (CLAUDE.md). - # --------------------------------------------------------------------------- - manifest: - name: manifest ${{ matrix.entry.engine }} ${{ matrix.entry.version }} - needs: [prepare, publish] - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - packages: write - id-token: write - attestations: write - artifact-metadata: write - strategy: - fail-fast: false - matrix: - entry: ${{ fromJSON(needs.prepare.outputs.images) }} - steps: - - id: create - name: Create the multi-architecture manifest - env: - ENGINE: ${{ matrix.entry.engine }} - VERSION: ${{ matrix.entry.version }} - RELEASE: ${{ needs.prepare.outputs.version }} - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - repo="ghcr.io/simtabi/dbpantry-${ENGINE}" - if [ "$ENGINE" = "cli" ]; then - immutable="$RELEASE"; tags=(-t "${repo}:${RELEASE}") - else - immutable="${VERSION}-${RELEASE}"; tags=(-t "${repo}:${immutable}" -t "${repo}:${VERSION}") - fi - printf '%s' "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin - docker buildx imagetools create "${tags[@]}" \ - "${repo}:${immutable}-amd64" "${repo}:${immutable}-arm64" - digest="$(docker buildx imagetools inspect "${repo}:${immutable}" --format '{{json .Manifest}}' | jq -r .digest)" - echo "published ${repo}:${immutable} @ ${digest}" - echo "repo=$repo" >> "$GITHUB_OUTPUT" - echo "digest=$digest" >> "$GITHUB_OUTPUT" - - - name: Attach the build provenance to the manifest - uses: actions/attest@v4 - with: - subject-name: ${{ steps.create.outputs.repo }} - subject-digest: ${{ steps.create.outputs.digest }} - push-to-registry: true - - # --------------------------------------------------------------------------- - # The GitHub release, last: it announces images that already exist. + # The GitHub release, last: only once every image has built and passed the + # scan, and `make verify` has passed on both architectures. # # The body is the version's CHANGELOG section with the contributors since # the previous tag appended (docs/guides/release.md), and the source tarball is @@ -450,7 +282,7 @@ jobs: # --------------------------------------------------------------------------- release: name: GitHub release - needs: [prepare, manifest] + needs: [prepare, scan, verify] runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -461,13 +293,14 @@ jobs: steps: - uses: actions/checkout@v7 with: + ref: refs/tags/${{ needs.prepare.outputs.tag }} fetch-depth: 0 persist-credentials: false - name: Write the release notes and the source tarball env: VERSION: ${{ needs.prepare.outputs.version }} - TAG: ${{ github.ref_name }} + TAG: ${{ needs.prepare.outputs.tag }} run: | set -euo pipefail scripts/release-notes "$VERSION" > notes.md @@ -490,7 +323,7 @@ jobs: - name: Create the release env: VERSION: ${{ needs.prepare.outputs.version }} - TAG: ${{ github.ref_name }} + TAG: ${{ needs.prepare.outputs.tag }} GH_TOKEN: ${{ github.token }} run: | gh release create "$TAG" --verify-tag --title "$TAG" --notes-file notes.md \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b0a71..f21caf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -The first release published by CI: every engine image and the cli image on GHCR, -for amd64 and arm64, each with an SBOM and build provenance. It also renames the -project to dbpantry and fixes the findings that failed the Trivy gate on every -image, replacing Cassandra's vulnerable vendored JARs rather than waiving them. +The first release published by CI: the GitHub release, with the source tarball +and its provenance, once every engine image and the cli image has built and passed +the Trivy gate on amd64 and arm64. Container images are no longer published. It +also renames the project to dbpantry and fixes the findings that failed the Trivy +gate on every image, replacing Cassandra's vulnerable vendored JARs rather than +waiving them. ### Upgrade @@ -24,11 +26,16 @@ image, replacing Cassandra's vulnerable vendored JARs rather than waiving them. `MDB_EXTRA_SANS`, then restart `pg-pooler`. ### Changed -- **Pushing a `vX.Y.Z` tag no longer publishes anything.** `release.yml` runs only from a - `workflow_dispatch` on a version tag already on GitHub, with `-f confirm=release-go`, and its first - step refuses anything else before a checkout or build: - `gh workflow run release.yml --repo simtabi/dbpantry --ref vX.Y.Z -f confirm=release-go`. - What a release publishes is unchanged (docs/guides/release.md, design.md D-76). +- **Container images are no longer published.** A release publishes the GitHub release only, with + the notes and the source tarball (owner, 2026-11-13). The `ghcr.io/simtabi/dbpantry-*` images are + frozen: no new tag is pushed and nothing is deleted from ghcr.io. Build the images with + `make build`. The Kubernetes backend and the Helm chart still name `ghcr.io/simtabi` as their + registry, so push your own build there or point `global.imageRegistry` at your own registry + (design.md D-77). +- **A release runs on a pushed `vX.Y.Z` tag, or on request for a tag already on GitHub:** + `gh workflow run release.yml --repo simtabi/dbpantry -f tag=vX.Y.Z`. This replaces the dispatch + gated on `-f confirm=release-go` (D-76). Every job checks out the tag, and the first step refuses + anything but a version tag that exists on GitHub (docs/guides/release.md, design.md D-77). - **`make dbpantry` writes `build//dbpantry`** (`build/macos/dbpantry`, `build/linux/dbpantry`), the org's build layout: one folder per OS and nothing else in `build/`. It wrote `build/dbpantry` before; delete that file if you built one. Check 67 fails on anything else in `build/`, and the diff --git a/README.md b/README.md index 48bde83..5f68aa7 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,9 @@ The descriptors are also published as a versioned JSON manifest by the `dbpantry ## Documentation The documentation lives in [`docs/`](docs/), indexed below. Engine images are not -published yet: the release workflow publishes them only when dispatched by hand -for an approved version (a tag push publishes nothing), and until then -`make build` builds them locally (see [Release](docs/guides/release.md)). +published: `make build` builds them locally, and the `ghcr.io/simtabi/dbpantry-*` +images are frozen. A release is the GitHub release, made by pushing a `vX.Y.Z` +tag (see [Release](docs/guides/release.md)). ### Guides — `docs/guides/` diff --git a/SECURITY.md b/SECURITY.md index 8bc1622..02355a8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,11 +24,12 @@ minor release, announced in `CHANGELOG.md`. ## Verifying what you run -Every published image carries a build-provenance attestation and an SBOM -generated in CI. +Container images are no longer published; `make build` builds them from +digest-pinned bases, and CI scans every one before a release. Each release's +source tarball carries a build-provenance attestation: ```bash -gh attestation verify oci://ghcr.io/simtabi/dbpantry-pg:17 --owner simtabi +gh attestation verify dbpantry-X.Y.Z.tar.gz --repo simtabi/dbpantry ``` Images are pinned by digest throughout this repository. If a digest in diff --git a/design.md b/design.md index e4fa5ad..27fefe1 100644 --- a/design.md +++ b/design.md @@ -490,6 +490,8 @@ Relied on: pgBouncer configuration reference (`, because a dispatch's `GITHUB_SHA` is its branch's head, not the tag's commit: the tag-on-`main` check now tests `HEAD` after that checkout. No pre-release tag filter is declared, since dbpantry refuses pre-releases. dbpantry builds no release binaries, so it keeps its own jobs rather than calling `simtabi/.github`'s shared `go-release.yml`, which runs GoReleaser. The Kubernetes backend (`scripts/backends/kubernetes.sh`) and the Helm chart (`global.imageRegistry: ghcr.io/simtabi`) still name the frozen registry; changing them is a separate decision. The guard is `src/internal/release`'s `TestReleaseRunsOnAVersionTagOrOnRequest`, which fails on any other trigger, a branch or non-version tag filter, a missing or optional `tag` input, a first step that is not the refusal, a checkout of anything but the tag, a job that does not need the gating job, or any image publish (registry login or push, `buildx --push`, `imagetools create`, `push-to-registry: true`, a `docker/` publish action, `packages: write`); `TestReleasePolicyRejectsAWrongRelease` proves each can fail, and that a local build and scan passes. + ## 8. arm64 deliverability **Nothing in the spec is undeliverable on arm64.** The named unknown anticipated gaps that do not exist: diff --git a/docs/guides/release.md b/docs/guides/release.md index fc4f307..078b4b6 100644 --- a/docs/guides/release.md +++ b/docs/guides/release.md @@ -3,37 +3,42 @@ How a version of dbpantry ships, and what a consumer gets. > **Status:** [`release.yml`](../../.github/workflows/release.yml) implements this -> contract. It has not run yet: v0.1.0 was released by hand, with no images, so -> the first owner-approved dispatch is the first that publishes anything. Until -> then the images exist only where `make build` built them. +> contract. v0.1.0 was tagged by hand, and the GitHub release has not been +> published for any version yet. +> +> **Container images are no longer published** (owner, 2026-11-13: a release ships +> on the GitHub release only). The `ghcr.io/simtabi/dbpantry-*` images are frozen: +> no new tag is pushed, and nothing is deleted from ghcr.io. Measured 2026-11-13, +> no dbpantry image had been published there. Build the images with `make build`. ## What ships -A release is one git tag, `vX.Y.Z`, plus one confirmed dispatch of the release -workflow on that tag. Together they produce: +A release is one git tag, `vX.Y.Z`, pushed to GitHub (see [Publishing](#publishing)). +It produces the GitHub release, and nothing else: | Artefact | Where | |---|---| -| Engine images, one per engine per supported major | `ghcr.io/simtabi/dbpantry-` | -| The `cli` image | `ghcr.io/simtabi/dbpantry-cli` | -| Source tarball | GitHub release | -| SBOM per image | attached to the image | -| Build provenance attestation | attached to the image | - -Every image is built for **amd64 and arm64**, from the same digest-pinned bases, -and both architectures are proven by CI before anything is pushed. There is no -emulated build: an engine with no arm64 upstream image is not published for -arm64 rather than shipped as a QEMU image that corrupts data under load. - -The derived images, `dbpantry-pgbouncer` and `dbpantry-patroni`, are not -published: `make build` builds them locally from the engine images it has just -built. +| Release notes, from the version's `CHANGELOG.md` section | GitHub release | +| Source tarball, `dbpantry-X.Y.Z.tar.gz`, and its `.sha256` | GitHub release | +| Build provenance attestation for the tarball | GitHub attestations | + +The images are the toolkit's product, but they are built where they run: +`make build` builds every engine image, the `cli` image and the derived +`dbpantry-pgbouncer` and `dbpantry-patroni` images from the digest-pinned bases. +Before a release is published, CI still builds every engine image and the `cli` +image on **amd64 and arm64**, on native runners, and scans each one. There is no +emulated build: an engine with no arm64 upstream image fails the release rather +than shipping as a QEMU image that corrupts data under load. **MongoDB is not published.** It is SSPL-licensed, so the toolkit references the upstream image rather than deriving one. See [Licensing](licensing.md). Check 31 fails the build if that ever changes silently. -## Image tags +## Image tags (frozen) + +No image is published any more. This is the tag scheme `release.yml` used for +`ghcr.io/simtabi/dbpantry-*` until 2026-11-13, kept so that a tag already pulled +can be read. `make build` tags local images `` (engines) and `dev` (cli). | Tag | Example | Moves | |---|---|---| @@ -42,9 +47,8 @@ fails the build if that ever changes silently. | ``, cli only | `dbpantry-cli:0.1.1` | never | | `-amd64`, `-arm64` | `dbpantry-pg:17-0.1.1-arm64` | never | -Pin `-`, or better its digest, for anything that matters. The -per-architecture tags are the two halves of each multi-architecture manifest; -pull the manifest, not them. `latest` is never published. +The per-architecture tags were the two halves of each multi-architecture +manifest. `latest` was never published. ## Versioning @@ -70,58 +74,60 @@ git tag -a v1.4.0 -m "..." git push origin v1.4.0 ``` -**Pushing a tag publishes nothing.** It names the version and fixes the commit; -no workflow starts. Nothing is published from a branch, a pull request or a tag -push. +**Pushing a `vX.Y.Z` tag releases it.** Push one only for final code that the +owner has approved: tags are final, and the push starts the release workflow. +Nothing is published from a branch or a pull request. ## Publishing -A release runs only when dispatched by hand, with this exact command, from any -directory: +A release runs in two ways, from any directory: ```bash -gh workflow run release.yml --repo simtabi/dbpantry --ref vX.Y.Z -f confirm=release-go +git -C ~/Artisan/projects/opensource/simtabi/packages/dbpantry push origin vX.Y.Z # releases vX.Y.Z +gh workflow run release.yml --repo simtabi/dbpantry -f tag=vX.Y.Z # the same, for a tag already pushed ``` -- `vX.Y.Z` must be a tag **already on GitHub** (see [Tagging](#tagging)). The - run is dispatched on that tag, so the workflow reads the version and the - commit from it. -- The command **publishes for real**: every image to `ghcr.io/simtabi` and the - GitHub release. Run it only for a version the owner has approved. -- Without `-f confirm=release-go`, or dispatched on anything but a `vX.Y.Z` tag, - the workflow's first step refuses before anything is checked out or built. - -`src/internal/release`'s `TestReleaseRunsOnlyOnAConfirmedDispatch` fails if -`release.yml` gains a `push`, `schedule`, `pull_request` or `release` trigger, -loses the required `confirm` input, or its first step stops refusing. +- The tag push is the normal path. The dispatch is for a tag that is already + on GitHub: a run that failed, or a tag pushed before this workflow existed. +- `vX.Y.Z` must be a tag **already on GitHub** (see [Tagging](#tagging)). Every + job checks out `refs/tags/vX.Y.Z`, so the version and the commit come from the + tag, never from the branch a dispatch was started from. +- Anything but a `vX.Y.Z` tag that exists on GitHub is refused by the + workflow's first step, before anything is checked out or built. + +`src/internal/release`'s `TestReleaseRunsOnAVersionTagOrOnRequest` fails if +`release.yml` gains a branch push, `schedule`, `pull_request` or `release` +trigger, a tag filter that is not a version, loses the required `tag` input, +stops refusing first, checks out anything but the tag, or publishes a container +image (a registry login or push, a manifest, an attestation pushed to a +registry, a `docker/` publish action, or `packages: write`). ## The workflow -`release.yml` runs only on a confirmed `workflow_dispatch` (see +`release.yml` runs on a pushed version tag or a dispatch naming one (see [Publishing](#publishing)). Its jobs run in this order, and each refuses as early as it can: | Job | What it does | Refuses when | |---|---|---| -| resolve release | refuses first unless `confirm` is `release-go` on a `vX.Y.Z` tag, then reads the version from the tag and the images from the engine descriptors | the dispatch is unconfirmed or not on a version tag, the tag is not on `main`, `CHANGELOG.md` has no section for it, or MongoDB is in the matrix | +| resolve release | refuses first unless the tag is a `vX.Y.Z` tag on GitHub, then checks it out, reads the version from it and the images to scan from the engine descriptors | the tag is not a version tag on GitHub, the tag is not on `main`, `CHANGELOG.md` has no section for it, or MongoDB is in the matrix | | scan | builds every image on both architectures and runs the Trivy gate | any image fails to build or has a fixable HIGH or CRITICAL finding | | verify | runs `make verify` on amd64 and arm64 | any check fails | -| publish | builds again, scans again, pushes each architecture, and attaches its SBOM and build provenance | the second scan fails | -| manifest | joins the two architectures under the tags above, with provenance | — | -| GitHub release | creates the release with the notes and the source tarball, its checksum and provenance | the release already exists | +| GitHub release | once scan and verify have passed, creates the release with the notes and the source tarball, its checksum and provenance | the release already exists | + +Nothing is published until scan and verify have passed for every image. The +images are built on native runners for each architecture, never under QEMU. +The workflow uses no third-party action: trivy is a binary checked against its +published checksums, and GitHub's own `actions/checkout` and `actions/attest` +float on their major tag, per the Simtabi action rule. -Nothing is pushed until scan and verify have passed for every image, so a -finding in one engine cannot leave the others half-published under the version. -The images are built on native runners for each architecture, never under QEMU. -The workflow uses no third-party action: docker login and `docker buildx -imagetools` come from the runner, and trivy and syft are binaries checked -against their published checksums. GitHub's own `actions/checkout` and -`actions/attest` float on their major tag, per the Simtabi action rule. +dbpantry builds no release binaries, so it keeps its own jobs rather than +calling `simtabi/.github`'s shared `go-release.yml`, which runs GoReleaser. -Verify an image's provenance with: +Verify the source tarball's provenance with: ```bash -gh attestation verify oci://ghcr.io/simtabi/dbpantry-pg:17-0.1.1 --owner simtabi +gh attestation verify dbpantry-X.Y.Z.tar.gz --repo simtabi/dbpantry ``` ## Release notes @@ -169,14 +175,9 @@ Then confirm, by hand: bottom of `CHANGELOG.md`. `scripts/release-notes X.Y.Z` prints the body the release will carry. 2. Merge it, once CI is green. -3. Tag the merge commit on `main` and push the tag (see [Tagging](#tagging)). - This publishes nothing. -4. Once the owner approves the version, dispatch the release with the command in - [Publishing](#publishing). - -The first publish creates each `dbpantry-*` package on GHCR, and GHCR creates a -package private. The owner sets each one public once, in the package settings; -until then a pull without credentials fails. +3. Once the owner approves the version, tag the merge commit on `main` and push + the tag (see [Tagging](#tagging) and [Publishing](#publishing)). The push + starts the release. ## Digest pinning @@ -196,8 +197,8 @@ make init make up ``` -Or pull images directly without the repository, pinned by digest for anything -that matters. +`make build` builds the images. They are no longer published to ghcr.io, so +there is no image to pull without the repository. `make self-update` updates the toolkit itself and never touches project data or `secrets/`. diff --git a/scripts/verify/48-runtime-detection.sh b/scripts/verify/48-runtime-detection.sh index ed10e4a..d46fa32 100755 --- a/scripts/verify/48-runtime-detection.sh +++ b/scripts/verify/48-runtime-detection.sh @@ -95,7 +95,10 @@ for d in /usr/bin /bin; do for f in "$d"/*; do n="${f##*/}" case "$n" in docker*|podman*|nerdctl*|container|socktainer|incus*|kubectl|lima*|colima) continue ;; esac - [[ -e "$tools/$n" ]] || ln -s "$f" "$tools/$n" + # -L as well as -e: a dangling symlink in /usr/bin (grub-ntldr-img on the + # 2026-11 ubuntu-latest image) fails -e, so /bin, which is /usr/bin there, + # would link the same name twice and ln would stop the check. + [[ -e "$tools/$n" || -L "$tools/$n" ]] || ln -s "$f" "$tools/$n" done done diff --git a/src/internal/release/workflow_test.go b/src/internal/release/workflow_test.go index d1c0eaa..90afb2b 100644 --- a/src/internal/release/workflow_test.go +++ b/src/internal/release/workflow_test.go @@ -6,6 +6,7 @@ package release_test import ( "os" "path/filepath" + "regexp" "slices" "strings" "testing" @@ -47,8 +48,18 @@ type job struct { } type step struct { - Uses string `yaml:"uses"` - Run string `yaml:"run"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + With map[string]string `yaml:"with"` +} + +type push struct { + Tags []string `yaml:"tags"` + TagsIgnore []string `yaml:"tags-ignore"` + Branches []string `yaml:"branches"` + BranchesIgnore []string `yaml:"branches-ignore"` + Paths []string `yaml:"paths"` + PathsIgnore []string `yaml:"paths-ignore"` } type dispatch struct { @@ -58,10 +69,31 @@ type dispatch struct { } `yaml:"inputs"` } -// releaseProblems returns every way a release workflow breaks the rule: it may -// start only from a workflow_dispatch with a required confirm input, its first -// job's first step must refuse unless confirm is release-go on a vX.Y.Z tag, -// and every other job must wait for that job, so a refusal publishes nothing. +// theTag is how every job names the tag being released: the dispatch's tag +// input, or the pushed tag. +const theTag = "${{ inputs.tag || github.ref_name }}" + +// imagePublish matches a step or setting that publishes a container image: +// a registry login or push, a multi-architecture manifest, an attestation +// pushed to a registry, an image action, or the permission to write packages. +// Images are no longer published (owner, 2026-11-13); building and scanning +// them locally is not publishing, so `docker build` and `trivy image` pass. +var imagePublish = regexp.MustCompile(`(?m)` + + `uses:\s*['"]?docker/(login-action|build-push-action|bake-action|metadata-action)\b` + + `|\bdocker\s+(login|push)\b|\bbuildx\s+build\b[^\n]*--push\b|\bimagetools\s+create\b` + + `|\b(crane|oras)\s+push\b|\bskopeo\s+copy\b` + + `|^\s*push-to-registry:\s*true\b|^\s*packages:\s*write\b`) + +// comment strips a YAML comment line, so prose explaining what was removed +// does not count as the thing itself. +var comment = regexp.MustCompile(`(?m)^\s*#.*$`) + +// releaseProblems returns every way a release workflow breaks the rule: it +// starts only from a pushed version tag or a workflow_dispatch with a required +// tag input; its first job's first step refuses anything but a vX.Y.Z tag that +// exists on GitHub, before any checkout; every other job waits for that job, +// so a refusal publishes nothing; every checkout is of the tag, never a +// branch; and nothing publishes a container image. func releaseProblems(t *testing.T, src []byte) []string { t.Helper() var wf workflow @@ -71,52 +103,84 @@ func releaseProblems(t *testing.T, src []byte) []string { var problems []string for trigger := range wf.On { - if trigger != "workflow_dispatch" { - problems = append(problems, "trigger "+trigger+": a release runs on workflow_dispatch only, never by itself") + if trigger != "push" && trigger != "workflow_dispatch" { + problems = append(problems, "trigger "+trigger+": a release runs on a pushed version tag or on request, nothing else") + } + } + if node, ok := wf.On["push"]; !ok { + problems = append(problems, "no push trigger on version tags") + } else { + var p push + if err := node.Decode(&p); err != nil { + t.Fatalf("push does not decode: %v", err) + } + if len(p.Branches)+len(p.BranchesIgnore)+len(p.Paths)+len(p.PathsIgnore)+len(p.TagsIgnore) > 0 { + problems = append(problems, "the push trigger filters branches or paths; it must fire on version tags only") + } + if len(p.Tags) == 0 { + problems = append(problems, "the push trigger has no tag filter, so every branch push would release") + } + for _, tag := range p.Tags { + if !strings.HasPrefix(tag, "v[0-9]+.") { + problems = append(problems, "tag filter "+tag+" is not a version tag") + } } } - node, ok := wf.On["workflow_dispatch"] - if !ok { + if node, ok := wf.On["workflow_dispatch"]; !ok { problems = append(problems, "no workflow_dispatch trigger") } else { var d dispatch if err := node.Decode(&d); err != nil { t.Fatalf("workflow_dispatch does not decode: %v", err) } - if in, ok := d.Inputs["confirm"]; !ok || !in.Required { - problems = append(problems, "workflow_dispatch lacks a required confirm input") + if in, ok := d.Inputs["tag"]; !ok || !in.Required { + problems = append(problems, "workflow_dispatch lacks a required tag input") } } + if !strings.Contains(string(src), theTag) { + problems = append(problems, "nothing resolves the tag as "+theTag) + } + + if m := imagePublish.FindString(comment.ReplaceAllString(string(src), "")); m != "" { + problems = append(problems, "publishes a container image ("+strings.TrimSpace(m)+"); a release publishes the GitHub release only") + } if wf.Jobs.Kind != yaml.MappingNode || len(wf.Jobs.Content) < 2 { return append(problems, "release.yml has no jobs") } jobs := wf.Jobs.Content // key, value, key, value, ... first := jobs[0].Value - var gate job - if err := jobs[1].Decode(&gate); err != nil { - t.Fatalf("job %s does not decode: %v", first, err) - } - if len(gate.Steps) == 0 { - problems = append(problems, "job "+first+" has no steps") - } else { - s := gate.Steps[0] - if s.Uses != "" { - problems = append(problems, "the first step of "+first+" is "+s.Uses+"; the refusal must come before any checkout or build") - } - for _, want := range []string{`"release-go"`, "refs/tags/v[0-9]*", "exit 1"} { - if !strings.Contains(s.Run, want) { - problems = append(problems, "the first step of "+first+" must refuse without confirm=release-go on a vX.Y.Z tag; it lacks "+want) - } - } - } - - for i := 2; i+1 < len(jobs); i += 2 { + checkouts := 0 + for i := 0; i+1 < len(jobs); i += 2 { name := jobs[i].Value var j job if err := jobs[i+1].Decode(&j); err != nil { t.Fatalf("job %s does not decode: %v", name, err) } + for _, s := range j.Steps { + if strings.HasPrefix(s.Uses, "actions/checkout@") { + checkouts++ + if !strings.HasPrefix(s.With["ref"], "refs/tags/") { + problems = append(problems, "job "+name+" checks out "+s.With["ref"]+", not refs/tags/: a dispatch would build its branch") + } + } + } + if i == 0 { + if len(j.Steps) == 0 { + problems = append(problems, "job "+first+" has no steps") + continue + } + s := j.Steps[0] + if s.Uses != "" { + problems = append(problems, "the first step of "+first+" is "+s.Uses+"; the refusal must come before any checkout or build") + } + for _, want := range []string{`^v[0-9]+\.[0-9]+\.[0-9]+`, "git/ref/tags/", "exit 1"} { + if !strings.Contains(s.Run, want) { + problems = append(problems, "the first step of "+first+" must refuse anything but a vX.Y.Z tag on GitHub; it lacks "+want) + } + } + continue + } var needs []string switch j.Needs.Kind { case yaml.ScalarNode: @@ -130,15 +194,17 @@ func releaseProblems(t *testing.T, src []byte) []string { problems = append(problems, "job "+name+" does not need "+first+", so it would run past the refusal") } } + if checkouts == 0 { + problems = append(problems, "no job checks out the tag") + } return problems } -// Pushing a tag publishes nothing. release.yml publishes dbpantry's images and -// GitHub release only when dispatched on a vX.Y.Z tag with -// -f confirm=release-go, and its first step refuses anything else. A tag push -// that started a release nobody wanted yet is how this rule began (2026-11-02, -// in another repository). -func TestReleaseRunsOnlyOnAConfirmedDispatch(t *testing.T) { +// A release runs on a pushed vX.Y.Z tag, or on request for a tag already on +// GitHub (gh workflow run release.yml -f tag=vX.Y.Z), and on nothing else; it +// publishes the GitHub release and no container image (owner, 2026-11-13, +// replacing design.md D-76's confirmed dispatch). +func TestReleaseRunsOnAVersionTagOrOnRequest(t *testing.T) { src, err := os.ReadFile(filepath.Join(repoRoot(t), ".github", "workflows", "release.yml")) // #nosec G304 -- a fixed file of this repository if err != nil { t.Fatal(err) @@ -149,41 +215,70 @@ func TestReleaseRunsOnlyOnAConfirmedDispatch(t *testing.T) { } // The policy has to be able to fail: each of these workflows breaks it. -func TestReleasePolicyRejectsAnUnconfirmedRelease(t *testing.T) { +func TestReleasePolicyRejectsAWrongRelease(t *testing.T) { + const triggers = ` +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + workflow_dispatch: + inputs: + tag: + required: true +` const gate = ` jobs: prepare: + env: + TAG: ${{ inputs.tag || github.ref_name }} steps: - run: | - [ "$CONFIRM" = "release-go" ] || exit 1 - case "$GITHUB_REF" in refs/tags/v[0-9]*) ;; *) exit 1 ;; esac + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1 + gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent || exit 1 - uses: actions/checkout@v7 - publish: + with: + ref: refs/tags/${{ inputs.tag || github.ref_name }} + release: needs: [prepare] + permissions: + contents: write steps: - - run: docker push -` - const dispatch = ` -on: - workflow_dispatch: - inputs: - confirm: - required: true + - uses: actions/checkout@v7 + with: + ref: refs/tags/${{ needs.prepare.outputs.tag }} + - run: gh release create "$TAG" ` - if p := releaseProblems(t, []byte(dispatch+gate)); len(p) != 0 { + if p := releaseProblems(t, []byte(triggers+gate)); len(p) != 0 { t.Fatalf("a conforming workflow was rejected: %v", p) } + withStep := func(s string) string { + return triggers + gate + " - " + s + "\n" + } cases := map[string]string{ - "a tag push": "on:\n push:\n tags: ['v*']\n" + gate, - "a tag push as well": strings.Replace(dispatch, "on:\n", "on:\n push:\n tags: ['v*']\n", 1) + gate, - "a schedule": strings.Replace(dispatch, "on:\n", "on:\n schedule:\n - cron: '0 0 * * 1'\n", 1) + gate, - "a pull request": strings.Replace(dispatch, "on:\n", "on:\n pull_request:\n", 1) + gate, - "a release event": strings.Replace(dispatch, "on:\n", "on:\n release:\n types: [published]\n", 1) + gate, - "no confirm input": "on:\n workflow_dispatch:\n" + gate, - "an optional confirm": strings.Replace(dispatch, "required: true", "required: false", 1) + gate, - "checkout first": dispatch + strings.Replace(gate, " steps:\n - run: |", " steps:\n - uses: actions/checkout@v7\n - run: |", 1), - "no tag check": dispatch + strings.Replace(gate, "refs/tags/v[0-9]*", "refs/heads/*", 1), - "a job that skips the gate": dispatch + strings.Replace(gate, " needs: [prepare]\n", "", 1), + "a branch push": strings.Replace(triggers, " tags:\n", " branches: [main]\n tags:\n", 1) + gate, + "a push with no tag filter": strings.Replace(triggers, " tags:\n - 'v[0-9]+.[0-9]+.[0-9]+'\n", "", 1) + gate, + "a non-version tag": strings.Replace(triggers, "'v[0-9]+.[0-9]+.[0-9]+'", "'*'", 1) + gate, + "no push trigger": strings.Replace(triggers, " push:\n tags:\n - 'v[0-9]+.[0-9]+.[0-9]+'\n", "", 1) + gate, + "a schedule": strings.Replace(triggers, "on:\n", "on:\n schedule:\n - cron: '0 0 * * 1'\n", 1) + gate, + "a pull request": strings.Replace(triggers, "on:\n", "on:\n pull_request:\n", 1) + gate, + "a release event": strings.Replace(triggers, "on:\n", "on:\n release:\n types: [published]\n", 1) + gate, + "no tag input": strings.Replace(triggers, " inputs:\n tag:\n required: true\n", "", 1) + gate, + "an optional tag": strings.Replace(triggers, "required: true", "required: false", 1) + gate, + "the retired confirm": strings.Replace(triggers, " tag:\n", " confirm:\n", 1) + gate, + "checkout first": triggers + strings.Replace(gate, " steps:\n - run: |", " steps:\n - uses: actions/checkout@v7\n with:\n ref: refs/tags/v1.0.0\n - run: |", 1), + "no existence check": triggers + strings.Replace(gate, "git/ref/tags/", "git/ref/heads/", 1), + "no version check": triggers + strings.Replace(gate, `^v[0-9]+\.[0-9]+\.[0-9]+`, `^.+`, 1), + "a branch checkout": triggers + strings.Replace(gate, "ref: refs/tags/${{ needs.prepare.outputs.tag }}", "ref: main", 1), + "a default checkout": triggers + strings.Replace(gate, " with:\n ref: refs/tags/${{ needs.prepare.outputs.tag }}\n", "", 1), + "a job that skips the gate": triggers + strings.Replace(gate, " needs: [prepare]\n", "", 1), + "docker/login-action": withStep("uses: docker/login-action@v3"), + "docker/build-push-action": withStep("uses: docker/build-push-action@v6"), + "a docker login": withStep(`run: printf '%s' "$T" | docker login ghcr.io -u x --password-stdin`), + "a ghcr.io push": withStep("run: docker push ghcr.io/simtabi/dbpantry-pg:17-0.1.1"), + "a buildx --push": withStep("run: docker buildx build -t ghcr.io/simtabi/dbpantry-pg:17 --push ."), + "a manifest": withStep("run: docker buildx imagetools create -t ghcr.io/simtabi/dbpantry-pg:17 a b"), + "an attestation pushed": withStep("uses: actions/attest@v4\n with:\n push-to-registry: true"), + "packages: write": strings.Replace(triggers+gate, " contents: write\n", " contents: write\n packages: write\n", 1), } for name, wf := range cases { t.Run(name, func(t *testing.T) { @@ -192,4 +287,10 @@ on: } }) } + + // Building and scanning an image locally is not publishing it. + local := withStep(`run: scripts/build pg && trivy image "ghcr.io/simtabi/dbpantry-pg:17"`) + if p := releaseProblems(t, []byte(local)); len(p) != 0 { + t.Fatalf("a local build and scan was rejected: %v", p) + } }