From 6e560c61e3d1b4c54a03918058d61ee364e874fa Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 12 Apr 2026 14:17:51 +0800 Subject: [PATCH] feat: enforce traceable release chain --- .github/workflows/pipeline.yml | 51 ++++++++++-- AGENTS.md | 16 ++++ api/api.go | 3 + api/api_test.go | 33 ++++++++ scripts/github-actions/validate-deploy.sh | 2 + skills/release-traceability/SKILL.md | 98 +++++++++++++++++++++++ 6 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 AGENTS.md create mode 100644 skills/release-traceability/SKILL.md diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index f48d87c..fbc40af 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -79,6 +79,8 @@ jobs: SERVICE_IMAGE_NAME: accounts outputs: service_image_repo: ${{ steps.service_image.outputs.repo }} + service_image_tag: ${{ steps.service_ref.outputs.image_tag }} + service_image_commit: ${{ steps.service_ref.outputs.image_commit }} service_image_ref: ${{ steps.service_ref.outputs.image_ref }} steps: - name: Check Out Repository @@ -116,6 +118,14 @@ jobs: run: | set -euo pipefail image_ref="$(bash .github/scripts/utils/preferred-image-ref.sh "${{ steps.service_meta.outputs.tags }}")" + image_no_digest="${image_ref%@*}" + image_tag="${image_no_digest##*:}" + image_commit="" + if [[ "${image_tag}" =~ ^[0-9a-f]{7,40}$ ]]; then + image_commit="${image_tag}" + fi + echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT" + echo "image_commit=${image_commit}" >> "$GITHUB_OUTPUT" echo "image_ref=${image_ref}" >> "$GITHUB_OUTPUT" - name: Build And Push Service Image @@ -136,7 +146,7 @@ jobs: if: ${{ needs.prep.outputs.push_image == 'true' }} runs-on: ubuntu-latest outputs: - image: ${{ needs.build.outputs.service_image_repo }} + image: ${{ needs.build.outputs.service_image_ref }} image_ref: ${{ needs.build.outputs.service_image_ref }} run_apply: ${{ needs.prep.outputs.run_apply }} pushed: "true" @@ -144,6 +154,25 @@ jobs: - name: Check Out Repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Resolve Deploy Image Artifact + id: deploy_image + run: | + set -euo pipefail + + image_ref="${{ needs.build.outputs.service_image_ref }}" + image_no_digest="${image_ref%@*}" + image_repo="${image_no_digest%:*}" + image_tag="${image_no_digest##*:}" + + if [[ -z "${image_ref}" || -z "${image_repo}" || -z "${image_tag}" || "${image_repo}" == "${image_tag}" ]]; then + echo "invalid deploy image artifact: ${image_ref}" >&2 + exit 1 + fi + + echo "image_ref=${image_ref}" >> "$GITHUB_OUTPUT" + echo "image_repo=${image_repo}" >> "$GITHUB_OUTPUT" + echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT" + - name: Check Out Playbooks Repository # Pull latest playbooks HEAD from the default branch. uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -152,12 +181,19 @@ jobs: token: ${{ secrets.WORKSPACE_REPO_TOKEN || github.token }} path: playbooks - - name: Resolve Deploy Image Tag - id: deploy_image_tag + - name: Guard Against Host Image Builds + working-directory: ${{ github.workspace }}/playbooks run: | set -euo pipefail - image_ref="${{ needs.build.outputs.service_image_ref }}" - echo "value=${image_ref##*:}" >> "$GITHUB_OUTPUT" + + if rg -n \ + deploy_accounts_svc_plus.yml \ + roles/vhosts/accounts_service \ + '(docker build|podman build|docker buildx build|gcloud builds submit)' \ + ; then + echo "deploy flow must use the build job image artifact and must not build images on the target host" >&2 + exit 1 + fi - name: Set Up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.0.0 @@ -181,8 +217,9 @@ jobs: - name: Run Accounts Deploy Playbook working-directory: ${{ github.workspace }}/playbooks env: - ACCOUNTS_IMAGE_REPO: ${{ needs.build.outputs.service_image_repo }} - ACCOUNTS_IMAGE_TAG: ${{ steps.deploy_image_tag.outputs.value }} + ACCOUNTS_IMAGE_REF: ${{ steps.deploy_image.outputs.image_ref }} + ACCOUNTS_IMAGE_REPO: ${{ steps.deploy_image.outputs.image_repo }} + ACCOUNTS_IMAGE_TAG: ${{ steps.deploy_image.outputs.image_tag }} ACCOUNTS_PULL_IMAGE: "true" run: | set -euo pipefail diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..af9c6dd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# Repository Agent Guide + +Default local skill references for this repository: + +- Release traceability: [skills/release-traceability/SKILL.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/accounts.svc.plus/skills/release-traceability/SKILL.md) + +## Default Rule + +For any change related to CI/CD, image tags, deployment contracts, release validation, or runtime version reporting, follow the release traceability skill above as the default repository policy. + +## What This Means + +- Treat `service_image_ref` as the single source of truth for release identity. +- Keep commit traceability tied to full `GITHUB_SHA`. +- Ensure deploy uses prebuilt images rather than building on the target host. +- Ensure `/api/ping` and validate both report and verify the same runtime image-derived version data. diff --git a/api/api.go b/api/api.go index f9e82e1..c763091 100644 --- a/api/api.go +++ b/api/api.go @@ -3080,6 +3080,9 @@ func parseImageVersionInfo(imageRef string) imageVersionInfo { case isHexCommit(tag): info.Commit = tag info.Version = tag + case strings.HasPrefix(tag, "sha-") && isHexCommit(strings.TrimPrefix(tag, "sha-")): + info.Commit = strings.TrimPrefix(tag, "sha-") + info.Version = tag case strings.HasPrefix(tag, "v") && len(tag) > 1: info.Version = tag default: diff --git a/api/api_test.go b/api/api_test.go index a108488..8aa93d5 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -1527,6 +1527,39 @@ func TestPingEndpointDerivesVersionFromImageEnv(t *testing.T) { } } +func TestPingEndpointDerivesCommitFromShaPrefixedImageTag(t *testing.T) { + t.Setenv("IMAGE", "ghcr.io/example/accounts:sha-abcdef1234567890abcdef1234567890abcdef12") + gin.SetMode(gin.TestMode) + + router := gin.New() + RegisterRoutes(router) + + req := httptest.NewRequest(http.MethodGet, "/api/ping", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected ping endpoint to return 200, got %d", rr.Code) + } + + var resp map[string]string + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode ping response: %v", err) + } + if got := resp["image"]; got != "ghcr.io/example/accounts:sha-abcdef1234567890abcdef1234567890abcdef12" { + t.Fatalf("expected image ref from env, got %q", got) + } + if got := resp["tag"]; got != "sha-abcdef1234567890abcdef1234567890abcdef12" { + t.Fatalf("expected tag derived from image ref, got %q", got) + } + if got := resp["commit"]; got != "abcdef1234567890abcdef1234567890abcdef12" { + t.Fatalf("expected commit derived from sha-prefixed image ref, got %q", got) + } + if got := resp["version"]; got != "sha-abcdef1234567890abcdef1234567890abcdef12" { + t.Fatalf("expected version derived from image ref, got %q", got) + } +} + func TestPasswordResetFlow(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/scripts/github-actions/validate-deploy.sh b/scripts/github-actions/validate-deploy.sh index 128ddd1..dada9b7 100644 --- a/scripts/github-actions/validate-deploy.sh +++ b/scripts/github-actions/validate-deploy.sh @@ -21,6 +21,8 @@ version="${tag}" if [[ "${tag}" =~ ^[0-9a-f]{7,40}$ ]]; then commit="${tag}" +elif [[ "${tag}" =~ ^sha-([0-9a-f]{7,40})$ ]]; then + commit="${BASH_REMATCH[1]}" fi ping_json="$( diff --git a/skills/release-traceability/SKILL.md b/skills/release-traceability/SKILL.md new file mode 100644 index 0000000..44d4131 --- /dev/null +++ b/skills/release-traceability/SKILL.md @@ -0,0 +1,98 @@ +--- +name: release-traceability +description: Use when changing CI/CD, image tagging, deployment inputs, runtime version reporting, or validate logic for accounts.svc.plus. Enforces a single-source-of-truth release chain from GITHUB_SHA-based image build output through deploy and /api/ping validation. +license: Internal use only +metadata: + owner: cloud-neutral-toolkit + distribution: local-repo + package-format: .skill +--- + +# Release Traceability + +Use this skill for any work that touches: + +- `.github/workflows/pipeline.yml` +- image tag generation +- deploy inputs and playbook contracts +- `/api/ping` version reporting +- `scripts/github-actions/validate-deploy.sh` +- cross-repo release handoff into deployment automation + +## Goal + +Preserve a single, traceable release chain so every delivery can answer: + +- Which commit was built? +- Which image was deployed? +- Does the running service report that exact image and commit? + +## Canonical Contract + +1. `GITHUB_SHA` is the only commit source for release identity. +2. Build image tags must use the full commit SHA, typically `sha-<40 hex>`. +3. `service_image_ref` is the only authoritative build artifact identifier. +4. Deploy must consume `service_image_ref` directly. +5. Any `repo` / `tag` values used during deploy must be derived from `service_image_ref`, never provided independently. +6. The running container must receive `IMAGE=`. +7. `/api/ping` must derive `image`, `tag`, `commit`, and `version` from `IMAGE`. +8. Validate must accept only `image_ref` and compare remote output against values parsed from it. + +## Build Rules + +- Generate tags only from full `GITHUB_SHA`. +- Do not introduce manual `image_tag`, `commit_id`, `version_tag`, or similar override inputs. +- Prefer one build output: + - `service_image_ref` +- Optional outputs such as `service_image_tag` and `service_image_commit` are allowed only if parsed from `service_image_ref`. +- `latest` may exist as an auxiliary tag, but it must never be used as the authoritative release identifier. + +## Deploy Rules + +- Deploy must use the build job output, not a separately selected image. +- Default deploy flow must pull and run a prebuilt image. +- Do not add or preserve target-host build behavior: + - `docker build` + - `podman build` + - `docker buildx build` + - `gcloud builds submit` +- If an external repo or playbook is part of the release path, it must also accept `IMAGE_REF` / `ACCOUNTS_IMAGE_REF` and treat it as the single source of truth. +- If legacy deploy systems still need `repo` and `tag`, derive them in the deploy job from `service_image_ref`. + +## Runtime Rules + +- `/api/ping` must not trust a separate `COMMIT_ID` environment variable. +- `commit` must come from parsing the image tag in `IMAGE`. +- Support both: + - raw hex tags like `<40 hex>` + - SHA-prefixed tags like `sha-<40 hex>` +- If `IMAGE` is missing, return diagnostically useful empty values rather than inventing a commit. + +## Validate Rules + +- `scripts/github-actions/validate-deploy.sh` must accept only: + - `image_ref` + - optional base URL +- The script must parse `tag`, `commit`, and `version` from `image_ref`. +- Minimum required checks: + - remote `image == image_ref` + - remote `tag == parsed tag` + - remote `commit == parsed commit` + - remote `version == parsed version` + +## Required Checks Before Completion + +- Confirm the workflow still emits a full-SHA `service_image_ref`. +- Confirm deploy consumes that artifact and does not re-select the image. +- Confirm the runtime container receives `IMAGE=`. +- Confirm `/api/ping` reports values consistent with `service_image_ref`. +- Confirm validate compares against values parsed from the same `service_image_ref`. +- Confirm no target-host image build commands were introduced into the default deploy path. + +## Anti-Patterns + +- Passing `commit_id` separately into deploy or validate +- Letting deploy choose a different image than build produced +- Using `latest` as the release truth source +- Rebuilding images on the target host +- Returning a commit from any source other than the runtime `IMAGE`