From fa39a4b4a5e175ef4a125bbc13e01fca899355bf Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 07:46:21 +0100 Subject: [PATCH] fix(docker): build and push Docker images for Release Candidates (#978) --- .github/scripts/check-workflow-concurrency.py | 20 ++++++---- .github/workflows/docker.yml | 37 ++++++++++++++++++- .github/workflows/release-candidate.yml | 22 +++++++++++ CONTRIBUTING.md | 28 +++++++++++++- README.md | 24 +++++++++--- 5 files changed, 116 insertions(+), 15 deletions(-) diff --git a/.github/scripts/check-workflow-concurrency.py b/.github/scripts/check-workflow-concurrency.py index 300cc7f4b..0d7a49291 100644 --- a/.github/scripts/check-workflow-concurrency.py +++ b/.github/scripts/check-workflow-concurrency.py @@ -11,10 +11,14 @@ Rules: `concurrency:` block. 2. Reusable workflows (on: workflow_call ONLY) do NOT declare one. 3. The `concurrency.group` expression MUST reference either - `${{ github.workflow }}` or a literal `CI-` prefix (the documented - ci.yml reusable-workflow-safe exception). This is checked by substring - containment rather than prefix match because ci.yml's group is a - conditional expression that resolves to a `CI-…` literal at runtime. + `${{ github.workflow }}` or one of the approved hardcoded literal prefixes + for workflows that are simultaneously entry-points AND reusable (on: push/ + workflow_call). Two such exceptions are currently approved: + - `CI-` for ci.yml (the original canonical form) + - `docker-build-push-` for docker.yml + This is checked by substring containment rather than prefix match because + the group value is a conditional expression that resolves to a `CI-…` or + `docker-build-push-…` literal at runtime. We deliberately do not use a YAML library — keeps the script dependency-free on any vanilla runner. `on:` block parsing is line-based and handles both the @@ -28,7 +32,7 @@ import re import sys -REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-") +REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-", "docker-build-push-") def is_reusable(lines: list[str]) -> bool: @@ -150,8 +154,10 @@ def check(workflows_dir: pathlib.Path) -> int: if not any(token in group for token in REQUIRED_TOKENS): print( f"::error file={path}::concurrency.group `{group}` must " - f"reference one of {REQUIRED_TOKENS}. See CONTRIBUTING.md -> " - "GitHub Actions — Concurrency Convention." + f"reference one of {REQUIRED_TOKENS} (use ${{{{ github.workflow }}}} " + "for normal entry-point workflows; use an approved literal prefix " + "only for workflows that are both entry-points AND reusable — " + "see CONTRIBUTING.md -> GitHub Actions — Concurrency Convention)." ) fail = 1 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d27358bf6..ce583e537 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -7,12 +7,26 @@ on: # 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. + 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 # 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.workflow }}-${{ github.ref }} + 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: @@ -46,7 +60,12 @@ jobs: slug: gitnexus steps: + # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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 @@ -56,8 +75,16 @@ jobs: - name: Verify tag matches gitnexus/package.json version id: version 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: | - TAG_VERSION="${GITHUB_REF#refs/tags/v}" + 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 @@ -92,6 +119,11 @@ jobs: # 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. - name: Extract Docker metadata id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 @@ -102,6 +134,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} + type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.event_name == 'workflow_call' }} - name: Build and push id: build diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index d4db75db0..3e35a58ee 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -125,6 +125,8 @@ jobs: permissions: contents: write # push rc tag + marker id-token: write # npm provenance + outputs: + vtag: ${{ steps.reltag.outputs.vtag }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -364,3 +366,23 @@ jobs: Release candidates are pre-stable builds intended for early testing. Stable releases remain on the `latest` dist-tag. + + # ── Build & push RC Docker images ──────────────────────────────────── + # Calls docker.yml as a reusable workflow so that the build, signing, and + # attestation logic stays in one place. The publish job exposes `vtag` + # (e.g. `v1.2.3-rc.1`) as an output so we can pass it as the tag input. + # RC images are signed with Cosign keyless signing; the OIDC identity + # will be `docker.yml@refs/heads/main` (the caller's ref) rather than a + # tag ref — see README.md § Docker for the correct verify command for RCs. + docker: + name: Build & Push RC Docker images + needs: [guard, publish] + if: needs.guard.outputs.should_run == 'true' + uses: ./.github/workflows/docker.yml + permissions: + contents: read + packages: write + id-token: write + attestations: write + with: + tag: ${{ needs.publish.outputs.vtag }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22104edb4..7f797f9a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ Every workflow under `.github/workflows/` MUST declare a top-level `concurrency: - Per-PR scope (for `issue_comment`, `pull_request_review*`, `pull_request` meta events): `${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}` - `workflow_run` scope (e.g. `ci-report.yml`): `${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}` — the fork fallback must be stable across reruns (never `workflow_run.id`, which is per-run-unique and defeats serialization). - Global single-slot (manual dispatch utilities): `${{ github.workflow }}` - - **Reusable workflows invoked via `workflow_call`:** do NOT use `${{ github.workflow }}` in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a `github.event_name`-aware expression that falls through to `github.run_id` for reusable invocations (see `ci.yml` for the canonical form). + - **Reusable workflows invoked via `workflow_call`:** do NOT use `${{ github.workflow }}` in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a `github.event_name`-aware expression that falls through to `github.run_id` for reusable invocations (see `ci.yml` for the canonical form). Approved literal prefixes: `CI-` (`ci.yml`) and `docker-build-push-` (`docker.yml`). The `check-workflow-concurrency.py` validation script must be updated whenever a new approved literal prefix is added. - **Merge queue (`merge_group`)**: when this event is added, use `${{ github.workflow }}-${{ github.event.merge_group.head_ref }}` with `cancel-in-progress: false` (every queue entry is a distinct ref; never cancel). - **`cancel-in-progress` policy:** @@ -127,6 +127,11 @@ Two publish workflows ship `gitnexus` to npm: the cycle from `latest`. - `N` is auto-incremented against existing `X.Y.Z-rc.*` entries on the 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). Idempotency: the workflow pushes an `rc/` marker tag and a `v` release tag **atomically, before** calling `npm publish`. The guard @@ -140,6 +145,27 @@ Two publish workflows ship `gitnexus` to npm: # then redispatch the workflow with force: true ``` + **Docker-only partial failure:** if `publish` succeeds (npm tarball + tags + are live) but the `docker` job subsequently fails (e.g. GHCR flakiness), + the npm RC is already published and the `rc/` marker is in place. + Re-running `release-candidate.yml` with `force: true` will abort at the + "Version already exists on npm" guard. To recover without cutting a new RC: + + ```bash + # 1. Manually trigger only the docker workflow, passing the existing RC tag: + gh workflow run docker.yml --ref main -f tag=v + # (requires a workflow_dispatch trigger on docker.yml — see note below) + ``` + + Because `docker.yml` intentionally has no `workflow_dispatch` (images are + tag-driven by design), the practical recovery options are: + - Wait for the next commit on `main`, which will cut a new RC that includes + the Docker build. + - Manually run `docker build` + `docker push` locally and sign with Cosign + against the same digest. + - Delete `rc/` and `v` tags, then redispatch with `force: + true` to re-run the full RC pipeline (cuts a new RC number). + The rc workflow never moves `latest`. To verify after a change, inspect dist-tags: ```bash diff --git a/README.md b/README.md index e60d8c8c1..4c273ab47 100644 --- a/README.md +++ b/README.md @@ -400,11 +400,14 @@ docker compose --env-file .env up -d The Docker images are version-locked to the npm package: -- Both images are **only published from `vX.Y.Z` git tags**, 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`. +- 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`. +- 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. - `:latest` is auto-promoted only from non-prerelease tags by the Docker metadata action, so it always points at a real, npm-published version. @@ -416,6 +419,8 @@ typo-squatted registry), they cannot forge a Cosign signature tied to `abhigyanpatwari/GitNexus`'s `docker.yml`. Always verify before pulling into sensitive environments: +**Stable releases** — signed from the `v*` tag ref: + ```bash 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.]+)?$' \ @@ -426,6 +431,15 @@ 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. +**Release candidates** — signed from `refs/heads/main` (the caller's ref when +`release-candidate.yml` invokes `docker.yml` as a reusable workflow): + +```bash +cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1 \ + --certificate-identity 'https://github.com/abhigyanpatwari/GitNexus/.github/workflows/docker.yml@refs/heads/main' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + You can also inspect the build provenance and SBOM: ```bash