From a094ffbf6fd163a708845d65ae3f6ede9986fe3c Mon Sep 17 00:00:00 2001 From: yoshi49535 Date: Mon, 27 Jul 2026 14:31:52 +0900 Subject: [PATCH] release hardening: tested-is-shipped, pinned inputs, build-once, honest identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow slice of the release-engineering track (dplaax.spec release.* rules). Everything here is repo-local; nothing changes the published images until the next deliberate version cut. **Node 24 everywhere** (release.toolchain.tested-is-shipped): CI tested on 22 while every image stage ships node:24-alpine, which narrowed each green check to "green somewhere else". CI and release.yml now test 24, and the root package.json declares engines >=24 <25. **corepack@0.35.0** in the generated-instance Dockerfile and both scaffold templates: `npm install -g corepack --force` floated to whatever npm served that day — an unpinned build input the spec calls out by name. **release.yml explicitly disabled** (if: false): nothing is on npm and the preconditions for publishing (trusted publishing, per-ecosystem provenance) are not built. Until now it was only accidentally inert — its tag pattern never matched and the NPM_TOKEN secret does not exist. Neither accident is a gate; the if: line is. Re-enabling means building trusted publishing, not restoring a token. **build-once guard** (release.build.once): publishing a version tag now fails if that tag already resolves in GHCR — the scanned bytes and a rebuild's bytes are different artifacts sharing a name, and the exit is a new version. Plus a concurrency group so two runs on one ref cannot interleave a multi-arch push. **sha- is not "immutable"** (release.docs.match-artifacts): the header called the sha tag immutable and the quickstart called it exactly reproducible. It names the source commit but is still a registry tag — this workflow itself would move it if re-run on the same commit. The docs now say what is true: the digest is the identity, tags are locators. Each run prints its digests to the job summary (release.subject.exact-digest, the cheap half). **sbom: true**: per-platform final-stage SPDX attestations. Deliberately described as inventory, not completeness — binding evidence to the index digest is the release-evidence manifest's job and stays on the P0-7 track, alongside base-image digest pins (consciously deferred: digest-pinning bases without a scan/refresh loop freezes CVE fixes, a net loss at this maturity). --- .github/workflows/ci.yml | 6 +- .github/workflows/publish-images.yml | 72 +++++++++++++++++-- .github/workflows/release.yml | 12 +++- deploy/generated-instance.Dockerfile | 4 +- package.json | 3 + .../src/template/Dockerfile | 2 +- .../create-provider/src/template/Dockerfile | 2 +- 7 files changed, 87 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e653c4c..ce8b4ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "22" + node-version: "24" cache: "pnpm" - run: pnpm install --frozen-lockfile @@ -52,7 +52,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "22" + node-version: "24" cache: "pnpm" - run: pnpm install --frozen-lockfile @@ -94,7 +94,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "22" + node-version: "24" cache: "pnpm" # Workspace install only to build the generator CLIs. diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 3674401..74cf276 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -6,11 +6,17 @@ name: publish-images # themselves (gap-backlog L1: "provin.auth GHCR image publish CI"). # # Triggers: manual (workflow_dispatch, builds the selected ref) and version -# tags. Every build pushes an immutable sha- tag plus a moving tag named -# after the branch/tag that triggered it. A version-tag build (v1.2.3) also -# pushes a moving minor tag (v1.2) — the provin.oss quickstart pins that so it -# tracks patch releases without a pin bump; pin the sha- tag instead for -# exact reproducibility. +# tags. Every build pushes a sha- tag plus a moving tag named after the +# branch/tag that triggered it. A version-tag build (v1.2.3) also pushes a +# moving minor tag (v1.2) — the provin.oss quickstart pins that so it tracks +# patch releases without a pin bump. +# +# On identity (release.subject.exact-digest / release.docs.match-artifacts): +# a sha- tag names the SOURCE commit, but it is still a registry tag — a +# mutable pointer that this workflow itself would move if re-run on the same +# commit. The only immutable reference to an image is its digest +# (name@sha256:…), which each run prints to the job summary. Use the sha tag +# as a convenient locator; cite the digest when the claim is identity. on: workflow_dispatch: @@ -21,6 +27,12 @@ permissions: contents: read packages: write +# One publish per ref at a time, never cancelled mid-push: two concurrent runs +# on the same tag could interleave their multi-arch pushes into one manifest. +concurrency: + group: publish-images-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest @@ -64,6 +76,31 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # A released tag is built once (release.build.once): if the version tag + # already resolves in the registry, the bytes that were (or will be) + # scanned and the bytes a rebuild would publish are different artifacts + # sharing a name. Fail instead — the exit is a new version, not a + # rebuild. Guarded to version tags only: workflow_dispatch re-publishing + # the moving develop/sha tags is that trigger's whole purpose. + - name: refuse to rebuild an already-published version tag + if: startsWith(github.ref, 'refs/tags/') + env: + # Via env, not inline: a git refname may contain characters the + # shell would expand. + IMAGE: auth-${{ matrix.generator }} + TAG: ${{ github.ref_name }} + run: | + token=$(curl -fsS "https://ghcr.io/token?service=ghcr.io&scope=repository:provin-line/${IMAGE}:pull" | jq -r .token) + code=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $token" \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ + "https://ghcr.io/v2/provin-line/${IMAGE}/manifests/${TAG}") + if [ "$code" = "200" ]; then + echo "::error::${IMAGE}:${TAG} is already published — a released tag is built once. Cut a new version instead of rebuilding this one." + exit 1 + fi + echo "${IMAGE}:${TAG} not yet published (HTTP $code) — proceeding." + - id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: @@ -74,12 +111,20 @@ jobs: type=ref,event=branch type=ref,event=tag - - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + - id: build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: deploy/generated-instance.Dockerfile push: true platforms: linux/amd64,linux/arm64 + # Per-platform final-stage SBOM attestations (SPDX, generated by + # buildx's scanner). Each attestation's subject is the PLATFORM + # manifest, not the top-level index — binding evidence to the index + # digest is the release-evidence manifest's job and stays on the + # P0-7 track. This is inventory, not a completeness claim + # (release.sbom.subject-bound). + sbom: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | @@ -89,3 +134,18 @@ jobs: SCAFFOLD_ARGS=${{ matrix.scaffold_args }} secrets: | github_token=${{ secrets.GITHUB_TOKEN }} + + # The digest is the release identity; tags are locators. Put it where a + # human lands first when they open the run. + - name: record the release identity + env: + IMAGE: auth-${{ matrix.generator }} + DIGEST: ${{ steps.build.outputs.digest }} + run: | + { + echo "### ghcr.io/provin-line/${IMAGE}" + echo "" + echo '```' + echo "${DIGEST}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2acc23f..943ce74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,16 @@ on: jobs: publish: + # Disabled until npm publishing is actually a project decision. Nothing is + # on npm today (packages are consumed as git subdirectory refs), and the + # preconditions the spec sets for it — trusted publishing instead of a + # long-lived NPM_TOKEN, and an npm-specific provenance contract + # (release.provenance.per-ecosystem) — are not built. Until then this job + # was only *accidentally* inert: its tag pattern never matched a v* tag + # and the NPM_TOKEN secret does not exist. Neither of those is a gate; + # this line is. Re-enabling means setting up trusted publishing first, + # not restoring a token. + if: false runs-on: ubuntu-latest permissions: contents: read @@ -18,7 +28,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: "22" + node-version: "24" registry-url: "https://registry.npmjs.org" cache: "pnpm" diff --git a/deploy/generated-instance.Dockerfile b/deploy/generated-instance.Dockerfile index c0f3aa6..f534863 100644 --- a/deploy/generated-instance.Dockerfile +++ b/deploy/generated-instance.Dockerfile @@ -34,7 +34,7 @@ ARG SCAFFOLD_ARGS="" # --- gen: clone provin.auth, build the generator, scaffold the instance --- FROM node:24-alpine AS gen -RUN apk add --no-cache git && npm install -g corepack --force && corepack enable +RUN apk add --no-cache git && npm install -g corepack@0.35.0 --force && corepack enable WORKDIR /src ARG GENERATOR ARG AUTH_REF @@ -58,7 +58,7 @@ RUN --mount=type=secret,id=github_token \ # --- builder: install the instance's deps (git-subdir refs) and compile --- FROM node:24-alpine AS builder -RUN apk add --no-cache git && npm install -g corepack --force && corepack enable +RUN apk add --no-cache git && npm install -g corepack@0.35.0 --force && corepack enable WORKDIR /app COPY --from=gen /instance/ ./ RUN --mount=type=secret,id=github_token \ diff --git a/package.json b/package.json index 9863505..966d7fc 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "@provin-line/dplaax.auth", "private": true, "type": "module", + "engines": { + "node": ">=24 <25" + }, "packageManager": "pnpm@10.30.2+sha512.36cdc707e7b7940a988c9c1ecf88d084f8514b5c3f085f53a2e244c2921d3b2545bc20dd4ebe1fc245feec463bb298aecea7a63ed1f7680b877dc6379d8d0cb4", "scripts": { "build": "pnpm -r run build", diff --git a/packages/create-policy-verifier/src/template/Dockerfile b/packages/create-policy-verifier/src/template/Dockerfile index fefd7b4..26a3796 100644 --- a/packages/create-policy-verifier/src/template/Dockerfile +++ b/packages/create-policy-verifier/src/template/Dockerfile @@ -18,7 +18,7 @@ FROM node:24-alpine AS node-base ENV HOME=/home/node RUN apk add --no-cache tini git \ - && npm install -g corepack --force \ + && npm install -g corepack@0.35.0 --force \ && corepack enable WORKDIR /home/node/app diff --git a/packages/create-provider/src/template/Dockerfile b/packages/create-provider/src/template/Dockerfile index f7e1aa2..73b71e5 100644 --- a/packages/create-provider/src/template/Dockerfile +++ b/packages/create-provider/src/template/Dockerfile @@ -18,7 +18,7 @@ FROM node:24-alpine AS node-base ENV HOME=/home/node RUN apk add --no-cache tini git \ - && npm install -g corepack --force \ + && npm install -g corepack@0.35.0 --force \ && corepack enable WORKDIR /home/node/app