fix(docker): build and push Docker images for Release Candidates (#978)

This commit is contained in:
Copilot 2026-04-19 07:46:21 +01:00 • committed by GitHub
parent dae7bd3b3f
commit fa39a4b4a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 116 additions and 15 deletions

View file

@ -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

View file

@ -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-<ref>`; 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

View file

@ -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 }}

View file

@ -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/<HEAD_SHA>` marker tag and a
`v<RC>` 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/<HEAD_SHA>` 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<RC_VERSION>
# (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/<HEAD_SHA>` and `v<RC>` 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

View file

@ -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