Sfoglia il codice sorgente

Fixed image publishing

Efren Yevale Varela 3 settimane fa
parent
commit
4197d93e0b

+ 54 - 6
.github/workflows/supply-chain.yml

@@ -5,6 +5,10 @@
 # Per-push-to-master legs: multi-arch `docker buildx bake` with SBOM +
 # provenance, trivy/grype gating HIGH/CRITICAL, keyless cosign signing, and
 # digest pinning of `deploy/k8s/kustomization.yaml` (strict lint re-run).
+# Release tags (`v*`): the same signed multi-arch build is published under
+# semver tags (X.Y.Z, X.Y, X, `latest`; a pre-release tag gets only X.Y.Z) and
+# a GitHub Release is created. `pin-digest` does not run on a tag (it tracks
+# master).
 # Scheduled legs (weekly rebuild, Monday 04:00 UTC): rebuild + rescan + resign
 # so base-image digests advance even without code changes.
 #
@@ -16,6 +20,7 @@ name: supply-chain
 on:
   push:
     branches: ["master"]
+    tags: ["v*"]
   pull_request: {}
   schedule:
     # Weekly rebuild (Monday 04:00 UTC).
@@ -26,7 +31,7 @@ permissions:
   contents: read
 
 env:
-  REGISTRY_IMAGE: ghcr.io/d-oauth/d-oauth-server
+  REGISTRY_IMAGE: ghcr.io/syavne/d-oauth-server
   SQLX_OFFLINE: "true"
 
 jobs:
@@ -70,10 +75,25 @@ jobs:
       - name: Multi-arch bake with SBOM + provenance
         id: bake
         run: |
