Skip to content

ci: rebuild release workflow on upstream matrix + full hardening#6

Open
Fieldnote-Echo wants to merge 2 commits into
harden/release-gatingfrom
harden/release-matrix
Open

ci: rebuild release workflow on upstream matrix + full hardening#6
Fieldnote-Echo wants to merge 2 commits into
harden/release-gatingfrom
harden/release-matrix

Conversation

@Fieldnote-Echo

Copy link
Copy Markdown
Owner

Stacked on #5 (base harden/release-gating). Rebuilds the release workflow on upstream's multi-arch matrix with our hardening layered on — the "best of both" end-state from the review: we get upstream's breadth back and keep the supply-chain security upstream lacks.

Supersedes #5's release.yml. #5 hardened the old single-job (amd64-GPU-only) workflow; this replaces that file entirely with the matrix version, folding #5's hardening into it. If you'd rather not carry the redundant intermediate, #5 can be dropped and this rebased onto #4 — say the word.

Why

Our single-job workflow built one artifact (amd64 GPU); upstream's builds six (cpu/gpu/gpu-cu128/rocm × amd64+arm64) via a clean 4-job matrix with native arm64 runners and multi-arch manifest assembly. On the capability axis ours was strictly worse. This takes upstream's structure and adds what upstream is missing.

Structure (5 jobs)

prepare-releasebuild-images (6-leg matrix) → create-manifests (immutable :VERSION family + attest) → promote-latest (rolling :latest*, gated on all packages) → create-release. The promote-latest split is new — it prevents :latest ever pointing at a half-published set.

Hardening (per job)

Every action SHA-pinned · harden-runner egress-policy: block first in every job · gated Trivy scan per leg before push (fixable CRITICAL, ignore-unfixed) + non-blocking CRITICAL,HIGH SARIF · Sigstore provenance + SBOM attested on the published multi-arch index · persist-credentials: false · least-privilege permissions.

Red-teamed (4 adversarial lenses; all critical/major fixed)

  • Shell injection (critical): inputs.branch_name is attacker-controllable via workflow_dispatch; it (and all context values) now flow through env: and are referenced as shell vars — never spliced into run: text. Plus OCI-tag allowlist sanitization on the branch-derived tag.
  • Egress fail-closed (major): block mode would have killed every build — the Docker builds fetch uv (astral.sh), OS packages over :80 apt mirrors, Rust (rustup), an Arch rocBLAS pin, and UniDic. All added to the per-job allowlists.
  • Torn release (major): split :latest into the gated promote-latest job; documented a Trivy break-glass (refresh base digests, or a dated .trivyignore) for when the vuln DB flags a fixable CRITICAL weeks after a clean release.
  • Determinism (info): one CREATED timestamp shared across scan/push/annotations so the scanned and pushed images are bit-identical.

Verification

actionlint clean · 19 actions SHA-pinned (0 floating) · all 6 matrix legs map to real docker-bake.hcl targets · harden-runner first in all 5 jobs.

⚠️ First-run caveat: egress-policy: block fails closed. The allowlists cover all known egress, but the first hardened run may hit an unlisted CDN/mirror shard (likeliest: raw.githubusercontent.com for syft in create-manifests) — harden-runner names the blocked host in the log; add it and re-run. This PR is the review gate before any of it takes effect.

Residual (documented, non-blocking): SBOM is amd64-representative for multi-arch packages (provenance is digest-exact); prerelease VERSIONs with a - skip :latest promotion (inherited from upstream's version logic); Trivy-DB drift can gate a previously-clean release (break-glass above).

🤖 Generated with Claude Code

@gemini-code-assist

Copy link
Copy Markdown

Note

Gemini is unable to generate a review for this pull request due to the file types involved not being currently supported.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

CI: rebuild hardened multi-arch release workflow with gated scanning and attestations

✨ Enhancement ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Replaces the single-image release pipeline with an upstream-style multi-arch build matrix.
• Adds per-leg hardening: egress blocking, pre-push Trivy gating, and SARIF reporting.
• Publishes immutable versioned manifests + attestations, then promotes :latest only when complete.
Diagram

graph TD
  A(("Trigger")) --> B["prepare-release"] --> C["build-images (6-leg matrix)"] --> D["create-manifests (version tags + attest)"] --> E["promote-latest (gated)"] --> F["create-release"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Push to staging tags then promote on scan pass
  • ➕ Eliminates orphaned per-arch tags when a sibling leg fails the Trivy gate
  • ➕ Allows scanning the exact pushed digest rather than a locally loaded image
  • ➖ Requires extra registry namespace/tags and a promotion step for all artifacts
  • ➖ More moving parts; staging cleanup and retention policy needed
2. Per-arch SBOM generation + attestation
  • ➕ Accurate component inventory for multi-arch images (arm64 may differ materially, e.g., different CUDA/torch wheels)
  • ➕ Aligns SBOM coverage with the exact per-arch digests already pushed
  • ➖ More build/scan time and more attestations to manage/verify
  • ➖ More complex logic (loop over per-arch digests)
3. Extract hardening into reusable workflow/composite actions
  • ➕ Reduces YAML size and duplication (harden-runner + login + bake + scan patterns)
  • ➕ Makes future workflow changes easier to audit and apply consistently
  • ➖ Adds indirection during incident response/debugging
  • ➖ Reusable workflows can complicate permission/secret scoping across jobs

Recommendation: The PR’s approach is strong for supply-chain posture while restoring upstream’s multi-arch breadth. If operational friction shows up (orphaned tags, SBOM accuracy requirements), the most valuable follow-up would be per-arch SBOM attestation; otherwise keep the current index-level attestations and gated :latest promotion as the best balance of security and maintainability.

Files changed (1) +614 / -132

Other (1) +614 / -132
release.ymlRebuild release workflow as hardened multi-arch matrix pipeline +614/-132

Rebuild release workflow as hardened multi-arch matrix pipeline

• Replaces the prior single-job release workflow with a 5-job pipeline: prepare-release, a 6-leg build matrix with egress blocking and pre-push Trivy gating, manifest assembly with index annotations, gated :latest promotion, and GitHub Release creation. Adds deterministic created timestamps, SHA-pinned actions, least-privilege permissions, and provenance/SBOM attestations for published multi-arch indices.

.github/workflows/release.yml

@qodo-code-review

qodo-code-review Bot commented Jul 18, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Unpinned release source commit ✓ Resolved 🐞 Bug ≡ Correctness
Description
The workflow checks out and releases by a moving branch name (release / inputs.branch_name)
instead of a resolved commit SHA, so different jobs (and the final GitHub tag/release) can point at
a different commit than the images that were built/scanned. This breaks determinism and can publish
OCI metadata/provenance that claims the wrong revision.
Code

.github/workflows/release.yml[R262-266]

+      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
-          persist-credentials: false  # don't leave the token in .git/config for later steps
-
-      # ── Guard: don't re-publish an existing release ───────────────────
-      - name: Check release does not exist
-        if: startsWith(github.ref, 'refs/tags/')
-        env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        run: |
-          if gh release view "${{ github.ref_name }}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
-            echo "::error::Release ${{ github.ref_name }} already exists. Push a new tag to publish a new release."
-            exit 1
-          fi
+          ref: ${{ needs.prepare-release.outputs.source_ref }}
+          persist-credentials: false
Evidence
prepare-release resolves only a branch ref (source_ref) and later jobs re-checkout using that
ref, which can move; meanwhile build metadata uses ${{ github.sha }} and release creation uses
target_commitish: source_ref, so the commit being built/scanned and the commit being
recorded/tagged can diverge. Since docker-bake.hcl applies REVISION into OCI labels/annotations,
this mismatch becomes externally visible in published artifacts.

.github/workflows/release.yml[97-116]
.github/workflows/release.yml[262-266]
.github/workflows/release.yml[301-307]
.github/workflows/release.yml[368-373]
.github/workflows/release.yml[505-516]
.github/workflows/release.yml[694-705]
docker-bake.hcl[22-58]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The workflow passes a branch name between jobs (`source_ref`) and uses it for checkouts and `target_commitish`, which is not immutable. If the branch advances while the workflow is running (or if `workflow_dispatch` builds from a ref different than the workflow’s own ref), later jobs can build/release a different commit than the one used to compute the version and/or the one recorded in OCI metadata.

## Issue Context
- `docker-bake.hcl` uses `REVISION`/`CREATED` to stamp OCI labels/annotations.
- The workflow currently sets `REVISION` from `${{ github.sha }}` even when code is checked out by branch name.

## Fix
1. In `prepare-release`, after checkout, capture the exact source commit SHA (e.g., `git rev-parse HEAD`) and expose it as a job output (e.g., `source_sha`).
2. Use that immutable SHA everywhere:
  - all `actions/checkout` steps (`ref: ${{ needs.prepare-release.outputs.source_sha }}`)
  - all `REVISION`/annotation values (use the resolved SHA, not `${{ github.sha }}`)
  - `softprops/action-gh-release` `target_commitish` (use the SHA)

## Fix Focus Areas
- .github/workflows/release.yml[94-116]
- .github/workflows/release.yml[262-267]
- .github/workflows/release.yml[301-308]
- .github/workflows/release.yml[368-374]
- .github/workflows/release.yml[503-516]
- .github/workflows/release.yml[694-706]
- docker-bake.hcl[22-58]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Unauthenticated tag fetch ✓ Resolved 🐞 Bug ☼ Reliability
Description
prepare-release runs git fetch --tags after checkout with persist-credentials: false, so the
fetch runs without authentication and can fail on private repositories, breaking the duplicate-tag
guard. This also adds an unnecessary network dependency in the most fragile (egress-blocked) job.
Code

.github/workflows/release.yml[R141-149]

+      - name: Check tag does not exist
+        env:
+          TAG_NAME: ${{ steps.get-version.outputs.version_tag }}
+        run: |
+          set -euo pipefail
+          git fetch --tags
+          if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
+            echo "::error::Tag $TAG_NAME already exists. Please increment the version in the VERSION file."
+            exit 1
Evidence
The workflow explicitly sets persist-credentials: false on checkout and then performs `git fetch
--tags, meaning the subsequent fetch does not have the checkout token available via .git/config`;
this can fail when authentication is required and adds avoidable network flakiness.

.github/workflows/release.yml[111-116]
.github/workflows/release.yml[141-149]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The workflow disables persisted credentials on checkout, then runs `git fetch --tags`. That fetch will run without authenticated credentials and may fail depending on repo visibility, breaking the tag-existence guard.

## Issue Context
The tag existence check only needs local tag refs; if a full-history+tags checkout is intended, it should be accomplished during the checkout step (or by explicit authenticated configuration), not via a later unauthenticated fetch.

## Fix
Choose one:
- Prefer: remove `git fetch --tags` and make tag availability explicit via checkout configuration (e.g., ensure tags are fetched as part of `actions/checkout`).
- Alternative: if a separate fetch is required, configure authentication for the fetch (e.g., temporarily set an authenticated remote using `GITHUB_TOKEN` for that command).

## Fix Focus Areas
- .github/workflows/release.yml[111-116]
- .github/workflows/release.yml[141-149]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread .github/workflows/release.yml
Comment thread .github/workflows/release.yml

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1924270969

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
echo "source_ref=${DISPATCH_BRANCH}" >> "$GITHUB_OUTPUT"
else
echo "source_ref=${PUSH_REF_NAME}" >> "$GITHUB_OUTPUT"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve the release source to an immutable commit

For release-branch pushes, source_ref is the mutable branch name rather than the triggering SHA. If another commit reaches release during this long matrix workflow, prepare, individual build legs, and target_commitish can resolve different branch tips, publishing code from a later commit under the earlier run's version; the OCI revision remains github.sha, making the metadata false as well. Resolve and propagate the checked-out commit SHA instead.

Useful? React with 👍 / 👎.

Comment thread .github/workflows/release.yml Outdated
BUILDKIT_STEP_LOG_MAX_SIZE: 10485760
VERSION: ${{ needs.prepare-release.outputs.version_tag }}
REGISTRY: ${{ vars.REGISTRY || 'ghcr.io' }}
OWNER: ${{ vars.OWNER || 'remsky' }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Default image pushes to this fork's namespace

When the repository has no OWNER Actions variable, this fallback sends every bake push to ghcr.io/remsky, even though this Fieldnote-Echo fork and the previous workflow publish under ghcr.io/fieldnote-echo. The fork's GITHUB_TOKEN is normally not authorized to write packages in the upstream owner's namespace, so the first --push fails and no release is produced; default to the current repository owner or this fork's namespace.

Useful? React with 👍 / 👎.

DOCKER_BUILDKIT: 1
BUILDKIT_STEP_LOG_MAX_SIZE: 10485760
VERSION: ${{ needs.prepare-release.outputs.version_tag }}
REGISTRY: ${{ vars.REGISTRY || 'ghcr.io' }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Authenticate the configured registry

When vars.REGISTRY is set to anything other than ghcr.io, bake and the manifest jobs target that registry, but every login remains hard-coded to GHCR and the blocking egress allowlists omit the configured host. Such a configured run therefore fails during push or manifest creation; either consistently authenticate and allow REGISTRY, or remove the nonfunctional override.

Useful? React with 👍 / 👎.

fail-fast: false
matrix:
include:
- build_target: "cpu"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pin base images for the newly published targets

Adding the CPU and ROCm legs publishes targets whose base references remain mutable: docker/cpu/Dockerfile.optimized uses python:3.10, while docker/rocm/Dockerfile uses rocm/dev-ubuntu-24.04:6.4.4-complete; docker-bake.hcl only supplies digest overrides for the CUDA targets. A registry retag can therefore change these release artifacts without any source change, defeating the workflow's stated base-image pinning and reproducibility guarantees; add digest-pinned overrides before publishing these legs.

Useful? React with 👍 / 👎.

Comment thread .github/workflows/release.yml Outdated
Curated summary: [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ needs.prepare-release.outputs.version_tag }}/CHANGELOG.md)
generate_release_notes: true
draft: false
prerelease: false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark suffixed releases as prereleases

When VERSION has a prerelease suffix, or a manual feature-branch build produces the workflow's branch-suffixed version, this explicit false still publishes the resulting GitHub Release as stable. It can consequently become the user-facing latest release even though promote-latest deliberately leaves the container :latest tags on the previous stable build; derive this flag from the version/source or skip GitHub Release creation for branch builds.

Useful? React with 👍 / 👎.

@@ -166,59 +347,360 @@ jobs:
uses: github/codeql-action/upload-sarif@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep SARIF reporting failures from blocking image pushes

When the Code Scanning upload is rejected or temporarily unavailable, this step fails normally despite the preceding comment describing reporting as non-blocking. Because the gated Build and push step appears later and has the implicit success() condition, that reporting failure skips the push even when the vulnerability gate passed; make the SARIF report/upload explicitly continue-on-error or condition the push on the gate result instead.

Useful? React with 👍 / 👎.

Fieldnote-Echo and others added 2 commits July 18, 2026 19:44
Supersedes the single-job amd64-GPU-only release.yml (from harden/release-
gating) with upstream's multi-arch pipeline, hardening layered on so we regain
breadth without losing security. Best of both.

Structure (upstream, 5 jobs): prepare-release -> build-images (6-leg matrix:
cpu/gpu/gpu-cu128/rocm x amd64+arm64, native arm64 runners) -> create-manifests
(version tags + index attest) -> promote-latest (rolling :latest, gated on ALL
packages) -> create-release.

Hardening (ours, per job): every action SHA-pinned; harden-runner egress:block
first in every job; gated Trivy scan per leg BEFORE push (fixable CRITICAL,
ignore-unfixed) + non-blocking CRITICAL,HIGH SARIF; Sigstore provenance + SBOM
attested on the published multi-arch index; persist-credentials:false;
least-privilege per-job permissions.

Hardened + red-teamed via 4 adversarial lenses; all critical/major findings
integrated:
- shell-injection: inputs.branch_name (attacker-controllable) now flows through
  env vars, never spliced into run: text; OCI-tag allowlist sanitization
- egress: added the build-time hosts block mode would have failed closed on
  (astral.sh/uv, apt mirrors on :80, rustup, archlinux rocblas pin, UniDic)
- torn-release: split :latest into a promote-latest job gated on all packages;
  documented Trivy break-glass (refresh base digests or dated .trivyignore)
- determinism: single CREATED timestamp shared across scan/push/annotations

actionlint clean; 19 actions SHA-pinned; all 6 bake targets verified against
docker-bake.hcl.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex/Qodo findings on PR #6:
- OWNER default was upstream's 'remsky'; on this fork the token can't push to
  ghcr.io/remsky, so the first release would fail. Default to 'fieldnote-echo'
  (matches the prior single-job workflow). [Codex P1 + Qodo]
- source_ref was a mutable branch name used for checkouts, OCI revision, and
  the release target_commitish — a branch advancing mid-run could build/attest/
  tag different commits. Resolve the commit SHA once in prepare-release
  (source_sha output) and use it everywhere. Matters here because we attest
  provenance. [Codex P1 + Qodo, independently]
- CPU (python:3.10 / -slim) and ROCm base images were unpinned while the
  workflow claims reproducible pinning; now that we publish those legs, pin
  them by digest. ARG-ified the Dockerfiles (tag defaults keep plain builds
  working) + digest args in docker-bake.hcl. [Codex P1]
- SARIF report/upload could fail and skip the gated-clean push (implicit
  success() on the later push step). Marked both continue-on-error. [Codex P2]
- prerelease was hardcoded false; a suffixed/branch version would publish as
  the user-facing stable release. Derive it from the version_tag suffix. [Codex P2]
- Dropped the unauthenticated 'git fetch --tags' (checkout fetch-depth:0 already
  fetched tags; persist-credentials:false made the fetch anonymous). [Qodo]

actionlint clean; all actions SHA-pinned; bake config parses with the new pins.
@Fieldnote-Echo
Fieldnote-Echo force-pushed the harden/release-gating branch from 687a13b to 0bc6b5f Compare July 19, 2026 00:50
@Fieldnote-Echo
Fieldnote-Echo force-pushed the harden/release-matrix branch from 1924270 to 65867d6 Compare July 19, 2026 00:50

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65867d6faf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +146 to +148
SAFE_BRANCH=$(echo "$BRANCH_NAME" | tr '/' '-' | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9._-')
echo "version=${VERSION_PLAIN}-${SAFE_BRANCH}" >> "$GITHUB_OUTPUT"
echo "version_tag=v${VERSION_PLAIN}-${SAFE_BRANCH}" >> "$GITHUB_OUTPUT"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make branch-derived release tags collision-resistant

When two manual builds use distinct valid refs that normalize identically—such as feature/foo, feature-foo, or case-only variants—this transform gives them the same version tags while the concurrency group at line 64 still uses the raw branch names, so both runs may proceed simultaneously. Their per-architecture pushes can overwrite one another and produce a manifest assembled from different commits while its revision annotation names only one commit; append a commit-derived identifier or otherwise make the normalized value unique.

Useful? React with 👍 / 👎.

Comment on lines +146 to +148
SAFE_BRANCH=$(echo "$BRANCH_NAME" | tr '/' '-' | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9._-')
echo "version=${VERSION_PLAIN}-${SAFE_BRANCH}" >> "$GITHUB_OUTPUT"
echo "version_tag=v${VERSION_PLAIN}-${SAFE_BRANCH}" >> "$GITHUB_OUTPUT"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit branch-derived tags to the OCI maximum length

For a manually dispatched branch longer than the remaining space after v${VERSION_PLAIN}-, this unbounded suffix produces a tag exceeding the 128-character Docker/OCI tag limit. Git accepts substantially longer branch names, so the workflow can complete preparation and expensive builds only to fail when bake parses or pushes the generated tag; truncate the suffix while retaining a collision-resistant hash.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant