From 7fb3713716126ee4eea4bb4fa6b977211e11a262 Mon Sep 17 00:00:00 2001 From: Blair Hamilton Date: Sun, 5 Jul 2026 10:03:37 -0400 Subject: [PATCH 1/4] feat(stevedore-release): add draft reusable stevedore release workflow Reusable image build/release via stevedore v0.0.3. The caller passes no service matrix: stevedore reads the repo's .stevedore.yaml, resolves per-service ECR versions, does change detection against per-image refs/releases/image/ marker refs (the same ref family docker-release.yml advances; contents: write so it can push them), and groups identical build specs into a single build. Draft: defaults to --no-push; remaining gap vs docker-release.yml is the Dispatch publish notification. Co-Authored-By: Claude Fable 5 --- .github/workflows/stevedore-release.yml | 117 ++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 .github/workflows/stevedore-release.yml diff --git a/.github/workflows/stevedore-release.yml b/.github/workflows/stevedore-release.yml new file mode 100644 index 0000000..97cec51 --- /dev/null +++ b/.github/workflows/stevedore-release.yml @@ -0,0 +1,117 @@ +name: Stevedore Release + +# Reusable image build/release via stevedore (https://github.com/blairham/stevedore). +# +# Unlike docker-release.yml, the CALLER PASSES NO SERVICE MATRIX. stevedore reads +# the repo's .stevedore.yaml, resolves per-service ECR versions itself +# (`aws ecr describe-images`, highest X.Y.Z + bump), does change detection +# (--changed-since or the config's change_detection), and builds — in parallel — +# only the images whose sources changed. +# +# Draft for evaluation. stevedore v0.0.3 closes the marker-ref gap: with +# `change_detection.marker_refs: true` in a repo's .stevedore.yaml it advances +# and pushes `refs/releases/image/` after each successful publish (the same +# ref family docker-release.yml uses — image ids match service names). It also +# groups identical build specs and builds them once (build-once-tag-many). +# Remaining gap vs docker-release.yml: no Dispatch publish notification yet. +# Use --no-push (default) until that is wired, or keep docker-release.yml for +# real publishes. + +on: + workflow_call: + inputs: + command: + description: "stevedore subcommand (release, build, check)." + required: false + type: string + default: "release" + changed-since: + description: "git ref to diff against for change detection (e.g. the PR base or the pushed-before SHA)." + required: false + type: string + default: "" + no-push: + description: "Build without pushing (validation only). Default true while this is a draft." + required: false + type: boolean + default: true + parallel: + description: "Build up to N images concurrently." + required: false + type: number + default: 4 + stevedore-version: + description: "stevedore release to use." + required: false + type: string + default: "v0.0.3" + +env: + AWS_REGION: us-east-1 + +concurrency: + group: stevedore-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + stevedore: + runs-on: ubuntu-latest + permissions: + contents: write # push refs/releases/image/* marker refs on publish + id-token: write # AWS OIDC (ECR) + cosign keyless signing + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # tags + history for versioning and change detection + + - name: Configure AWS + uses: pinpredict/.github/actions/configure-aws-with-retry@main + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Log in to Amazon ECR + id: ecr-login + uses: aws-actions/amazon-ecr-login@v2 + + - name: Compose args + id: args + env: + NO_PUSH: ${{ inputs.no-push }} + PARALLEL: ${{ inputs.parallel }} + CHANGED_SINCE: ${{ inputs.changed-since }} + run: | + set -euo pipefail + args="--parallel ${PARALLEL}" + [ "${NO_PUSH}" = "true" ] && args="${args} --no-push" + [ -n "${CHANGED_SINCE}" ] && args="${args} --changed-since ${CHANGED_SINCE}" + echo "value=${args}" >> "$GITHUB_OUTPUT" + + - name: Stevedore + # uses: refs cannot be expressions — pin the action; the `version` + # input below selects the binary that actually runs. + uses: blairham/stevedore@v0.0.3 + env: + # ECR host, e.g. 123456789.dkr.ecr.us-east-1.amazonaws.com — the + # .stevedore.yaml repositories template this as {{ .Env.REGISTRY }}. + REGISTRY: ${{ steps.ecr-login.outputs.registry }} + with: + command: ${{ inputs.command }} + version: ${{ inputs.stevedore-version }} + args: ${{ steps.args.outputs.value }} + # cosign/syft/grype install is skipped — enable per config once + # sign/sbom/scan are turned on in .stevedore.yaml. + install-cosign: "false" + install-syft: "false" + install-grype: "false" + +# ── How a repo calls it ────────────────────────────────────────────────────── +# +# jobs: +# images: +# permissions: { contents: read, id-token: write } +# uses: pinpredict/.github/.github/workflows/stevedore-release.yml@main +# with: +# changed-since: ${{ github.event.before }} # or the PR base SHA +# no-push: true # flip to false to publish +# secrets: inherit From 2d750e64527b85db4b2173661ca8aa7888955e60 Mon Sep 17 00:00:00 2001 From: Blair Hamilton Date: Sun, 5 Jul 2026 16:36:58 -0400 Subject: [PATCH 2/4] feat(stevedore-release): wire gha layer cache + bump stevedore v0.0.4 - Export STEVEDORE_CACHE_FROM/STEVEDORE_CACHE_TO (one type=gha scope per calling repo) and re-export the GitHub runtime env via crazy-max/ghaction-github-runtime so stevedore's shelled-out buildx can reach the gha cache backend. Configs opt in per image with '{{ index .Env "STEVEDORE_CACHE_FROM" }}' entries that render empty outside CI (skipped by stevedore >= v0.0.5, blairham/stevedore#8). - Bump the action pin + default binary to v0.0.4 (plan/matrix mode). - Document the single-job-by-design decision (pinpredict/.github#39): one runner + one BuildKit means a build-once Dockerfile (trading#1967) compiles once for all images, even with --parallel; a per-image matrix fan-out would cold-cache every runner and re-run the shared stage N times. Still a draft (no-push defaults true): the remaining publish gap is the Dispatch notification (blairham/stevedore#6). Co-Authored-By: Claude Fable 5 --- .github/workflows/stevedore-release.yml | 43 +++++++++++++++++++++---- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/.github/workflows/stevedore-release.yml b/.github/workflows/stevedore-release.yml index 97cec51..501a212 100644 --- a/.github/workflows/stevedore-release.yml +++ b/.github/workflows/stevedore-release.yml @@ -8,14 +8,28 @@ name: Stevedore Release # (--changed-since or the config's change_detection), and builds — in parallel — # only the images whose sources changed. # -# Draft for evaluation. stevedore v0.0.3 closes the marker-ref gap: with +# Draft for evaluation. stevedore >= v0.0.3 closes the marker-ref gap: with # `change_detection.marker_refs: true` in a repo's .stevedore.yaml it advances # and pushes `refs/releases/image/` after each successful publish (the same # ref family docker-release.yml uses — image ids match service names). It also # groups identical build specs and builds them once (build-once-tag-many). -# Remaining gap vs docker-release.yml: no Dispatch publish notification yet. -# Use --no-push (default) until that is wired, or keep docker-release.yml for -# real publishes. +# Remaining gap vs docker-release.yml: no Dispatch publish notification yet +# (blairham/stevedore#6). Use --no-push (default) until that is wired, or keep +# docker-release.yml for real publishes. +# +# SINGLE JOB BY DESIGN (pinpredict/.github#39): all of a repo's images build on +# one runner sharing one BuildKit instance, so a build-once Dockerfile (e.g. +# trading#1967 — a shared compile stage with no per-image build args) compiles +# ONCE and every image reuses it, even with --parallel (BuildKit dedups +# identical in-flight stages). Fanning out per image (stevedore plan → matrix) +# would give every image a cold cache and re-run the shared stage per runner — +# only adopt matrix mode for repos whose images have nothing in common. +# +# Layer cache: exports STEVEDORE_CACHE_FROM/STEVEDORE_CACHE_TO (one type=gha +# scope per calling repo — GHA cache is repo-scoped already). Configs opt in +# per image with '{{ index .Env "STEVEDORE_CACHE_FROM" }}' entries, which +# render empty (and are skipped, stevedore >= v0.0.5) outside CI so local +# builds are unaffected. on: workflow_call: @@ -44,7 +58,11 @@ on: description: "stevedore release to use." required: false type: string - default: "v0.0.3" + # v0.0.4 = plan/matrix mode + --only/--pin-version. Bump to v0.0.5 + # when blairham/stevedore#8 (skip empty cache entries) is released — + # required only for LOCAL builds of cache-wired configs; in CI the + # cache env below renders the entries non-empty either way. + default: "v0.0.4" env: AWS_REGION: us-east-1 @@ -87,14 +105,27 @@ jobs: [ -n "${CHANGED_SINCE}" ] && args="${args} --changed-since ${CHANGED_SINCE}" echo "value=${args}" >> "$GITHUB_OUTPUT" + # buildx's type=gha cache backend reads ACTIONS_CACHE_URL / + # ACTIONS_RUNTIME_TOKEN, which GitHub only hands to actions — this step + # re-exports them to the job env so stevedore's shelled-out buildx sees + # them. + - name: Expose GitHub runtime for buildx gha cache + uses: crazy-max/ghaction-github-runtime@v3 + - name: Stevedore # uses: refs cannot be expressions — pin the action; the `version` # input below selects the binary that actually runs. - uses: blairham/stevedore@v0.0.3 + uses: blairham/stevedore@v0.0.4 env: # ECR host, e.g. 123456789.dkr.ecr.us-east-1.amazonaws.com — the # .stevedore.yaml repositories template this as {{ .Env.REGISTRY }}. REGISTRY: ${{ steps.ecr-login.outputs.registry }} + # One layer-cache scope per calling repo (GHA cache is already + # repo-scoped). Configs opt in per image via + # '{{ index .Env "STEVEDORE_CACHE_FROM" }}' — repos without cache + # entries simply ignore these. + STEVEDORE_CACHE_FROM: "type=gha,scope=stevedore" + STEVEDORE_CACHE_TO: "type=gha,mode=max,scope=stevedore" with: command: ${{ inputs.command }} version: ${{ inputs.stevedore-version }} From bb4a2accee43b275532849dc8c29d0ed92a000d9 Mon Sep 17 00:00:00 2001 From: Blair Hamilton Date: Sun, 5 Jul 2026 16:45:21 -0400 Subject: [PATCH 3/4] feat(stevedore-release): bump stevedore to v0.0.5 blairham/stevedore#8 merged and released: empty-rendering cache_from/cache_to entries are skipped, so cache-wired configs (pinpredict/trading#1969) build locally without the CI cache env. Co-Authored-By: Claude Fable 5 --- .github/workflows/stevedore-release.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/stevedore-release.yml b/.github/workflows/stevedore-release.yml index 501a212..671c0bb 100644 --- a/.github/workflows/stevedore-release.yml +++ b/.github/workflows/stevedore-release.yml @@ -58,11 +58,11 @@ on: description: "stevedore release to use." required: false type: string - # v0.0.4 = plan/matrix mode + --only/--pin-version. Bump to v0.0.5 - # when blairham/stevedore#8 (skip empty cache entries) is released — - # required only for LOCAL builds of cache-wired configs; in CI the - # cache env below renders the entries non-empty either way. - default: "v0.0.4" + # v0.0.5 = plan/matrix mode (v0.0.4) + skip-empty cache entries + # (blairham/stevedore#8), which keeps LOCAL builds of cache-wired + # configs working; in CI the cache env below renders the entries + # non-empty either way. + default: "v0.0.5" env: AWS_REGION: us-east-1 @@ -115,7 +115,7 @@ jobs: - name: Stevedore # uses: refs cannot be expressions — pin the action; the `version` # input below selects the binary that actually runs. - uses: blairham/stevedore@v0.0.4 + uses: blairham/stevedore@v0.0.5 env: # ECR host, e.g. 123456789.dkr.ecr.us-east-1.amazonaws.com — the # .stevedore.yaml repositories template this as {{ .Env.REGISTRY }}. From abad819e02962c75ea9e0f5248b5dea80e3e0cf8 Mon Sep 17 00:00:00 2001 From: Blair Hamilton Date: Sun, 5 Jul 2026 16:55:20 -0400 Subject: [PATCH 4/4] feat(docker-release)!: replace the matrix implementation with stevedore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matrix implementation lives on the frozen pre-stevedore tag that every existing caller is pinned to — this replacement breaks the workflow_call contract (no matrix input) for @main only, which is exactly what the pin was minted for. Repos migrate by adding a .stevedore.yaml, dropping the matrix wiring from ci.yml, and pointing uses: back to @main. Carries the draft stevedore workflow's shape: single job by design (#39 — one BuildKit instance so a build-once Dockerfile compiles once for all images, even with --parallel), gha layer cache env (STEVEDORE_CACHE_FROM/TO, one scope per repo, opt-in per config via index-form templates skipped when empty by stevedore >= v0.0.5), and no-push defaulting true until the Dispatch notification gap (blairham/stevedore#6) closes. README + AGENTS.md updated: consumer contract, migration steps, the pre-stevedore exception wording, and the statements that only apply to the frozen matrix implementation (per-service push roles, notify-dispatch, matrix-shape playbook). Co-Authored-By: Claude Fable 5 --- .github/workflows/docker-release.yml | 311 +++++++++--------------- .github/workflows/stevedore-release.yml | 148 ----------- AGENTS.md | 6 +- README.md | 11 +- 4 files changed, 126 insertions(+), 350 deletions(-) delete mode 100644 .github/workflows/stevedore-release.yml diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 8e6c7e3..b4b7f96 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -1,52 +1,73 @@ name: Docker Release -# Reusable matrix-based image build/push. +# Reusable image build/release via stevedore (https://github.com/blairham/stevedore). # -# The caller owns the service catalog. It passes `matrix` as a JSON string -# in the GitHub Actions matrix include shape: +# This REPLACED the matrix-based implementation on main — that snapshot lives +# on the frozen `pre-stevedore` tag, which every existing caller is pinned to. +# The pin comes off caller-by-caller as each repo adds a `.stevedore.yaml` and +# switches its `uses:` back to @main; the tag is deleted when the migration +# completes (see README "How to use"). # -# { -# "include": [ -# { -# "name": "trader-tools", # required: ECR repo basename + tag service segment -# "ecr_repo": "trader-tools", # required: full ECR repo path (e.g. "trading/oms") -# "dockerfile": "Dockerfile", # optional: defaults to "Dockerfile" -# "target": "production", # optional: docker --target value -# "build_args": "KEY=VAL\nK2=V2" # optional: newline-separated --build-arg entries -# } -# ] -# } +# Unlike the matrix implementation, the CALLER PASSES NO SERVICE MATRIX. +# stevedore reads the repo's .stevedore.yaml, resolves per-service ECR versions +# itself (`aws ecr describe-images`, highest X.Y.Z + bump — ECR stays the +# version record, platform-gitops#1201), does change detection against the +# `refs/releases/image/` marker refs (the same family the matrix +# implementation advanced — it fetches and re-pushes them itself), and builds +# only the images whose sources changed. No git tags or GitHub Releases are +# minted, same as before. # -# Version resolution reads ECR (highest X.Y.Z tag + 1) — the artifact -# store is the version record (platform-gitops#1201). Each successful -# build pushes the image, advances the `refs/releases/image/` -# marker ref (the change-detection baseline for discover-services), -# and notifies Dispatch directly (signed POST via notify-dispatch; -# the EventBridge ECR-push backstop covers a missed call). No git tags -# or GitHub Releases are minted — the historical `image//X.Y.Z` -# tags remain in repos as the frozen fallback baseline only. +# SINGLE JOB BY DESIGN (pinpredict/.github#39): all of a repo's images build on +# one runner sharing one BuildKit instance, so a build-once Dockerfile (e.g. +# trading#1967 — a shared compile stage with no per-image build args) compiles +# ONCE and every image reuses it, even with --parallel (BuildKit dedups +# identical in-flight stages). Fanning out per image (stevedore plan → matrix) +# would give every image a cold cache and re-run the shared stage per runner — +# only adopt matrix mode for repos whose images have nothing in common. +# +# Layer cache: exports STEVEDORE_CACHE_FROM/STEVEDORE_CACHE_TO (one type=gha +# scope per calling repo — GHA cache is repo-scoped already). Configs opt in +# per image with '{{ index .Env "STEVEDORE_CACHE_FROM" }}' entries, which +# render empty (and are skipped, stevedore >= v0.0.5) outside CI so local +# builds are unaffected. +# +# Remaining gap vs the matrix implementation: no Dispatch publish notification +# yet (blairham/stevedore#6) — the EventBridge ECR-push backstop still covers +# deploy correlation. Callers should keep `no-push: true` (the default) or stay +# on the pre-stevedore pin for real publishes until that is wired. on: workflow_call: inputs: - matrix: - description: "JSON matrix of services to build (GitHub Actions matrix `include` shape)" - required: true + command: + description: "stevedore subcommand (release, build, check)." + required: false type: string - private-modules: - description: >- - Mint a short-lived, read-only token for the org-wide pinpredict-argocd - App (BOOTSTRAP_APP_ID / BOOTSTRAP_APP_PRIVATE_KEY) and expose it to the - build as BuildKit secret `id=gh_token`. Opt-in for services whose - Dockerfile fetches a private pinpredict module instead of vendoring it - (e.g. a Go repo that requires github.com/pinpredict/ppkit) via - `RUN --mount=type=secret,id=gh_token ... go mod download`. Handed to - buildx as a `--secret`, never a `--build-arg`, so it never lands in an - image layer or `docker history`. Default false — a no-op for every - existing caller. + default: "release" + changed-since: + description: "git ref to diff against for change detection (e.g. the PR base or the pushed-before SHA)." + required: false + type: string + default: "" + no-push: + description: "Build without pushing (validation only). Default true until the Dispatch notification gap closes." required: false type: boolean - default: false + default: true + parallel: + description: "Build up to N images concurrently." + required: false + type: number + default: 4 + stevedore-version: + description: "stevedore release to use." + required: false + type: string + # v0.0.5 = plan/matrix mode (v0.0.4) + skip-empty cache entries + # (blairham/stevedore#8), which keeps LOCAL builds of cache-wired + # configs working; in CI the cache env below renders the entries + # non-empty either way. + default: "v0.0.5" env: AWS_REGION: us-east-1 @@ -57,180 +78,80 @@ concurrency: cancel-in-progress: false jobs: - build: - if: ${{ fromJson(inputs.matrix).include[0] != null }} + stevedore: runs-on: ubuntu-latest permissions: - id-token: write - contents: write - strategy: - fail-fast: false - matrix: ${{ fromJson(inputs.matrix) }} + contents: write # push refs/releases/image/* marker refs on publish + id-token: write # AWS OIDC (ECR) + cosign keyless signing steps: - uses: actions/checkout@v6 with: - fetch-depth: 0 - fetch-tags: true + fetch-depth: 0 # history for versioning and change detection (stevedore fetches marker refs itself) - # Three-shot retry on the AWS AssumeRole to survive a - # first-colocation race with Crossplane minting the per-service - # xp--gha-push role (pinpredict/trading#616). Empty - # `matrix.role` for un-migrated services falls back to the central - # xp-trading-gha-push role in secrets.AWS_ROLE_ARN. See the action - # for the full incident writeup. - name: Configure AWS credentials uses: pinpredict/.github/actions/configure-aws-with-retry@main with: - role-to-assume: ${{ matrix.role || secrets.AWS_ROLE_ARN }} + # Single-job builds push every image under the central role. The + # per-service xp--gha-push roles are a matrix-mode feature + # (stevedore plan + release --only) — see the workflow header. + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} aws-region: ${{ env.AWS_REGION }} - name: Log in to Amazon ECR id: ecr-login uses: aws-actions/amazon-ecr-login@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - # Opt-in (private-modules: true): mint a short-lived, read-only token for - # the org-wide pinpredict-argocd App so the build can fetch private - # pinpredict modules without vendoring. contents:read across the org (the - # App is installed on all repos); consumed by buildx as a BuildKit - # `--secret` below — never a build-arg — so it never lands in an image - # layer or `docker history`. Skipped (and the secret omitted) for every - # caller that leaves private-modules at its default false. - - name: Mint private-module read token - id: private-module-token - if: ${{ inputs.private-modules }} - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.BOOTSTRAP_APP_ID }} - private-key: ${{ secrets.BOOTSTRAP_APP_PRIVATE_KEY }} - owner: pinpredict - permission-contents: read - - - name: Resolve version, build, push, tag - id: release + - name: Compose args + id: args env: - NAME: ${{ matrix.name }} - ECR_REPO: ${{ matrix.ecr_repo }} - DOCKERFILE: ${{ matrix.dockerfile || 'Dockerfile' }} - TARGET: ${{ matrix.target }} - BUILD_ARGS: ${{ matrix.build_args }} - ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }} - SHORT_SHA: ${{ github.sha }} - # Empty unless private-modules minted a token above; gates the - # BuildKit --secret so it's a no-op for every other caller. - GH_PRIVATE_TOKEN: ${{ steps.private-module-token.outputs.token }} + NO_PUSH: ${{ inputs.no-push }} + PARALLEL: ${{ inputs.parallel }} + CHANGED_SINCE: ${{ inputs.changed-since }} run: | set -euo pipefail - SHORT_SHA="${SHORT_SHA:0:7}" - - # The ECR repo is the version record: next version is a patch - # bump of the highest X.Y.Z tag already pushed (the - - # sibling tags are filtered out). A missing repo or an empty - # tag list both mean first release. - latest=$(aws ecr describe-images \ - --repository-name "${ECR_REPO}" \ - --filter tagStatus=TAGGED \ - --query 'imageDetails[].imageTags[]' --output text 2>/dev/null \ - | tr '[:space:]' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \ - | sort -V | tail -n1 || true) - - if [ -z "$latest" ]; then - major=0; minor=0; patch=0 - else - IFS='.' read -r major minor patch <<< "$latest" - fi - patch=$((patch + 1)) - - # ECR tags are immutable — probe-and-bump past any candidate - # that already exists (covers races that slip the workflow - # concurrency group and repos with gaps above the max semver). - while aws ecr describe-images \ - --repository-name "${ECR_REPO}" \ - --image-ids imageTag="${major}.${minor}.${patch}" >/dev/null 2>&1; do - echo "Image ${ECR_REPO}:${major}.${minor}.${patch} exists in ECR — bumping" - patch=$((patch + 1)) - done - version="${major}.${minor}.${patch}" - - echo "Building ${ECR_REPO} v${version} from ${DOCKERFILE}${TARGET:+ (target=${TARGET})}" - - buildx_args=( - --platform linux/amd64 - --push - --provenance=false - --cache-from "type=gha,scope=${NAME}" - --cache-to "type=gha,mode=max,scope=${NAME}" - -t "${ECR_REGISTRY}/${ECR_REPO}:${version}" - -t "${ECR_REGISTRY}/${ECR_REPO}:${version}-${SHORT_SHA}" - -f "${DOCKERFILE}" - # Stamp the resolved version into the build so images can - # surface what they actually are at runtime (e.g. Vite-baked - # `VITE_APP_VERSION` for SPA bundles, /api/version fallback - # for backend services). Dockerfiles that don't declare - # `ARG APP_VERSION` silently ignore it. - --build-arg "APP_VERSION=${version}" - # Stamp the git short-SHA too. Unlike the per-repo `0.0.X` - # version (a build-ID), the commit is identical iff the code is - # identical — the reliable "is this the same build?" signal for - # services on parallel lanes (e.g. trader-tools stable vs dev). - # Dockerfiles that don't declare `ARG GIT_SHA` silently ignore it. - --build-arg "GIT_SHA=${SHORT_SHA}" - ) - if [ -n "${TARGET}" ]; then - buildx_args+=(--target "${TARGET}") - fi - if [ -n "${BUILD_ARGS}" ]; then - while IFS= read -r ba; do - [ -z "$ba" ] && continue - buildx_args+=(--build-arg "$ba") - done <<< "${BUILD_ARGS}" - fi - - # Private-module fetch (inputs.private-modules): hand the minted App - # token to the build as a BuildKit secret, readable inside the - # Dockerfile at /run/secrets/gh_token via - # `RUN --mount=type=secret,id=gh_token`. Only added when the token - # step ran (env non-empty) — a no-op for every other caller. - if [ -n "${GH_PRIVATE_TOKEN:-}" ]; then - buildx_args+=(--secret "id=gh_token,env=GH_PRIVATE_TOKEN") - fi - - docker buildx build "${buildx_args[@]}" . - - # Advance the per-service release marker — one mutable ref, - # not a tag, so it is invisible to Kargo's tag enumeration and - # the GitHub create-webhook. discover-services diffs against - # it to decide whether the service needs a rebuild. - git push --force origin "${GITHUB_SHA}:refs/releases/image/${NAME}" \ - || echo "::warning::Marker ref push failed for refs/releases/image/${NAME} — next run falls back to the legacy tag baseline" - - echo "version=${version}" >> "$GITHUB_OUTPUT" - - # The publish signal (platform-gitops#1201). Never fails the - # release; the EventBridge ECR-push backstop covers a missed - # notification. - - name: Notify Dispatch - if: steps.release.outcome == 'success' - uses: pinpredict/.github/actions/notify-dispatch@main + args="--parallel ${PARALLEL}" + [ "${NO_PUSH}" = "true" ] && args="${args} --no-push" + [ -n "${CHANGED_SINCE}" ] && args="${args} --changed-since ${CHANGED_SINCE}" + echo "value=${args}" >> "$GITHUB_OUTPUT" + + # buildx's type=gha cache backend reads ACTIONS_CACHE_URL / + # ACTIONS_RUNTIME_TOKEN, which GitHub only hands to actions — this step + # re-exports them to the job env so stevedore's shelled-out buildx sees + # them. + - name: Expose GitHub runtime for buildx gha cache + uses: crazy-max/ghaction-github-runtime@v3 + + - name: Stevedore + # uses: refs cannot be expressions — pin the action; the `version` + # input below selects the binary that actually runs. + uses: blairham/stevedore@v0.0.5 + env: + # ECR host, e.g. 123456789.dkr.ecr.us-east-1.amazonaws.com — the + # .stevedore.yaml repositories template this as {{ .Env.REGISTRY }}. + REGISTRY: ${{ steps.ecr-login.outputs.registry }} + # One layer-cache scope per calling repo (GHA cache is already + # repo-scoped). Configs opt in per image via + # '{{ index .Env "STEVEDORE_CACHE_FROM" }}' — repos without cache + # entries simply ignore these. + STEVEDORE_CACHE_FROM: "type=gha,scope=stevedore" + STEVEDORE_CACHE_TO: "type=gha,mode=max,scope=stevedore" with: - kind: image - service: ${{ matrix.name }} - version: ${{ steps.release.outputs.version }} - secret: ${{ secrets.CI_WEBHOOK_SECRET }} - - # Kept as the aggregate fail-gate (stable check name for branch - # protection). The GitHub release it used to create was keyed off the - # legacy image/* git tag; Dispatch's Slack cards carry the per-service - # changelog now (platform-gitops#1201). - release-summary: - needs: build - if: ${{ !cancelled() && needs.build.result != 'skipped' }} - runs-on: ubuntu-latest - steps: - - name: Fail if any build failed - if: needs.build.result != 'success' - run: | - echo "::error::One or more service builds failed (build job result: ${{ needs.build.result }})" - exit 1 + command: ${{ inputs.command }} + version: ${{ inputs.stevedore-version }} + args: ${{ steps.args.outputs.value }} + # cosign/syft/grype install is skipped — enable per config once + # sign/sbom/scan are turned on in .stevedore.yaml. + install-cosign: "false" + install-syft: "false" + install-grype: "false" + +# ── How a repo calls it ────────────────────────────────────────────────────── +# +# jobs: +# images: +# permissions: { contents: write, id-token: write } +# uses: pinpredict/.github/.github/workflows/docker-release.yml@main +# with: +# changed-since: ${{ github.event.before }} # or the PR base SHA +# no-push: true # flip to false to publish +# secrets: inherit diff --git a/.github/workflows/stevedore-release.yml b/.github/workflows/stevedore-release.yml deleted file mode 100644 index 671c0bb..0000000 --- a/.github/workflows/stevedore-release.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: Stevedore Release - -# Reusable image build/release via stevedore (https://github.com/blairham/stevedore). -# -# Unlike docker-release.yml, the CALLER PASSES NO SERVICE MATRIX. stevedore reads -# the repo's .stevedore.yaml, resolves per-service ECR versions itself -# (`aws ecr describe-images`, highest X.Y.Z + bump), does change detection -# (--changed-since or the config's change_detection), and builds — in parallel — -# only the images whose sources changed. -# -# Draft for evaluation. stevedore >= v0.0.3 closes the marker-ref gap: with -# `change_detection.marker_refs: true` in a repo's .stevedore.yaml it advances -# and pushes `refs/releases/image/` after each successful publish (the same -# ref family docker-release.yml uses — image ids match service names). It also -# groups identical build specs and builds them once (build-once-tag-many). -# Remaining gap vs docker-release.yml: no Dispatch publish notification yet -# (blairham/stevedore#6). Use --no-push (default) until that is wired, or keep -# docker-release.yml for real publishes. -# -# SINGLE JOB BY DESIGN (pinpredict/.github#39): all of a repo's images build on -# one runner sharing one BuildKit instance, so a build-once Dockerfile (e.g. -# trading#1967 — a shared compile stage with no per-image build args) compiles -# ONCE and every image reuses it, even with --parallel (BuildKit dedups -# identical in-flight stages). Fanning out per image (stevedore plan → matrix) -# would give every image a cold cache and re-run the shared stage per runner — -# only adopt matrix mode for repos whose images have nothing in common. -# -# Layer cache: exports STEVEDORE_CACHE_FROM/STEVEDORE_CACHE_TO (one type=gha -# scope per calling repo — GHA cache is repo-scoped already). Configs opt in -# per image with '{{ index .Env "STEVEDORE_CACHE_FROM" }}' entries, which -# render empty (and are skipped, stevedore >= v0.0.5) outside CI so local -# builds are unaffected. - -on: - workflow_call: - inputs: - command: - description: "stevedore subcommand (release, build, check)." - required: false - type: string - default: "release" - changed-since: - description: "git ref to diff against for change detection (e.g. the PR base or the pushed-before SHA)." - required: false - type: string - default: "" - no-push: - description: "Build without pushing (validation only). Default true while this is a draft." - required: false - type: boolean - default: true - parallel: - description: "Build up to N images concurrently." - required: false - type: number - default: 4 - stevedore-version: - description: "stevedore release to use." - required: false - type: string - # v0.0.5 = plan/matrix mode (v0.0.4) + skip-empty cache entries - # (blairham/stevedore#8), which keeps LOCAL builds of cache-wired - # configs working; in CI the cache env below renders the entries - # non-empty either way. - default: "v0.0.5" - -env: - AWS_REGION: us-east-1 - -concurrency: - group: stevedore-release-${{ github.ref }} - cancel-in-progress: false - -jobs: - stevedore: - runs-on: ubuntu-latest - permissions: - contents: write # push refs/releases/image/* marker refs on publish - id-token: write # AWS OIDC (ECR) + cosign keyless signing - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # tags + history for versioning and change detection - - - name: Configure AWS - uses: pinpredict/.github/actions/configure-aws-with-retry@main - with: - role-to-assume: ${{ secrets.AWS_ROLE_ARN }} - aws-region: ${{ env.AWS_REGION }} - - - name: Log in to Amazon ECR - id: ecr-login - uses: aws-actions/amazon-ecr-login@v2 - - - name: Compose args - id: args - env: - NO_PUSH: ${{ inputs.no-push }} - PARALLEL: ${{ inputs.parallel }} - CHANGED_SINCE: ${{ inputs.changed-since }} - run: | - set -euo pipefail - args="--parallel ${PARALLEL}" - [ "${NO_PUSH}" = "true" ] && args="${args} --no-push" - [ -n "${CHANGED_SINCE}" ] && args="${args} --changed-since ${CHANGED_SINCE}" - echo "value=${args}" >> "$GITHUB_OUTPUT" - - # buildx's type=gha cache backend reads ACTIONS_CACHE_URL / - # ACTIONS_RUNTIME_TOKEN, which GitHub only hands to actions — this step - # re-exports them to the job env so stevedore's shelled-out buildx sees - # them. - - name: Expose GitHub runtime for buildx gha cache - uses: crazy-max/ghaction-github-runtime@v3 - - - name: Stevedore - # uses: refs cannot be expressions — pin the action; the `version` - # input below selects the binary that actually runs. - uses: blairham/stevedore@v0.0.5 - env: - # ECR host, e.g. 123456789.dkr.ecr.us-east-1.amazonaws.com — the - # .stevedore.yaml repositories template this as {{ .Env.REGISTRY }}. - REGISTRY: ${{ steps.ecr-login.outputs.registry }} - # One layer-cache scope per calling repo (GHA cache is already - # repo-scoped). Configs opt in per image via - # '{{ index .Env "STEVEDORE_CACHE_FROM" }}' — repos without cache - # entries simply ignore these. - STEVEDORE_CACHE_FROM: "type=gha,scope=stevedore" - STEVEDORE_CACHE_TO: "type=gha,mode=max,scope=stevedore" - with: - command: ${{ inputs.command }} - version: ${{ inputs.stevedore-version }} - args: ${{ steps.args.outputs.value }} - # cosign/syft/grype install is skipped — enable per config once - # sign/sbom/scan are turned on in .stevedore.yaml. - install-cosign: "false" - install-syft: "false" - install-grype: "false" - -# ── How a repo calls it ────────────────────────────────────────────────────── -# -# jobs: -# images: -# permissions: { contents: read, id-token: write } -# uses: pinpredict/.github/.github/workflows/stevedore-release.yml@main -# with: -# changed-since: ${{ github.event.before }} # or the PR base SHA -# no-push: true # flip to false to publish -# secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index 1f06a8b..898ed7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Static lint: `actionlint.yml` runs on every PR that touches `.github/workflows/* ### Per-service IAM push role naming -Both `docker-release.yml` and `chart-release.yml` assume the deterministic role `arn:aws:iam::784682930591:role/xp--gha-push` (rendered by the Service XR composition in `platform-gitops`). If `matrix.role` is empty (un-migrated service/chart), the workflow falls back to `secrets.AWS_ROLE_ARN`. **Do not break this fallback** — some repos still rely on it. +`chart-release.yml` (and the frozen `docker-release.yml@pre-stevedore` that existing callers pin) assume the deterministic role `arn:aws:iam::784682930591:role/xp--gha-push` (rendered by the Service XR composition in `platform-gitops`). If `matrix.role` is empty (un-migrated service/chart), the workflow falls back to `secrets.AWS_ROLE_ARN`. **Do not break this fallback** — some repos still rely on it. The stevedore `docker-release.yml` on main builds all of a repo's images in one job under the central `secrets.AWS_ROLE_ARN`; per-service push roles are a stevedore matrix-mode feature (`plan` + `release --only`), deliberately not used for build-once repos (#39). Both workflows use a three-shot retry pattern (try / sleep 30 / retry / sleep 60 / retry) on `configure-aws-credentials` to survive the race where Crossplane is still creating the per-service role on first colocation. Incident reference: pinpredict/trading#616 (2-second race). Keep the retry pattern when editing AWS auth steps. @@ -36,7 +36,7 @@ Both workflows use a three-shot retry pattern (try / sleep 30 / retry / sleep 60 - **ECR is the version record** (platform-gitops#1201): both release workflows resolve the next version as the highest strict-`X.Y.Z` tag in the service's ECR repo (image or `charts/` OCI) plus one, then probe-and-bump past any existing candidate (ECR tags are immutable). Git tags are never read for versioning. - **Release marker refs**: `refs/releases/image/` and `refs/releases/chart/` — one mutable ref per service, force-advanced to the released SHA on every successful push. They are the change-detection baseline for `discover-services` and chart-release's prepare job (legacy `image|chart//X.Y.Z` tag is the fallback until a service releases once with the marker in place). Not tags, so they don't feed Kargo's tag enumeration or the GitHub `create` webhook. Readers must fetch them explicitly (`+refs/releases/*:refs/releases/*`). -- **Dispatch publish notification**: after each successful push, both release workflows call `actions/notify-dispatch` — a signed POST (service, version, full SHA, run URL) through the public webhook-forwarder `/dispatch/ci` route, HMAC'd with the org `CI_WEBHOOK_SECRET` (reaches reusable workflows via `secrets: inherit`). Warn-only on failure: the EventBridge ECR-push backstop covers a missed call. +- **Dispatch publish notification**: after each successful push, `chart-release.yml` and the pinned `docker-release.yml@pre-stevedore` call `actions/notify-dispatch` — the stevedore `docker-release.yml` on main does not yet (blairham/stevedore#6; its `no-push` defaults `true` until that closes). The notification is — a signed POST (service, version, full SHA, run URL) through the public webhook-forwarder `/dispatch/ci` route, HMAC'd with the org `CI_WEBHOOK_SECRET` (reaches reusable workflows via `secrets: inherit`). Warn-only on failure: the EventBridge ECR-push backstop covers a missed call. - **Chart sha alias**: chart-release aliases the pushed OCI chart as `X.Y.Z-` (via `aws ecr put-image` on the same manifest — allowed under tag immutability) so the ECR-push backstop can recover commit provenance for charts, mirroring the image tag pair. Semver-prerelease form, so Kargo chart Warehouses ignore it. - **No `image/*` / `chart/*` git tags and no GitHub Releases are minted** (platform-gitops#1201). The historical tags remain in service repos as the *frozen* fallback change-detection baseline — do not prune them until every service has released once with marker refs (the readers fall back to them). Do not reintroduce tag minting: it spams Kargo's git tag enumeration and the GitHub `create` webhook. - Per-service config tag (Kargo freight for `-config` Warehouse): `vX.Y.Z+` — semver **build metadata** form (`+`), not pre-release (`-`). The `+` form passes `semverConstraint: ">=0.0.0"` cleanly; the pre-release form would require `>=0.0.0-0`. **This tag family stays** — it is genuine Kargo freight. @@ -53,7 +53,7 @@ It posts a sticky PR comment with hook output and fails the job (so engineers se ## Editing playbook -- **Changing the docker matrix shape** in `discover-services/action.yml` means changing the corresponding consumer in `docker-release.yml` and (usually) the caller's `ci.yml` skeleton in `README.md` — keep all three in sync in one PR. +- **Changing the docker matrix shape** in `discover-services/action.yml`: its docker-release consumer now lives only on the frozen `@pre-stevedore` tag (never retarget it) — pinned callers' `ci.yml` files still read the matrix output, so treat the shape as frozen until the stevedore migration completes. - **Bumping action versions** (`actions/checkout`, `aws-actions/*`, etc.) — bump both workflows together if they share the action; mismatched versions across the two release workflows have caused subtle behavior splits before. - **Adding a new composite action** under `actions/`: add a row to the `## What's here` table in `README.md` and a usage block under `#### Language setup composites — usage` if it's a setup composite. - **Force `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true`** is set on workflows that use older marketplace actions still pinned to Node 20 — leave it in place when copying steps. diff --git a/README.md b/README.md index 35afe7a..9dba99d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Why `.github` and not a dedicated `github-actions` repo: `.github` is *the* GitH | File | Purpose | |---|---| -| `docker-release.yml` | Matrix-based image build + push to ECR. Version = highest `X.Y.Z` tag in the ECR repo + 1 (ECR is the version record — platform-gitops#1201); advances the `refs/releases/image/` marker ref and notifies Dispatch. No git tags or GitHub Releases. Caller passes a `matrix` input in the standard `{include:[...]}` shape. Optional `private-modules: true` mints a short-lived read-only `pinpredict-argocd` App token and exposes it to the build as BuildKit secret `id=gh_token` (`RUN --mount=type=secret,id=gh_token …`) — for Dockerfiles that fetch a private pinpredict module (e.g. `github.com/pinpredict/ppkit`) instead of vendoring it. Default false. | +| `docker-release.yml` | Image build + release via [stevedore](https://github.com/blairham/stevedore), driven by the caller repo's `.stevedore.yaml` — **no `matrix` input**. Single job by design (pinpredict/.github#39): one runner + one BuildKit, so a build-once Dockerfile compiles once for every image. Version = highest `X.Y.Z` tag in the ECR repo + 1 (ECR is the version record — platform-gitops#1201, resolved by stevedore); advances the `refs/releases/image/` marker refs. No git tags or GitHub Releases. Exports `STEVEDORE_CACHE_FROM/TO` (one `type=gha` scope) for configs that opt into layer caching. `no-push` defaults `true` until the Dispatch notification gap (blairham/stevedore#6) closes. Existing callers are pinned to `@pre-stevedore` (the frozen matrix implementation, which also carried the `private-modules` BuildKit-secret input) — see the tagging exception below. | | `chart-release.yml` | Auto-discovers `charts/*/`, skips charts unchanged since their `refs/releases/chart/` marker ref, resolves the next version from the ECR OCI repo, packages, pushes (+ `X.Y.Z-` provenance alias), advances the marker, notifies Dispatch. No git tags or GitHub Releases. No caller inputs. | | `tag-config.yml` | Tags merges to main that touch `.platform/services/.yaml` with `vX.Y.Z+` (per-service Kargo `-config` Warehouse freight), then dispatches `service-config-tag` to platform-gitops so missing pointer files get seeded. | | `actionlint.yml` | Lints GitHub Actions workflow YAML with [`actionlint`](https://github.com/rhysd/actionlint) at a pinned version. Self-runs on this repo when PRs/pushes touch `.github/workflows/**` or `actions/**/action.yml`; callers reuse it via `uses: pinpredict/.github/.github/workflows/actionlint.yml@main`. | @@ -70,7 +70,7 @@ Why `.github` and not a dedicated `github-actions` repo: `.github` is *the* GitH Pin callers to `@main`. We own all consumers, so version pinning adds overhead without safety benefit at this team size — `@main` gives the "edit once, propagate everywhere" property that's the whole point of centralizing. If blast radius ever bites, we add a tag selectively for the workflows that broke; we don't pre-tag everything. -**Current exception — the `pre-stevedore` tag.** `docker-release.yml` is being rebuilt on [stevedore](https://github.com/blairham/stevedore), which is exactly the "blast radius bites" case the rule reserves. Callers of `docker-release.yml` (and only that workflow) are temporarily pinned to `@pre-stevedore`, a frozen snapshot of main — not a maintained version line, no fixes land on it. Everything else (chart-release, composites, tag-config) stays on `@main`. The pin comes off caller-by-caller as each repo migrates, and the tag is deleted when the migration completes. +**Current exception — the `pre-stevedore` tag.** `docker-release.yml` on main is now the [stevedore](https://github.com/blairham/stevedore) implementation — the rebuild that is exactly the "blast radius bites" case the rule reserves. Callers of `docker-release.yml` (and only that workflow) are temporarily pinned to `@pre-stevedore`, the frozen snapshot of the old matrix implementation — not a maintained version line, no fixes land on it. Everything else (chart-release, composites, tag-config) stays on `@main`. A repo migrates by adding a `.stevedore.yaml`, dropping the `matrix` input from its `ci.yml`, and pointing `uses:` back to `@main`; the tag is deleted when the migration completes. For workflows that touch secrets/OIDC, pin to an immutable SHA only if a security audit later requires it. @@ -132,13 +132,16 @@ jobs: with: github-token: ${{ steps.pg-read-token.outputs.token }} + # Stevedore reads .stevedore.yaml and does its own change detection — + # no matrix input. Migrating repos: swap @pre-stevedore back to @main + # and drop the matrix wiring. docker-release: needs: [detect, test] - if: needs.detect.outputs.docker_matrix != '{"include":[]}' permissions: { id-token: write, contents: write } uses: pinpredict/.github/.github/workflows/docker-release.yml@main with: - matrix: ${{ needs.detect.outputs.docker_matrix }} + changed-since: ${{ github.event.before }} + no-push: true # flip to false once the Dispatch notification lands secrets: inherit chart-release: