feat: enforce traceable release chain

This commit is contained in:
Haitao Pan 2026-04-12 14:17:51 +08:00
parent a757cfcb23
commit 6e560c61e3
6 changed files with 196 additions and 7 deletions

View File

@ -79,6 +79,8 @@ jobs:
SERVICE_IMAGE_NAME: accounts SERVICE_IMAGE_NAME: accounts
outputs: outputs:
service_image_repo: ${{ steps.service_image.outputs.repo }} 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 }} service_image_ref: ${{ steps.service_ref.outputs.image_ref }}
steps: steps:
- name: Check Out Repository - name: Check Out Repository
@ -116,6 +118,14 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
image_ref="$(bash .github/scripts/utils/preferred-image-ref.sh "${{ steps.service_meta.outputs.tags }}")" 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" echo "image_ref=${image_ref}" >> "$GITHUB_OUTPUT"
- name: Build And Push Service Image - name: Build And Push Service Image
@ -136,7 +146,7 @@ jobs:
if: ${{ needs.prep.outputs.push_image == 'true' }} if: ${{ needs.prep.outputs.push_image == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
image: ${{ needs.build.outputs.service_image_repo }} image: ${{ needs.build.outputs.service_image_ref }}
image_ref: ${{ needs.build.outputs.service_image_ref }} image_ref: ${{ needs.build.outputs.service_image_ref }}
run_apply: ${{ needs.prep.outputs.run_apply }} run_apply: ${{ needs.prep.outputs.run_apply }}
pushed: "true" pushed: "true"
@ -144,6 +154,25 @@ jobs:
- name: Check Out Repository - name: Check Out Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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 - name: Check Out Playbooks Repository
# Pull latest playbooks HEAD from the default branch. # Pull latest playbooks HEAD from the default branch.
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -152,12 +181,19 @@ jobs:
token: ${{ secrets.WORKSPACE_REPO_TOKEN || github.token }} token: ${{ secrets.WORKSPACE_REPO_TOKEN || github.token }}
path: playbooks path: playbooks
- name: Resolve Deploy Image Tag - name: Guard Against Host Image Builds
id: deploy_image_tag working-directory: ${{ github.workspace }}/playbooks
run: | run: |
set -euo pipefail 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 - name: Set Up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.0.0 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.0.0
@ -181,8 +217,9 @@ jobs:
- name: Run Accounts Deploy Playbook - name: Run Accounts Deploy Playbook
working-directory: ${{ github.workspace }}/playbooks working-directory: ${{ github.workspace }}/playbooks
env: env:
ACCOUNTS_IMAGE_REPO: ${{ needs.build.outputs.service_image_repo }} ACCOUNTS_IMAGE_REF: ${{ steps.deploy_image.outputs.image_ref }}
ACCOUNTS_IMAGE_TAG: ${{ steps.deploy_image_tag.outputs.value }} ACCOUNTS_IMAGE_REPO: ${{ steps.deploy_image.outputs.image_repo }}
ACCOUNTS_IMAGE_TAG: ${{ steps.deploy_image.outputs.image_tag }}
ACCOUNTS_PULL_IMAGE: "true" ACCOUNTS_PULL_IMAGE: "true"
run: | run: |
set -euo pipefail set -euo pipefail

16
AGENTS.md Normal file
View File

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

View File

@ -3080,6 +3080,9 @@ func parseImageVersionInfo(imageRef string) imageVersionInfo {
case isHexCommit(tag): case isHexCommit(tag):
info.Commit = tag info.Commit = tag
info.Version = 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: case strings.HasPrefix(tag, "v") && len(tag) > 1:
info.Version = tag info.Version = tag
default: default:

View File

@ -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) { func TestPasswordResetFlow(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)

View File

@ -21,6 +21,8 @@ version="${tag}"
if [[ "${tag}" =~ ^[0-9a-f]{7,40}$ ]]; then if [[ "${tag}" =~ ^[0-9a-f]{7,40}$ ]]; then
commit="${tag}" commit="${tag}"
elif [[ "${tag}" =~ ^sha-([0-9a-f]{7,40})$ ]]; then
commit="${BASH_REMATCH[1]}"
fi fi
ping_json="$( ping_json="$(

View File

@ -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=<full image_ref>`.
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=<service_image_ref>`.
- 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`