Repository navigation
chore(release): 3.0.0 (#102) #16
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: publish-docker | |
| # Builds, tests, and publishes multi-arch socket-basics image variants | |
| # (linux/amd64 + linux/arm64) to GHCR and Docker Hub. | |
| # | |
| # Flow: | |
| # resolve-version | |
| # → build-test-push (matrix: image variant + native arch, pushes by digest) | |
| # → merge-manifests (assembles per-image per-arch digests into manifest lists) | |
| # → create-release (tag pushes only) | |
| # | |
| # Tag convention: | |
| # v2.0.0 — immutable exact release (floating major tags intentionally not published) | |
| # All image variants publish to the single socket-basics repository per | |
| # registry, distinguished by tag suffix: 2.0.0 (main), 2.0.0-heavy (heavy). | |
| # See docs/github-action.md → "Pinning strategies" for the full rationale. | |
| # | |
| # Required secrets — scoped to the `publish` environment (deployment policy: | |
| # branch `main` + tags `v*`), not repo-level: | |
| # DOCKERHUB_USERNAME — Docker Hub account name (also the registry namespace) | |
| # DOCKERHUB_TOKEN — Docker Hub access token (read/write) | |
| # Publishing jobs bind `environment: publish` to resolve them; the reusable | |
| # pipeline binds it only in push mode (see _docker-pipeline.yml). | |
| on: | |
| push: | |
| tags: | |
| - 'v[0-9]+.[0-9]+.[0-9]+' | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: "Full git tag to publish (e.g. v2.0.3 or 2.0.3). Must exist in the repo." | |
| required: true | |
| # Default: deny everything. Each job below grants only what it needs. | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: publish-docker-${{ github.ref }} | |
| cancel-in-progress: false # never cancel an in-flight publish | |
| jobs: | |
| # ── Job 1: Resolve version ───────────────────────────────────────────────── | |
| # Computes a clean semver string (no v prefix) consumed by downstream jobs. | |
| resolve-version: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.version.outputs.clean }} | |
| ref: ${{ steps.version.outputs.ref }} | |
| steps: | |
| - name: 🏷️ Resolve version | |
| id: version | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| INPUT_TAG: ${{ inputs.tag }} | |
| REF_NAME: ${{ github.ref_name }} | |
| REPO_URL: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git | |
| run: | | |
| if [ "$EVENT_NAME" = "workflow_dispatch" ]; then | |
| RAW="${INPUT_TAG#refs/tags/}" # full tag as provided (e.g. 2.0.3 or v2.0.3) | |
| else | |
| RAW="$REF_NAME" # e.g. v2.0.3 | |
| fi | |
| CLEAN="${RAW#v}" # strip leading v if present → 2.0.3 | |
| if [[ ! "$CLEAN" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "Invalid release tag: $CLEAN" >&2 | |
| exit 1 | |
| fi | |
| REF_TAG="$RAW" | |
| if [ "$EVENT_NAME" = "workflow_dispatch" ]; then | |
| REF_TAG="" | |
| for candidate in "$RAW" "v$CLEAN" "$CLEAN"; do | |
| if git ls-remote --exit-code --tags "$REPO_URL" "refs/tags/$candidate" >/dev/null 2>&1; then | |
| REF_TAG="$candidate" | |
| break | |
| fi | |
| done | |
| if [ -z "$REF_TAG" ]; then | |
| echo "No matching release tag found for input: $INPUT_TAG" >&2 | |
| exit 1 | |
| fi | |
| fi | |
| echo "clean=$CLEAN" >> "$GITHUB_OUTPUT" | |
| echo "ref=refs/tags/$REF_TAG" >> "$GITHUB_OUTPUT" | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ steps.version.outputs.ref }} | |
| persist-credentials: false | |
| # Guard: the release source must agree with the tag. The pre-commit | |
| # version-check hook was removed in #46 with no automated replacement, | |
| # which let v2.1.0 ship with version files still at 2.0.3. This is the | |
| # release-time gate: mismatches fail here, before anything is built. | |
| - name: 🔎 Verify source versions match release tag | |
| env: | |
| VERSION: ${{ steps.version.outputs.clean }} | |
| run: | | |
| set -euo pipefail | |
| fail=0 | |
| check() { | |
| if [ "$2" != "$VERSION" ]; then | |
| echo "::error::$1 declares version '$2' but the release tag resolves to '$VERSION'" | |
| fail=1 | |
| fi | |
| } | |
| check "socket_basics/version.py" "$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' socket_basics/version.py | head -1)" | |
| check "socket_basics/__init__.py" "$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' socket_basics/__init__.py | head -1)" | |
| check "pyproject.toml" "$(sed -n 's/^version = "\(.*\)"$/\1/p' pyproject.toml | head -1)" | |
| check "action.yml image tag" "$(sed -n 's#.*docker://ghcr.io/socketdev/socket-basics:\([^"]*\)".*#\1#p' action.yml | head -1)" | |
| if [ "$fail" -ne 0 ]; then | |
| echo "::error::Bump the version files in the release PR, merge, then re-tag." | |
| exit 1 | |
| fi | |
| echo "✅ version.py, pyproject.toml, and action.yml all agree on $VERSION" | |
| # ── Job 2: Build → test → push by digest (per image + arch) ──────────────── | |
| # Each matrix entry runs the full build/smoke/integration pipeline on a | |
| # native runner for its target arch and pushes the resulting image by digest | |
| # to both registries. The digest is exported as an artifact for the merge job. | |
| build-test-push: | |
| name: publish (${{ matrix.image }}, ${{ matrix.arch }}) | |
| needs: resolve-version | |
| permissions: | |
| contents: read | |
| packages: write # push images to GHCR | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - image: socket-basics | |
| dockerfile: Dockerfile | |
| check_set: main | |
| arch: amd64 | |
| runs_on: ubuntu-latest | |
| - image: socket-basics | |
| dockerfile: Dockerfile | |
| check_set: main | |
| arch: arm64 | |
| runs_on: ubuntu-24.04-arm | |
| - image: socket-basics-heavy | |
| dockerfile: Dockerfile.heavy | |
| check_set: heavy | |
| arch: amd64 | |
| runs_on: ubuntu-latest | |
| - image: socket-basics-heavy | |
| dockerfile: Dockerfile.heavy | |
| check_set: heavy | |
| arch: arm64 | |
| runs_on: ubuntu-24.04-arm | |
| # zizmor: ignore[secrets-inherit] — required: environment-scoped secrets | |
| # cannot be passed via an explicit workflow-call mapping (they only resolve | |
| # on the environment-bound job inside the called same-repo workflow). | |
| uses: ./.github/workflows/_docker-pipeline.yml # zizmor: ignore[secrets-inherit] | |
| with: | |
| name: ${{ matrix.image }} | |
| dockerfile: ${{ matrix.dockerfile }} | |
| context: . | |
| check_set: ${{ matrix.check_set }} | |
| runs_on: ${{ matrix.runs_on }} | |
| arch_label: ${{ matrix.arch }} | |
| # All variants publish to the single socket-basics repository on each | |
| # registry; variants are distinguished by tag suffix (e.g. -heavy), never | |
| # by a separate repository. | |
| push_name: socket-basics | |
| push: true | |
| version: ${{ needs.resolve-version.outputs.version }} | |
| ref: ${{ needs.resolve-version.outputs.ref }} | |
| # Environment secrets can't be passed from a workflow-call job (no | |
| # `environment:` allowed here); inherit lets the reusable workflow's | |
| # environment-bound job resolve them itself. | |
| secrets: inherit | |
| # ── Job 3: Merge per-arch digests into a multi-arch manifest list ────────── | |
| # Tags: exact immutable version (X.Y.Z / X.Y.Z-heavy) plus the floating | |
| # `latest` / `latest-heavy` convenience aliases. Version tags are immutable | |
| # registry-side (Docker Hub immutable-tag rule: ^\d+\.\d+\.\d+(-heavy)?$); | |
| # `latest` deliberately floats. Consumers needing reproducibility should pin | |
| # the exact version or digest (docs/github-action.md → "Pinning strategies"). | |
| # Floating MAJOR tags (v2 → latest v2.x.y) remain intentionally omitted. | |
| merge-manifests: | |
| name: merge-manifests (${{ matrix.variant }}) | |
| needs: [resolve-version, build-test-push] | |
| permissions: | |
| contents: read | |
| packages: write | |
| environment: publish # Docker Hub secrets are environment-scoped | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| # Both variants live in the single socket-basics repository per registry, | |
| # distinguished by tag suffix (2.2.0 vs 2.2.0-heavy). `variant` selects | |
| # the per-arch digest artifacts produced by build-test-push. | |
| include: | |
| - variant: socket-basics | |
| tag_suffix: "" | |
| - variant: socket-basics-heavy | |
| tag_suffix: "-heavy" | |
| steps: | |
| - name: ⬇️ Download per-arch digest artifacts | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| path: /tmp/digests | |
| pattern: digests-${{ matrix.variant }}__* | |
| merge-multiple: true | |
| - name: 🔨 Set up Docker Buildx | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 | |
| - name: Login to GHCR | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ github.token }} | |
| - name: Login to Docker Hub | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Extract image metadata | |
| id: meta | |
| uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 | |
| with: | |
| images: | | |
| ghcr.io/socketdev/socket-basics | |
| ${{ secrets.DOCKERHUB_USERNAME }}/socket-basics | |
| # `latest` (and `latest-heavy` via the variant suffix) float to the | |
| # newest release; exact version tags stay immutable registry-side. | |
| # The variant suffix yields X.Y.Z for the main image, X.Y.Z-heavy for heavy. | |
| flavor: | | |
| latest=true | |
| # onlatest applies the variant suffix to the latest alias too — | |
| # without it both variants would publish a bare, racing `latest` | |
| # and `latest-heavy` would never exist. | |
| suffix=${{ matrix.tag_suffix }},onlatest=true | |
| tags: | | |
| # Tag push (vX.Y.Z) → exact immutable version tag + latest alias. | |
| type=semver,pattern={{version}} | |
| # workflow_dispatch re-publish → use the version input directly. | |
| # NOTE: re-pushing an already-published version tag is rejected by | |
| # the registry's immutable-tag rule — dispatch mode is for recovery | |
| # when tags never landed (it also repoints `latest`). | |
| type=raw,value=${{ needs.resolve-version.outputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }} | |
| - name: 🧬 Create multi-arch manifest list | |
| working-directory: /tmp/digests | |
| env: | |
| GHCR_IMAGE: ghcr.io/socketdev/socket-basics | |
| DH_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/socket-basics | |
| META_TAGS: ${{ steps.meta.outputs.tags }} | |
| EXPECTED_ARCHES: "2" | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| digests=(*) | |
| if [ "${#digests[@]}" -ne "$EXPECTED_ARCHES" ]; then | |
| echo "::error::expected $EXPECTED_ARCHES per-arch digests for ${{ matrix.variant }}, found ${#digests[@]}" | |
| ls -la | |
| exit 1 | |
| fi | |
| # Each by-digest push from the matrix step landed blobs in BOTH | |
| # registries, so per-registry imagetools-create only writes manifests. | |
| for image in "$GHCR_IMAGE" "$DH_IMAGE"; do | |
| tag_args=() | |
| while IFS= read -r tag; do | |
| [ -z "$tag" ] && continue | |
| case "$tag" in | |
| "$image:"*) tag_args+=(-t "$tag") ;; | |
| esac | |
| done <<< "$META_TAGS" | |
| if [ ${#tag_args[@]} -eq 0 ]; then | |
| echo "::error::no tags resolved for $image" | |
| exit 1 | |
| fi | |
| sources=() | |
| for digest in "${digests[@]}"; do | |
| sources+=("${image}@sha256:${digest}") | |
| done | |
| echo "→ creating manifest list for $image with ${#sources[@]} arch sources" | |
| docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" | |
| done | |
| - name: 🔍 Inspect published manifest | |
| env: | |
| GHCR_IMAGE: ghcr.io/socketdev/socket-basics | |
| DH_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/socket-basics | |
| VERSION: ${{ needs.resolve-version.outputs.version }} | |
| TAG_SUFFIX: ${{ matrix.tag_suffix }} | |
| run: | | |
| set -euo pipefail | |
| for image in "$GHCR_IMAGE" "$DH_IMAGE"; do | |
| ref="${image}:${VERSION}${TAG_SUFFIX}" | |
| inspect="$(docker buildx imagetools inspect "$ref")" | |
| echo "$inspect" | |
| for platform in linux/amd64 linux/arm64; do | |
| if ! grep -q "Platform:[[:space:]]*$platform" <<< "$inspect"; then | |
| echo "::error::$ref is missing $platform" | |
| exit 1 | |
| fi | |
| done | |
| done | |
| # ── Job 4: Create GitHub release ─────────────────────────────────────────── | |
| # Runs once after the manifest is published (not for workflow_dispatch | |
| # re-publishes — those don't create new releases). | |
| # Generates categorised release notes from merged PR labels (.github/release.yml). | |
| # CHANGELOG updates are intentionally human-authored in the release PR so this | |
| # workflow never needs to push commits to the protected default branch. | |
| create-release: | |
| needs: [resolve-version, merge-manifests] | |
| if: github.ref_type == 'tag' | |
| permissions: | |
| contents: write # create GitHub release | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: 📝 Create GitHub release with auto-generated notes | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REF_NAME: ${{ github.ref_name }} | |
| run: | | |
| gh release create "$REF_NAME" \ | |
| --title "$REF_NAME" \ | |
| --generate-notes \ | |
| --verify-tag \ | |
| || echo "Release already exists (re-run scenario) — skipping creation" |