name: Docker Build & Push on: push: tags: - 'v*' 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: description: >- The full v-prefixed tag to build (e.g. v1.2.3-rc.1). The tag must already exist in the repo and its tree must contain a gitnexus/package.json whose version matches the tag. required: true type: string # Explicit secret contract — callers pass these by name. Replaces the # blanket `secrets: inherit` pattern (zizmor `secrets-inherit` audit). # GHCR auth uses the implicit GITHUB_TOKEN; only Docker Hub credentials # need to be passed through. secrets: DOCKERHUB_USERNAME: required: true DOCKERHUB_TOKEN: required: true permissions: contents: read # Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". # Tag refs are unique per release, so distinct tags run in parallel. # Re-pushes of the same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight. # Hardcoded `docker-build-push-` prefix (not `${{ github.workflow }}`) when invoked as a reusable # workflow: in called-workflow context `github.workflow` is ambiguous and could resolve to the # caller's name, sharing a concurrency group with the caller → deadlock. # Direct tag-push invocations use `docker-build-push-`; workflow_call invocations get a # per-run-unique group (they are already serialized by the caller's own concurrency group). concurrency: group: ${{ (github.event_name == 'push') && format('docker-build-push-{0}', github.ref) || format('docker-build-push-nested-{0}', github.run_id) }} cancel-in-progress: false jobs: build-push: name: Build & Push ${{ matrix.image.name }} runs-on: ubuntu-latest timeout-minutes: 60 permissions: contents: read packages: write # Required for Cosign keyless signing via the OIDC token exchange, # and for build provenance / SBOM attestations. id-token: write attestations: write strategy: fail-fast: false matrix: image: # Static UI bundle. Small, fast image. Drop-in replacement for the # legacy single-image setup at the same `gitnexus` repository slug # is intentionally avoided — the UI now lives at `gitnexus-web` and # the CLI/server takes the canonical `gitnexus` slug below. - name: gitnexus-web dockerfile: Dockerfile.web slug: gitnexus-web # CLI / `gitnexus serve` backend. Heavy native deps (tree-sitter, # onnxruntime-node) live only in this image. - name: gitnexus dockerfile: Dockerfile.cli slug: gitnexus steps: # Only the workflow_call path requires a non-empty `inputs.tag` — callers # (publish.yml in RC mode) must pass the RC tag explicitly. On direct # tag pushes the tag comes from `github.ref`, so `inputs.tag` is always # empty and validating it here would break every real release (#1064). # The downstream "Verify tag matches gitnexus/package.json version" step # handles both event types by falling back to GITHUB_REF. - name: Validate tag input if: github.event_name == 'workflow_call' shell: bash env: TAG_INPUT: ${{ inputs.tag }} run: | if [ -z "${TAG_INPUT}" ]; then echo "::error::No tag provided to docker.yml — refusing to build/push." exit 1 fi # When triggered by workflow_call the caller passes the RC tag as an input; # we check out that tag so the Dockerfile and package.json match the built image. # For tag-push events github.ref is already the tag ref — no override needed. - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.tag || github.ref }} # ── Lock the docker image version to the npm package version ────────── # Mirrors the check in publish.yml: refuse to build unless the git tag # exactly matches `gitnexus/package.json`'s version. This guarantees # `ghcr.io//gitnexus:X.Y.Z` always corresponds to the same # `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 # it is derived from GITHUB_REF (set to empty so the else-branch fires). INPUT_TAG: ${{ inputs.tag }} run: | if [ -n "$INPUT_TAG" ]; then TAG_VERSION="${INPUT_TAG#v}" else TAG_VERSION="${GITHUB_REF#refs/tags/v}" fi if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then echo "::error::Tag does not follow semver: v$TAG_VERSION" exit 1 fi PKG_VERSION=$(node -p "require('./gitnexus/package.json').version") if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then echo "::error::Tag version (v$TAG_VERSION) does not match gitnexus/package.json version ($PKG_VERSION)" exit 1 fi echo "version=$PKG_VERSION" >> "$GITHUB_OUTPUT" echo "Version verified: $PKG_VERSION" # Required for multi-platform (linux/arm64) emulation. - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Install Cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Log in to GitHub Container Registry if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.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) # `:latest` is only emitted for tag pushes thanks to `flavor: latest=auto`, # ensuring it always points at a real npm-published version. # # For workflow_call invocations github.ref is the caller's branch ref, so # the type=semver patterns would not match. In that case we add an explicit # type=raw tag using the version already verified above, so the same # image-naming rules apply regardless of how the workflow was triggered. # NOTE: We check `inputs.tag` rather than `github.event_name` because in a # reusable workflow the github context is inherited from the caller — # `github.event_name` would still be "push", not "workflow_call". - name: Extract Docker metadata id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: # 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}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag != '' }} # Transient 502s from GHCR / Docker Hub / GHA cache during multi-platform # exports are retried inside `.github/actions/docker-build-push-retry` # (see docker/build-push-action#1422 — retry policy stays out of the # upstream action). `ignore-error=true` on cache-to avoids cache export # flakes failing an otherwise successful push. - name: Build and push id: build uses: ./.github/actions/docker-build-push-retry with: context: . file: ${{ matrix.image.dockerfile }} platforms: linux/amd64,linux/arm64 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 }} cache-to: type=gha,mode=max,scope=${{ matrix.image.slug }},ignore-error=true # Cosign keyless signing. Each pushed tag is signed by the workflow's # OIDC identity, so consumers can verify the image with the strict, # fully-anchored identity regex (kept in sync with README.md and # deploy/kubernetes/cluster-image-policy.yaml — update all three together). # NOTE: `${...}` expression syntax is NOT evaluated inside YAML comments, so # the example below uses literal `/` placeholders that consumers # substitute themselves; the canonical, fully-rendered command lives in README.md. # cosign verify ghcr.io//: \ # --certificate-identity-regexp '^https://github\.com///\.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 # 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 # that is now deprecated/no-op, so it is intentionally omitted. DIGEST: ${{ steps.build.outputs.digest }} TAGS: ${{ steps.meta.outputs.tags }} run: | # Sign every tag at the same digest so consumers can verify by tag or by digest. # Use `while read` instead of `for $TAGS` to be robust against tags that # could ever contain whitespace (the metadata-action output is newline- # separated, not space-separated). while IFS= read -r tag; do [[ -n "$tag" ]] && cosign sign --yes "${tag}@${DIGEST}" done <<< "$TAGS" # 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@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 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@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-name: docker.io/akonlabs/${{ matrix.image.slug }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true