-          TAG="sha-${GITHUB_SHA::12}"
-          echo "tag=${TAG}" >> "${GITHUB_OUTPUT}"
-          docker buildx bake image \
-            --set "image.tags=${REGISTRY_IMAGE}:${TAG}" \
+          set -euo pipefail
+          if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
+            version="${GITHUB_REF_NAME#v}"
+            if ! [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
+              echo "::error::release tag '${GITHUB_REF_NAME}' is not semver (expected vX.Y.Z or vX.Y.Z-pre)"
+              exit 1
+            fi
+            tags="${REGISTRY_IMAGE}:${version}"
+            if [[ "${version}" != *-* ]]; then
+              major="${version%%.*}"
+              minor="${version#*.}"; minor="${minor%%.*}"
+              tags+=",${REGISTRY_IMAGE}:${major}.${minor},${REGISTRY_IMAGE}:${major},${REGISTRY_IMAGE}:latest"
+            fi
+            tags+=",${REGISTRY_IMAGE}:sha-${GITHUB_SHA::12}"
+          else
+            tags="${REGISTRY_IMAGE}:sha-${GITHUB_SHA::12}"
+          fi
+          echo "tags=${tags}" >> "${GITHUB_OUTPUT}"
+          IMAGE_TAGS="${tags}" docker buildx bake image \
             --set image.output="type=registry" \
             --sbom=true \
             --provenance=mode=max \
@@ -115,6 +135,9 @@ jobs:
   pin-digest:
     name: pin-digest
     needs: [build]
+    # Only master/scheduled builds advance the committed digest; a release tag
+    # must not rewrite kustomization.
+    if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')
     runs-on: ubuntu-24.04
     steps:
       - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
@@ -135,9 +158,34 @@ jobs:
           path = Path("deploy/k8s/kustomization.yaml")
           doc = yaml.safe_load(path.read_text())
           for img in doc.get("images", []):
-              if img.get("name") == "ghcr.io/d-oauth/d-oauth-server":
+              if img.get("name") == "ghcr.io/syavne/d-oauth-server":
                   img.pop("newTag", None)
                   img["digest"] = digest
           path.write_text(yaml.safe_dump(doc, sort_keys=False))
           EOF
           python3 scripts/check_manifests.py --strict-digest
+
+  release:
+    name: release
+    needs: [build]
+    if: startsWith(github.ref, 'refs/tags/v')
+    runs-on: ubuntu-24.04
+    permissions:
+      contents: write
+    steps:
+      - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
+      - name: Create GitHub Release
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          DIGEST: ${{ needs.build.outputs.digest }}
+        run: |
+          flags=()
+          if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
+            flags+=(--prerelease)
+          fi
+          gh release create "${GITHUB_REF_NAME}" \
+            --verify-tag \
+            --generate-notes \
+            --title "${GITHUB_REF_NAME}" \
+            "${flags[@]}" \
+            --notes "Container image \`${REGISTRY_IMAGE}@${DIGEST}\` (linux/amd64 + linux/arm64), signed with cosign; the SBOM and provenance attestations are attached to the image."

+ 1 - 1
Dockerfile

@@ -77,6 +77,6 @@ ENTRYPOINT ["/d-oauth-server", "serve"]
 LABEL org.opencontainers.image.title="d-oauth-server" \
   org.opencontainers.image.description="OAuth 2.1-only authorization server (static musl, non-root, no shell)" \
   org.opencontainers.image.licenses="MIT OR Apache-2.0" \
-  org.opencontainers.image.source="https://github.com/d-oauth/d-oauth" \
+  org.opencontainers.image.source="https://github.com/Syavne/d-OAuth-Server" \
   org.opencontainers.image.revision="unknown" \
   org.opencontainers.image.vendor="d-oauth"

+ 3 - 3
RUNBOOK.md

@@ -174,7 +174,7 @@ correlation. No step below writes a secret to a log.
 
 ## 6. Deploy provenance (signed image + digest record)
 
-Every rollout ships the signed `ghcr.io/d-oauth/d-oauth-server` image the
+Every rollout ships the signed `ghcr.io/syavne/d-oauth-server` image the
 `supply-chain.yml` `build` job signed (step `Keyless sign (cosign, OIDC)`)
 and the `pin-digest` job pinned into `deploy/k8s/kustomization.yaml`
 (step `Pin kustomization to the built digest + strict lint`). Nothing ships
@@ -182,7 +182,7 @@ from an unverified local tarball. The full path is in
 [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#deploy-path-use-the-signed-ghcrio-image).
 
 - [ ] Read the digest the `pin-digest` job committed:
-  `grep -A2 'name: ghcr.io/d-oauth/d-oauth-server' deploy/k8s/kustomization.yaml`.
+  `grep -A2 'name: ghcr.io/syavne/d-oauth-server' deploy/k8s/kustomization.yaml`.
 - [ ] Verify it before applying (placeholder `sha256:<digest>` stands for the
   value from the previous step; no real digest is recorded here):
 
@@ -190,7 +190,7 @@ from an unverified local tarball. The full path is in
   cosign verify \
     --certificate-oidc-issuer https://token.actions.githubusercontent.com \
     --certificate-identity-regexp 'supply-chain' \
-    'ghcr.io/d-oauth/d-oauth-server@sha256:<digest>'
+    'ghcr.io/syavne/d-oauth-server@sha256:<digest>'
   kustomize build deploy/k8s
   python3 scripts/check_manifests.py --strict-digest
   kubectl apply --dry-run=server -k deploy/k8s

+ 1 - 1
deploy/k8s/deployment.yaml

@@ -78,7 +78,7 @@ spec:
           # `.github/workflows/supply-chain.yml` writes the real signed-build
           # digest into the `images` entry of `kustomization.yaml`; kustomize
           # then applies that digest to this container and the migrate Job.
-          image: ghcr.io/d-oauth/d-oauth-server@sha256:0000000000000000000000000000000000000000000000000000000000000000
+          image: ghcr.io/syavne/d-oauth-server@sha256:0000000000000000000000000000000000000000000000000000000000000000
           imagePullPolicy: IfNotPresent
           ports:
             - name: public-tls

+ 2 - 2
deploy/k8s/kustomization.yaml

@@ -24,8 +24,8 @@ resources:
   - pdb.yaml
   - networkpolicy.yaml
 images:
-  - name: ghcr.io/d-oauth/d-oauth-server
-    newName: ghcr.io/d-oauth/d-oauth-server
+  - name: ghcr.io/syavne/d-oauth-server
+    newName: ghcr.io/syavne/d-oauth-server
     # Placeholder (all zeroes), not a pullable reference: a rollout without
     # the pin step fails closed instead of silently floating on a tag. CI
     # replaces this with the released `sha256:<digest>` after signing.

+ 1 - 1
deploy/k8s/migrate-job.yaml

@@ -48,7 +48,7 @@ spec:
         - name: migrate
           # Same pinning rule as the Deployment: placeholder digest, never a
           # shippable `:latest`; CI resolves it via `kustomization.yaml`.
-          image: ghcr.io/d-oauth/d-oauth-server@sha256:0000000000000000000000000000000000000000000000000000000000000000
+          image: ghcr.io/syavne/d-oauth-server@sha256:0000000000000000000000000000000000000000000000000000000000000000
           imagePullPolicy: IfNotPresent
           # Overrides the image ENTRYPOINT (["/d-oauth-server","serve"]).
           command: ["/d-oauth-server", "migrate"]

+ 8 - 1
docker-bake.hcl

@@ -5,6 +5,13 @@ variable "TAG" {
   default = "latest"
 }
 
+# Comma-separated tag list. Must be an env var, not `--set image.tags=`: bake
+# parses a `--set` value as one string, so a comma list becomes a single
+# invalid tag instead of several.
+variable "IMAGE_TAGS" {
+  default = ""
+}
+
 variable "SOURCE_DATE_EPOCH" {
   default = "0"
 }
@@ -17,7 +24,7 @@ target "image" {
   dockerfile = "Dockerfile"
   context    = "."
   platforms  = ["linux/amd64", "linux/arm64"]
-  tags       = ["d-oauth-server:${TAG}"]
+  tags       = IMAGE_TAGS != "" ? split(",", IMAGE_TAGS) : ["d-oauth-server:${TAG}"]
   args = {
     SOURCE_DATE_EPOCH = "${SOURCE_DATE_EPOCH}"
   }

+ 4 - 0
docs/CHANGELOG.md

@@ -10,6 +10,10 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
 
 ### Added
 
+- Container release publishing on GHCR. A `v*` git tag builds and pushes the
+  signed multi-arch image under semver tags (`X.Y.Z`, `X.Y`, `X`, `latest`; a
+  pre-release gets only `X.Y.Z`) and creates a GitHub Release. The image path is
+  `ghcr.io/syavne/d-oauth-server`.
 - Application-level envelope encryption for the PII directory: emails, client
   names, contacts, redirect URIs, JAR request URIs, JWKS URLs, software ids, and
   mTLS subject DNs/thumbprints are sealed with versioned AES-256-GCM under a new

+ 21 - 2
docs/DEPLOYMENT.md

@@ -50,6 +50,18 @@ docker buildx bake image
 docker buildx bake image --set image.platform=linux/amd64
 ```
 
+To emit several tags from one build, set `IMAGE_TAGS` to a comma-separated
+list. A `--set image.tags=` value is parsed as a single string, so a comma list
+there becomes one invalid tag rather than a tag set:
+
+```bash
+IMAGE_TAGS="ghcr.io/syavne/d-oauth-server:v1.2.3,ghcr.io/syavne/d-oauth-server:latest" \
+  docker buildx bake image
+```
+
+The release path in `.github/workflows/supply-chain.yml` drives this variable
+when a `v*` tag is pushed; see [Supply chain](#supply-chain).
+
 Building `linux/arm64` on an amd64 host requires QEMU binfmt emulation. Docker
 Desktop bundles it; a bare Docker Engine does not. When it is missing, the arm64
 leg fails fast with `exec /bin/sh: exec format error`, because BuildKit pulls
@@ -504,6 +516,13 @@ on:
 - Multi-arch build with an SBOM and provenance attestation (job `build`, step
   `Multi-arch bake with SBOM + provenance`: `docker buildx bake image` with
   `--sbom=true --provenance=mode=max`).
+- Release publishing on a `v*` tag (job `build`, then job `release`): the same
+  signed multi-arch image is tagged `<X.Y.Z>`, `<X.Y>`, `<X>`, and `latest` (a
+  pre-release such as `v1.2.3-rc1` gets only `<X.Y.Z>` plus its sha), and a
+  GitHub Release is created with generated notes. These tags are convenience
+  references only; a production rollout still pins the digest (see
+  [Deploy path](#deploy-path-use-the-signed-ghcrio-image)). `pin-digest` runs
+  only on master/scheduled builds, never on a tag.
 - A vulnerability scan that fails on HIGH and CRITICAL (job `build`, steps
   `Scan (trivy, gate HIGH/CRITICAL)` writing `trivy.sarif` and
   `Scan (grype, gate high/critical)`; the SBOM is committed as the
@@ -528,7 +547,7 @@ then records the exact digest it shipped:
 
 ```bash
 # 1. Read the pinned digest the `pin-digest` job committed.
-grep -A2 'name: ghcr.io/d-oauth/d-oauth-server' deploy/k8s/kustomization.yaml
+grep -A2 'name: ghcr.io/syavne/d-oauth-server' deploy/k8s/kustomization.yaml
 
 # 2. Verify the keyless cosign signature from the `build` job's
 #    `Keyless sign (cosign, OIDC)` step before pulling or applying.
@@ -537,7 +556,7 @@ grep -A2 'name: ghcr.io/d-oauth/d-oauth-server' deploy/k8s/kustomization.yaml
 cosign verify \
   --certificate-oidc-issuer https://token.actions.githubusercontent.com \
   --certificate-identity-regexp 'supply-chain' \
-  'ghcr.io/d-oauth/d-oauth-server@sha256:<digest>'
+  'ghcr.io/syavne/d-oauth-server@sha256:<digest>'
 
 # 3. Confirm the manifests resolve to that digest and pass policy.
 kustomize build deploy/k8s

+ 1 - 1
docs/SECURITY.md

@@ -69,7 +69,7 @@ class, or runtime setting).
 | Unrestricted pod egress (SSRF to metadata, exfiltration) | `networkpolicy.yaml` default-denies egress except DNS (53 to `kube-system`) and Postgres/Redis (5432/6379) scoped to the namespace hosting them — no internet-wide `0.0.0.0/0` peer; any `ipBlock` peer excludes `169.254.0.0/16`; ingress is limited to pod ports 8080/8081 | Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. A managed database outside the cluster needs a specific CIDR or a CNI FQDN policy. The default-deny also blocks the JAR `request_uri` HTTPS fetch — remote request objects are unsupported unless the deployment adds a scoped allowance (see `docs/DEPLOYMENT.md`). |
 | Operator `exec` into a running pod | No shell and a non-root, read-only-rootfs container limit what runs; every secret is a file the container user already reads, and no secret is in the environment | `pods/exec` (and `docker exec`) bypass the application's own authorization; restrict it with RBAC and audit it (`docs/OPERATIONS.md`). |
 | Kubernetes API access from the pod | `automountServiceAccountToken: false`; the binary never calls the API | A future feature that needs the API would have to re-enable it deliberately. |
-| Image/registry compromise | Digest pinning, keyless cosign signing in the `supply-chain.yml` `build` job (`Keyless sign (cosign, OIDC)`), verify-before-rollout with `cosign verify` on the pinned `ghcr.io/d-oauth/d-oauth-server@sha256:<digest>` (see `docs/DEPLOYMENT.md`), `pin-digest` writing the signed digest into `kustomization.yaml` with a `--strict-digest` lint, a committed digest reference with no shippable `:latest`, and a recorded digest per rollout in `RUNBOOK.md`; the local-build fallback still signs (`cosign sign`) and verifies (`cosign verify`) before loading | A registry compromise of an unpinned tag still ships if the operator skips the verify step; an unsigned `docker save \\| docker load` tarball bypasses every control, so it is forbidden except through the documented local sign/verify fallback. |
+| Image/registry compromise | Digest pinning, keyless cosign signing in the `supply-chain.yml` `build` job (`Keyless sign (cosign, OIDC)`), verify-before-rollout with `cosign verify` on the pinned `ghcr.io/syavne/d-oauth-server@sha256:<digest>` (see `docs/DEPLOYMENT.md`), `pin-digest` writing the signed digest into `kustomization.yaml` with a `--strict-digest` lint, a committed digest reference with no shippable `:latest`, and a recorded digest per rollout in `RUNBOOK.md`; the local-build fallback still signs (`cosign sign`) and verifies (`cosign verify`) before loading | A registry compromise of an unpinned tag still ships if the operator skips the verify step; an unsigned `docker save \\| docker load` tarball bypasses every control, so it is forbidden except through the documented local sign/verify fallback. |
 | Rate-limit evasion via spoofed `X-Forwarded-For` behind an L7 proxy | Default-empty   `rate_limit.trusted_proxies` (headers ignored unless the TCP peer is an allowlisted proxy IP/CIDR); rightmost-untrusted selection; malformed headers fall back to the verified peer; attribution is bucket selection only, never authentication | An allowlist entry that is too broad (or a compromised proxy) attributes attacker-chosen IPs; keep entries to the proxy peers and never a `/0` (rejected at load). Proxies must emit bare IPs (no port/brackets/`unknown`); anything else is skipped, and only the rightmost 32 entries are considered. |
 
 Last reviewed: 2026-10-09. File-mounted DB/Redis passwords, digest pinning,