From ac9246cfd31a8b2e828283b04ce56806c6f94168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 23 Apr 2026 18:59:26 +0100 Subject: [PATCH] ci(docker): mirror signed images to Docker Hub alongside GHCR (#1029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(docker): mirror signed images to Docker Hub alongside GHCR docker.yml now publishes to docker.io/abhigyanpatwari/gitnexus{,-web} in the same build step as the existing GHCR push, so both registries receive the same digest, the same Cosign keyless signature, and the same SBOM / build-provenance attestations. The Docker Hub login uses new repo secrets DOCKERHUB_USERNAME / DOCKERHUB_TOKEN (scoped PAT, not account password). Supply-chain guarantees carry over unchanged: the signing loop iterates metadata-action's full tag set, so Docker Hub tags get signed at the identical digest under the same docker.yml@refs/tags/v* identity. The ClusterImagePolicy is extended with docker.io / index.docker.io / bare- namespace globs so admission cannot be sidestepped by registry-prefix choice. README and .env.example document both registries; RC section in CONTRIBUTING.md notes the Docker Hub mirror tag. Closes #1027 * ci(docker): publish to akonlabs Docker Hub namespace; add PR dry-run CI - Hardcode `akonlabs` as the Docker Hub namespace in metadata-action and both attestation subject-names (Docker Hub org differs from GitHub org `abhigyanpatwari`, so `github.repository_owner` would produce the wrong ref) - Update docs (.env.example, README, CONTRIBUTING) and the Kubernetes ClusterImagePolicy globs to reference `akonlabs/gitnexus{,-web}` - Add `pull_request` trigger so the image build runs as CI on every PR (build only — no push, sign, or attestation) - Add `workflow_dispatch` with `dry_run: boolean` (default true) for manual build-only runs; all publish steps gated on `github.event_name != 'pull_request' && !inputs.dry_run` --- .env.example | 6 +- .github/workflows/docker.yml | 64 ++++++++++++++++++--- CONTRIBUTING.md | 7 ++- README.md | 26 ++++++--- deploy/kubernetes/cluster-image-policy.yaml | 20 +++++-- 5 files changed, 99 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index ec967c79e..8af9dee79 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ -# Images (signed Cosign keyless on every push from main / vX.Y.Z tags) +# Images (signed Cosign keyless on every push from main / vX.Y.Z tags). +# Available from both GHCR (default below) and Docker Hub — pick one: +# GHCR: ghcr.io/abhigyanpatwari/gitnexus{,-web}:latest +# Docker Hub: akonlabs/gitnexus{,-web}:latest +# Both registries receive the same digest from a single signed build. SERVER_IMAGE=ghcr.io/abhigyanpatwari/gitnexus:latest WEB_IMAGE=ghcr.io/abhigyanpatwari/gitnexus-web:latest diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7cd8b678c..a4a268222 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,9 +4,18 @@ on: push: tags: - 'v*' - # No workflow_dispatch: publishing is exclusively tag-driven so that every - # signed image corresponds 1:1 to a published `gitnexus@X.Y.Z` on npm. A - # manual run from a branch ref would fail the version check below anyway. + pull_request: + # workflow_dispatch is allowed for dry-run testing only. Publishing is still + # exclusively tag-driven so that every signed image corresponds 1:1 to a + # published `gitnexus@X.Y.Z` on npm. dry_run:true (the default) skips all + # push, sign, and attestation steps — the build runs but nothing is published. + workflow_dispatch: + inputs: + dry_run: + description: 'Build only — skip push, signing, and attestations' + required: false + default: true + type: boolean workflow_call: inputs: tag: @@ -61,7 +70,7 @@ jobs: steps: - name: Validate tag input - if: github.event_name == 'workflow_call' + if: github.event_name == 'workflow_call' || github.event_name == 'push' shell: bash env: TAG_INPUT: ${{ inputs.tag }} @@ -85,6 +94,7 @@ jobs: # `gitnexus@X.Y.Z` published to npm — no drift, no surprises. - name: Verify tag matches gitnexus/package.json version id: version + if: github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request' shell: bash env: # For workflow_call the tag comes from the caller input; for push events @@ -119,12 +129,27 @@ jobs: uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - name: Log in to GitHub Container Registry + if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # Docker Hub is a mirror of GHCR: same tags, same digests, same Cosign + # signatures. GHCR remains authoritative (it is the registry the + # ClusterImagePolicy globs against by default), but Docker Hub is the + # registry most users reach for first, so we publish there too. + # Requires repo secrets DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (a scoped + # access token, NOT the account password) with write access to the + # `akonlabs/gitnexus` and `akonlabs/gitnexus-web` repos. + - name: Log in to Docker Hub + if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + # Computes image tags and labels from the verified semver tag: # v1.2.3 → :1.2.3, :1.2, :1, :latest (auto, only for non-prerelease) # v1.2.3-rc.1 → :1.2.3-rc.1 only (prereleases never become :latest) @@ -142,7 +167,16 @@ jobs: id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: - images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }} + # Dual-registry publish. metadata-action expands the same tag set + # against every image ref listed here, and build-push-action pushes + # one build to all of them, so the GHCR and Docker Hub images share + # a digest and are byte-identical. The Docker Hub namespace + # (`akonlabs`) is hardcoded because it differs from the GitHub org + # (`abhigyanpatwari`) — `github.repository_owner` would produce the + # wrong ref. + images: | + ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }} + docker.io/akonlabs/${{ matrix.image.slug }} flavor: latest=auto tags: | type=semver,pattern={{version}} @@ -157,7 +191,7 @@ jobs: context: . file: ${{ matrix.image.dockerfile }} platforms: linux/amd64,linux/arm64 - push: true + push: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha,scope=${{ matrix.image.slug }} @@ -178,6 +212,7 @@ jobs: # Do NOT relax to `@.*` — that accepts signatures from any ref, including # unprotected branches and PRs, and defeats the supply-chain guarantee. - name: Sign image with Cosign (keyless) + if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} env: # Cosign v2 (installed by sigstore/cosign-installer above) makes # keyless the default. COSIGN_EXPERIMENTAL is a v1-only opt-in flag @@ -193,10 +228,23 @@ jobs: [[ -n "$tag" ]] && cosign sign --yes "${tag}@${DIGEST}" done <<< "$TAGS" - # Attach the SBOM produced by buildx as a verifiable attestation on the digest. - - name: Generate build provenance attestation + # Attach the SBOM produced by buildx as a verifiable attestation on the + # digest. Attestations are pushed as OCI referrers to the registry named + # in `subject-name`, so we call the action once per registry. The digest + # is identical across registries (same build, same push), so consumers + # pulling from either GHCR or Docker Hub see the same provenance. + - name: Generate build provenance attestation (GHCR) + if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true + + - name: Generate build provenance attestation (Docker Hub) + if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: docker.io/akonlabs/${{ matrix.image.slug }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f797f9a0..0ed2aeba5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,9 +129,10 @@ Two publish workflows ship `gitnexus` to npm: registry. First rc for a given base is `rc.1`. - After the npm publish succeeds, the workflow calls `docker.yml` as a reusable workflow to build and push the corresponding RC Docker images - (e.g. `ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1`). The images are - signed with Cosign; the OIDC identity is `docker.yml@refs/heads/main` - (the caller's ref — see README.md § Docker for the verify command). + (e.g. `ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1`, mirrored to + `docker.io/akonlabs/gitnexus:1.7.0-rc.1`). The images are signed + with Cosign; the OIDC identity is `docker.yml@refs/heads/main` (the + caller's ref — see README.md § Docker for the verify command). Idempotency: the workflow pushes an `rc/` marker tag and a `v` release tag **atomically, before** calling `npm publish`. The guard diff --git a/README.md b/README.md index 5260e8388..a31a707dd 100644 --- a/README.md +++ b/README.md @@ -338,12 +338,12 @@ npm run dev ## Docker -The official Docker setup ships **two signed images** orchestrated by `docker-compose.yaml`: +The official Docker setup ships **two signed images** orchestrated by `docker-compose.yaml`. Each image is published to both **GitHub Container Registry** (GHCR) and **Docker Hub** — same build, same digest, same Cosign signature — so pick whichever registry you prefer: -| Image | Purpose | -| -------------------------------------------------- | ---------------------------------------------------------------------- | -| `ghcr.io/abhigyanpatwari/gitnexus:latest` | CLI / `gitnexus serve` backend (HTTP API on port `4747`, MCP, indexer) | -| `ghcr.io/abhigyanpatwari/gitnexus-web:latest` | Static web UI (port `4173`) | +| Purpose | GHCR (default in `docker-compose.yaml`) | Docker Hub mirror | +| ---------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------- | +| CLI / `gitnexus serve` backend (HTTP API on port `4747`, MCP, indexer) | `ghcr.io/abhigyanpatwari/gitnexus:latest` | `akonlabs/gitnexus:latest` | +| Static web UI (port `4173`) | `ghcr.io/abhigyanpatwari/gitnexus-web:latest` | `akonlabs/gitnexus-web:latest` | > **Heads-up — image rename.** Earlier releases published the web UI under > `ghcr.io/abhigyanpatwari/gitnexus`. Starting with the introduction of the @@ -404,8 +404,11 @@ The Docker images are version-locked to the npm package: - Stable images are **only published from `vX.Y.Z` git tags** (via `docker.yml` triggered directly by the tag push), and the workflow refuses to build unless the tag exactly matches `gitnexus/package.json`'s version. So - `ghcr.io/abhigyanpatwari/gitnexus:1.6.2` is byte-for-byte the same release - as `npm install gitnexus@1.6.2` — no drift, no floating builds from `main`. + `ghcr.io/abhigyanpatwari/gitnexus:1.6.2` (and its Docker Hub mirror + `akonlabs/gitnexus:1.6.2`) is byte-for-byte the same release as + `npm install gitnexus@1.6.2` — no drift, no floating builds from `main`. + Both registries receive the same digest from a single build step, so you can + pull from either and the signature verifies identically. - Release-candidate images (e.g. `:1.7.0-rc.1`) are published alongside each RC npm release. They are built by `release-candidate.yml` calling `docker.yml` as a reusable workflow after the RC tag is created and pushed. @@ -426,11 +429,18 @@ sensitive environments: cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \ --certificate-identity-regexp '^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com + +# Same signature verifies the Docker Hub mirror (identical digest): +cosign verify docker.io/akonlabs/gitnexus:1.6.2 \ + --certificate-identity-regexp '^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com ``` The regex pins the certificate identity to this repo's `docker.yml` workflow **run from a `v*` tag** — rejecting unsigned images, images signed by other -workflows, and images signed from unprotected refs. +workflows, and images signed from unprotected refs. It is identical for both +registries because both sets of tags were signed at the same digest in one +workflow run. **Release candidates** — signed from `refs/heads/main` (the caller's ref when `release-candidate.yml` invokes `docker.yml` as a reusable workflow): diff --git a/deploy/kubernetes/cluster-image-policy.yaml b/deploy/kubernetes/cluster-image-policy.yaml index 9287a3c45..f04ed0fc7 100644 --- a/deploy/kubernetes/cluster-image-policy.yaml +++ b/deploy/kubernetes/cluster-image-policy.yaml @@ -36,12 +36,24 @@ kind: ClusterImagePolicy metadata: name: gitnexus-signed-images spec: - # Apply to both published GitNexus images on GHCR. Image references always - # carry a tag or digest at admission time, so these two globs cover every - # `gitnexus:`, `gitnexus@sha256:...`, `gitnexus-web:`, and - # `gitnexus-web@sha256:...` reference. + # Apply to both published GitNexus images on both registries. Image + # references always carry a tag or digest at admission time, so these globs + # cover every `gitnexus:`, `gitnexus@sha256:...`, `gitnexus-web:`, + # and `gitnexus-web@sha256:...` reference on either GHCR or Docker Hub. + # The Docker Hub images are byte-for-byte mirrors of the GHCR images (same + # build, same digest, same Cosign signature), so the same keyless identity + # authority verifies both. images: - glob: 'ghcr.io/abhigyanpatwari/gitnexus*' + # Docker Hub references can appear in three forms at admission time + # (`docker.io/...`, `index.docker.io/...`, and bare `akonlabs/...` with + # the default registry implied). List all three so the policy cannot be + # sidestepped by the choice of registry prefix. The Docker Hub namespace + # is `akonlabs` rather than `abhigyanpatwari` because the Docker Hub org + # differs from the GitHub org. + - glob: 'docker.io/akonlabs/gitnexus*' + - glob: 'index.docker.io/akonlabs/gitnexus*' + - glob: 'akonlabs/gitnexus*' authorities: - name: gitnexus-cosign-keyless keyless: