From 593a7c8fb5f5855f22c1c641a8657a901a3cd024 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Fri, 30 Oct 2026 07:39:58 -0700 Subject: [PATCH] Backport fork release publishing and branch maintenance (cherry picked from commit a57b0844b0b6cb9543f421ec5db2b4a6fbe1b701) Signed-off-by: Eitan Yarmush --- .agents/skills/sync-upstream-main/SKILL.md | 84 -------- .agents/skills/sync-upstream/SKILL.md | 190 ++++++++++++++++++ .github/scripts/release/publish-charts.sh | 43 ++++ .../release/refuse-published-release.sh | 6 +- .github/workflows/release-fork.yaml | 159 --------------- .github/workflows/release.yaml | 60 ++++-- cmd/ate-setup/commands.md | 4 +- cmd/ate-setup/internal/cmd/publish.go | 12 +- cmd/ate-setup/internal/cmd/publish_test.go | 42 ++++ cmd/ate-setup/internal/images/release.go | 5 +- cmd/ate-setup/internal/steps/publish.go | 7 +- cmd/ate-setup/internal/steps/publish_test.go | 42 +++- .../internal/steps/testdata/release-docker.sh | 20 ++ docs/dev/releasing.md | 65 ++++-- hack/release/draft-release.sh | 16 +- hack/release/release_test.go | 149 ++++++++++++++ hack/release/testdata/gh.sh | 33 +++ hack/release/testdata/helm.sh | 44 ++++ 18 files changed, 682 insertions(+), 299 deletions(-) delete mode 100644 .agents/skills/sync-upstream-main/SKILL.md create mode 100644 .agents/skills/sync-upstream/SKILL.md create mode 100755 .github/scripts/release/publish-charts.sh delete mode 100644 .github/workflows/release-fork.yaml create mode 100644 cmd/ate-setup/internal/cmd/publish_test.go create mode 100755 cmd/ate-setup/internal/steps/testdata/release-docker.sh create mode 100644 hack/release/release_test.go create mode 100755 hack/release/testdata/gh.sh create mode 100755 hack/release/testdata/helm.sh diff --git a/.agents/skills/sync-upstream-main/SKILL.md b/.agents/skills/sync-upstream-main/SKILL.md deleted file mode 100644 index 6963783978..0000000000 --- a/.agents/skills/sync-upstream-main/SKILL.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -name: sync-upstream-main -description: Merge agent-substrate/substrate upstream/main into the kagent-dev/substrate fork through a pull request, preserving both histories. Use for upstream synchronization of the fork's main branch, not feature-branch rebases or general PR conflict resolution. ---- - -# Sync Upstream Main - -The fork uses merge-based synchronization. Start a branch from current -`origin/main`, merge `upstream/main`, and submit that branch to `origin/main`. -Preserve upstream ancestry so later synchronizations contain only new changes. -Each sync PR must contain exactly one sync merge commit on top of `origin/main`, -with all conflict resolutions, integration fixes, and skill updates folded into -it. Preserve the incoming upstream commits; do not squash their history. -Do not replay fork commits or push to main. -Creating a PR requires an explicit request or approval; approval to create it -does not authorize merging it. - -## Prepare the merge - -1. Confirm remotes (`origin`: `kagent-dev/substrate`; `upstream`: - `agent-substrate/substrate`), branch, worktree, and local changes. Use an - isolated worktree under `$TMPDIR` when needed; preserve unrelated work. -2. Fetch both main branches and record their SHAs. Inspect incoming commits and - existing synchronization PRs before creating duplicate work. If upstream is - already an ancestor of origin, report that there is nothing to synchronize. -3. Create `sync/upstream-main-YYYYMMDD` from `origin/main` (choose a unique suffix - if occupied), then run `git merge --no-ff --no-commit upstream/main`. -4. Resolve conflicts using current upstream APIs while retaining intentional - fork behavior. Inspect automatically merged code where both sides changed - the same component. Check removed fields and renamed APIs across fork callers. -5. Inspect chart implications of upstream manifest and CRD changes. The fork's - chart uses agentgateway, while some preserved upstream manifests use Envoy; - translate applicable behavior without replacing the chart's dataplane. The - render check excludes preserved manifests, so passing it alone does not - establish parity. Keep `charts/substrate-crds/templates` synchronized with - generated CRDs. -6. Commit the merge with `git commit -s`. Fold later fixes and documentation - into that same commit with `git commit --amend -s`; do not leave follow-up - commits on the sync branch. Omit issue and PR references from commit messages. - -## Validate and investigate failures - -Run `go test -race ./...`, `hack/verify-all.sh`, -`hack/render-manifests.sh --check`, and `hack/verify/crd-chart.sh`. Use Go's default cache. -Record failures and distinguish regressions from missing prerequisites and -existing failures using evidence from the base revision when needed. - -Use `.github/workflows/pr-workflow.yaml` and `.github/workflows/helm-e2e.yaml` -at the merged revision as the source of truth for runtime validation. Follow -the fork's agentgateway configuration and cover both gVisor and microVM when -running E2E. Local E2E must use a task-owned cluster and avoid disrupting shared -clusters or registries. Package tests without `--e2e` are not runtime coverage. - -For a failed merge or PR run, identify the exact SHA, job, runtime, and failing -test or setup step. Read test output and component diagnostics together, and -check whether incoming upstream commits already address the failure before -adding another fix. A green unrelated lane or an unexamined rerun is not proof -that the failure is resolved. Report test counts and skips when relevant. - -## Publish the PR - -Before publishing, fetch both refs again. If either main tip changed, rebuild -the single merge from the latest `origin/main` and `upstream/main`, reapplying -and checking the integration fixes. Do not stack additional merge commits. -Verify that `git rev-list --first-parent --count origin/main..HEAD` returns `1` -and HEAD has exactly two parents: `origin/main` first, `upstream/main` second. -Check that the diff is intentional and the worktree is clean. - -Push only the sync branch. When amending an already published merge, verify -the remote still matches the last observed PR head and push with an explicit -`--force-with-lease=refs/heads/:`. Never force-push -unguarded or overwrite another contributor's changes. Verify that folding -commits preserved the intended tree, and report checks for the new commit SHA. - -When PR creation is authorized, open it against `kagent-dev/substrate:main`. -Describe the upstream SHA, material changes, conflict resolutions, required -migration steps, and actual validation. If checks fail or cannot run, disclose -the exact limitation; use a draft for unresolved integration failures. Do not -claim CI or runtime coverage that has not completed on the submitted revision. - -State that the PR must be merged with a merge commit to retain upstream -ancestry. Link the PR and report its current checks. Remove task-owned test -resources when finished, preserving the review branch and any useful failure -evidence. diff --git a/.agents/skills/sync-upstream/SKILL.md b/.agents/skills/sync-upstream/SKILL.md new file mode 100644 index 0000000000..a74678c694 --- /dev/null +++ b/.agents/skills/sync-upstream/SKILL.md @@ -0,0 +1,190 @@ +--- +name: sync-upstream +description: Maintain kagent-dev/substrate main and release branches against the matching agent-substrate/substrate branches while preserving fork patches. Use for upstream syncs, establishing downstream release branches, and backporting fork fixes; not general feature-branch rebases or publishing releases. +--- + +# Maintain the Substrate Fork + +The fork uses merge-based synchronization. Each maintained branch retains its +fork changes and receives updates from the matching upstream branch: + +| Destination | Upstream source | Fork changes | +|---|---|---| +| `main` | `upstream/main` | Development and fixes | +| `release-X.Y` | `upstream/release-X.Y` | Selected compatible fixes | + +Develop fork changes on `main` first, then backport applicable fixes. An urgent +release fix may start on its release branch; also apply it to `main` where +relevant. Do not merge all of fork `main` into an established release branch. +Routine syncs preserve history; they do not rebuild the fork patch stack. + +Keep work local unless publication is authorized. Opening a PR requires an +explicit request or approval. Approval to open a PR does not authorize merging +it, creating release tags, or publishing artifacts. A sync request does not +authorize pushing directly to protected branches or resetting their history. + +## Select the operation and revisions + +1. Confirm remotes (`origin`: `kagent-dev/substrate`; `upstream`: + `agent-substrate/substrate`), destination, worktree, and local changes. + Preserve unrelated work in an isolated worktree under `$TMPDIR`. Check for + an existing sync PR or another agent working on the same destination. +2. Fetch the relevant refs and record their SHAs. Pin both the fork destination + and the selected upstream revision. For patch selection, also pin the fork + source revision and inspect the merge resolutions at that revision. + Another agent advancing `main` must not silently change that patch set. Honor + an explicit release cutoff or tag; do not substitute latest `main`. Ask for + the destination if the request and context do not identify it. Do not treat + every old release branch as supported. +3. Choose the operation from the requested outcome and current branch state: + - **Routine sync:** merge the matching upstream line into an already patched + fork branch. + - **Establish a release:** combine the selected upstream release with our + applicable patches, whether the destination is absent or already exists + as an upstream-only mirror. + - **Backport:** apply selected changes to an established release branch. +4. Read the destination's `AGENTS.md` and compatibility guidance. From 1.0, + consult `docs/reference/version-skew-policy.md` for release changes. API + additions and schema migrations do not belong in patch releases. + +## Preserve fork behavior + +Check both ordinary fork commits and integration changes in earlier sync +merges. A list of non-merge commits alone can miss required behavior. Cover +deployment and publishing changes as well as Go code. Preserve the relevant +tests when upstream refactors the implementation. Do not guess a patch's +purpose from its title alone. + +For a patch PR, state which supported branches need it. For a sync, account for +patches affected by incoming changes and any redundant implementation removed. +A patch is supplied by upstream only once the destination branch contains the +replacement and the behavior is verified. Do not modify or publish another +branch as an implicit side effect. + +## Routine upstream sync + +1. Create `sync/upstream--YYYYMMDD` from the recorded fork tip, + choosing a unique suffix if occupied. If the selected upstream commit is + already an ancestor, there are no upstream commits to merge. This check does + not establish that an upstream-only mirror contains our patches. +2. Run `git merge --no-ff --no-commit `. Resolve conflicts using + the destination's APIs while retaining intentional fork behavior. Inspect + automatic merges where both sides changed the same component, including + renamed or removed APIs and all fork callers. +3. Check chart implications of manifest and CRD changes. The fork's chart uses + agentgateway, while preserved upstream manifests may use Envoy. Translate + applicable behavior without replacing the chart's dataplane. The render + check excludes preserved manifests, so passing it alone does not establish + parity. Keep `charts/substrate-crds/templates` aligned with generated CRDs. +4. Create exactly one sync merge commit on top of the recorded fork tip, using + `git commit -s` with the configured human identity. Fold conflict resolutions + and integration fixes into that commit with `git commit --amend -s`. Keep + unrelated changes separate. Preserve incoming upstream commits; do not squash + their history or replay the existing fork commits. +5. Verify `git rev-list --first-parent --count ..HEAD` returns `1` and + HEAD has exactly two parents: the recorded fork tip first and selected + upstream commit second. This counts the preparation branch, not the later + GitHub PR merge commit. + +## Establish a downstream release branch + +Use upstream's selected `release-X.Y` head, including its release-specific +backports, unless the user explicitly pins a different revision. A tag and the +current release head may differ; record which one is being used. + +Prefer a tested fork candidate prepared at the release cutoff. Confirm its +upstream base belongs to the release line and inspect any commits added after +that base. Reject candidates containing upstream `main` development excluded +from the selected release. Merging an older release ref cannot remove newer +commits already in a candidate's ancestry. + +When a suitable candidate exists, integrate the selected upstream release into +that candidate and validate the combined result. When none exists, start at the +selected upstream release and replay the applicable fork changes in dependency +order, including integration fixes from prior syncs. Adapt them to that release +and skip changes already provided there. This is initial branch establishment, +not the routine sync procedure; it need not produce one merge commit. + +If the destination already exists, preserve its tip as an ancestor and prepare +a PR against it. For an upstream-only mirror, add the fork patches through that +PR. If it already contains downstream changes, preserve and reconcile those +too. Do not reset an existing protected branch to a prepared candidate. + +If the destination is absent, explicit branch-creation authorization is needed. +Prepare and validate the candidate first. A reviewable route is to create the +upstream baseline branch, then propose the downstream changes against it; report +that this branch remains an upstream-only mirror until those changes are merged. +Immediately before creating it, confirm the destination is still absent and +use a create-only operation. If another actor created it, refetch and reconcile +with that tip instead of overwriting it. + +Before calling the branch ready, verify preserved fork behavior, CI triggers, +required checks, and release configuration for that branch. Record gaps and +prepare scoped fixes where authorized. Protection settings alone do not prove +CI runs or blocks merges. Continue `main` development separately after the +release candidate is fixed. + +## Backport selected changes + +Start from the recorded destination tip. Use the source patch commit, usually +the merged fork `main` PR, with `git cherry-pick -x -s `. Check dependencies +and whether upstream already supplies the behavior. Do not cherry-pick a whole +upstream sync merge as a substitute for identifying the needed fix. + +Adapt conflicts to the destination's interfaces and compatibility contract. +Preserve the source reference in the PR if adaptation substantially changes the +implementation. Verify the affected behavior on the destination; passing on +`main` does not establish a valid backport. Report any remaining branch work, +including a fix that started on a release branch and is still missing on `main`. + +## Validate the candidate + +For an upstream sync or initial release candidate, run `go test -race ./...`, +`hack/verify-all.sh`, `hack/render-manifests.sh --check`, and +`hack/verify/crd-chart.sh` where those fork checks exist. Investigate missing +checks rather than silently treating their absence as success. Use Go's default +cache. For a narrow backport, follow the destination's required checks and add +validation appropriate to the affected behavior. + +Use `.github/workflows/pr-workflow.yaml` and `.github/workflows/helm-e2e.yaml` +at the candidate revision to identify runtime coverage. Validate the fork's +agentgateway configuration on both gVisor and microVM for runtime changes and +release establishment. Local E2E uses task-owned resources and must not disrupt +shared clusters or registries. Package tests without `--e2e` are not runtime +coverage. Validate release upgrades and rollbacks against the previous supported +downstream release when required by the version-skew policy. + +For failures, record the exact SHA, job, runtime, and failing test or setup step. +Read test output and component diagnostics together. Distinguish regressions +from missing prerequisites and existing failures using the base revision when +needed. Check whether incoming upstream commits already address the failure. +An unrelated green lane or an unexamined rerun does not explain a failure. + +## Hand off or publish + +Before publishing, refetch the destination and source. If the destination +advanced, rebuild on its new tip and revalidate. Report upstream advances; +refresh a latest-tip sync, but retain an explicitly pinned revision or cutoff. +For routine syncs, rebuild the single merge instead of stacking more merges. +Keep a PR's rebase or reconstruction push separate from fixes to reviewed code. + +Review the diff and confirm it preserves the intended fork behavior. For +substantial changes, obtain an independent review and validate its findings. +Report the source and destination SHAs, patches preserved or backported, +remaining branch work, validation results, and blockers. If publication is not +authorized, leave the local branch ready for review. + +When authorized, push the preparation branch and open the PR against the +selected fork destination. The only destination write in this workflow is the +explicitly authorized creation of an absent release branch described above. +For an already published preparation branch that must be +rewritten, verify its head and use an explicit +`--force-with-lease=refs/heads/:`. Never overwrite +another contributor's work. Commit with human sign-off and disclose AI use in +the PR. Describe actual validation, including skips and missing runtime lanes; +use a draft for unresolved integration failures. + +Sync and initial-release PRs carrying upstream history must use a merge commit +to preserve that ancestry. Backport PRs may use the repository's usual patch +merge method. Do not merge the PR without authorization. Remove task-owned test +resources while preserving the review branch and useful failure evidence. diff --git a/.github/scripts/release/publish-charts.sh b/.github/scripts/release/publish-charts.sh new file mode 100755 index 0000000000..931d378a58 --- /dev/null +++ b/.github/scripts/release/publish-charts.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Publish the tagged checkout's charts beside its release images. +set -o errexit -o nounset -o pipefail + +: "${VERSION:?}" "${KO_DOCKER_REPO:?}" "${GITHUB_ACTOR:?}" "${GH_TOKEN:?}" +if [[ ! "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "error: ${VERSION} is not a release tag" >&2 + exit 1 +fi + +ROOT="$(git rev-parse --show-toplevel)" +"${ROOT}/.github/scripts/release/refuse-published-release.sh" +package_dir="$(mktemp -d)" +trap 'rm -rf "${package_dir}"' EXIT +version="${VERSION#v}" +charts=(substrate-crds substrate substrate-podcert) + +# Package the complete set before writing to the registry. +for chart in "${charts[@]}"; do + helm package "${ROOT}/charts/${chart}" --destination "${package_dir}" \ + --version "${version}" --app-version "${VERSION}" +done + +helm registry login "${KO_DOCKER_REPO%%/*}" --username "${GITHUB_ACTOR}" \ + --password-stdin <<<"${GH_TOKEN}" +for chart in "${charts[@]}"; do + helm push "${package_dir}/${chart}-${version}.tgz" "oci://${KO_DOCKER_REPO}/helm" +done diff --git a/.github/scripts/release/refuse-published-release.sh b/.github/scripts/release/refuse-published-release.sh index 35fae73965..4a8ec715ff 100755 --- a/.github/scripts/release/refuse-published-release.sh +++ b/.github/scripts/release/refuse-published-release.sh @@ -15,7 +15,7 @@ # limitations under the License. # Fails if the tag VERSION has a published (non-draft) release in -# GITHUB_REPOSITORY. Publishing a release's images again replaces the digests +# GITHUB_REPOSITORY. Publishing a release's artifacts again replaces the digests # that operators install by tag, so it is refused once the release is # published, whether or not the run asked to overwrite. # @@ -26,7 +26,7 @@ # release. Any other failure stops the script. A release the API returns is # treated as published unless it says it is a draft. # -# Used by the images job in .github/workflows/release.yaml. +# Used by the image and chart publishers in .github/workflows/release.yaml. set -o errexit -o nounset -o pipefail @@ -35,7 +35,7 @@ trap 'rm -f "${errfile}"' EXIT if draft="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${VERSION}" --jq .draft 2>"${errfile}")"; then if [[ "${draft}" != true ]]; then - echo "::error::${VERSION} has a published release; its images are not published again" + echo "::error::${VERSION} has a published release; its artifacts are not published again" exit 1 fi elif ! grep -q 'HTTP 404' "${errfile}"; then diff --git a/.github/workflows/release-fork.yaml b/.github/workflows/release-fork.yaml deleted file mode 100644 index 5c2709b40c..0000000000 --- a/.github/workflows/release-fork.yaml +++ /dev/null @@ -1,159 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -name: release-fork - -on: - workflow_dispatch: - inputs: - tag: - description: 'Image tag (e.g. v1.2.3-rc1). Leave blank to auto-generate from branch+SHA.' - required: false - create_release: - description: 'Create a GitHub release' - type: boolean - default: false - -permissions: - contents: write - packages: write - -jobs: - release: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - - name: Validate and resolve tag - id: tag - run: | - TAG="${{ inputs.tag }}" - if [[ -z "${TAG}" ]]; then - BRANCH="${GITHUB_REF_NAME//\//-}" - SHA="$(git rev-parse --short HEAD)" - TAG="${BRANCH}-${SHA}" - fi - if [[ "${{ inputs.create_release }}" == "true" ]]; then - if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$ ]]; then - echo "::error::Tag '${TAG}' must match vMAJOR.MINOR.PATCH[-prerelease] when creating a release (e.g. v1.2.3 or v1.2.3-rc1)" - exit 1 - fi - fi - echo "value=${TAG}" >> "$GITHUB_OUTPUT" - if [[ "${{ inputs.create_release }}" == "true" ]]; then - echo "tags=${TAG},latest" >> "$GITHUB_OUTPUT" - else - echo "tags=${TAG}" >> "$GITHUB_OUTPUT" - fi - - - name: Setup Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 - with: - go-version-file: 'go.mod' - - - name: Install ko - uses: ko-build/setup-ko@3aebd0597dc1e9d1a26bcfdb7cbeb19c131d3037 # v0.7 - - - name: Install Helm - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 - - - name: Log in to GHCR - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up QEMU (multi-arch) - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - - - name: Build and push images - env: - # ghcr.io// — resolves correctly in forks - IMAGE_REPOSITORY: ghcr.io/${{ github.repository }} - IMAGE_TAGS: ${{ steps.tag.outputs.tags }} - run: | - set -o errexit -o nounset -o pipefail - - for component in ateapi ate-idp-server atecontroller atelet ateworker-gvisor ateworker-microvm podcertcontroller atenet objectstore-plugin credential-provider/kubernetes-secrets; do - KO_DOCKER_REPO="${IMAGE_REPOSITORY}/${component##*/}" \ - ./hack/run-tool.sh ko build \ - --tags "${IMAGE_TAGS}" \ - --platform linux/amd64,linux/arm64 \ - --bare \ - "./cmd/${component}" - done - - - name: Package and push Helm charts - if: inputs.create_release - env: - HELM_EXPERIMENTAL_OCI: "1" - CHART_REPOSITORY: oci://ghcr.io/kagent-dev/substrate/helm - run: | - set -o errexit -o nounset -o pipefail - - tag="${{ steps.tag.outputs.value }}" - chart_version="${tag#v}" - package_dir="${RUNNER_TEMP}/helm-packages" - mkdir -p "${package_dir}" - - echo "${{ secrets.GITHUB_TOKEN }}" \ - | helm registry login ghcr.io \ - --username "${{ github.actor }}" \ - --password-stdin - - helm package charts/substrate-crds \ - --destination "${package_dir}" \ - --version "${chart_version}" \ - --app-version "${tag}" - helm package charts/substrate \ - --destination "${package_dir}" \ - --version "${chart_version}" \ - --app-version "${tag}" - helm package charts/substrate-podcert \ - --destination "${package_dir}" \ - --version "${chart_version}" \ - --app-version "${tag}" - - helm push "${package_dir}/substrate-crds-${chart_version}.tgz" "${CHART_REPOSITORY}" - helm push "${package_dir}/substrate-${chart_version}.tgz" "${CHART_REPOSITORY}" - helm push "${package_dir}/substrate-podcert-${chart_version}.tgz" "${CHART_REPOSITORY}" - - - name: Build kubectl-ate release binaries - if: inputs.create_release - env: - VERSION: ${{ steps.tag.outputs.value }} - run: | - set -o errexit -o nounset -o pipefail - - mkdir -p dist - for os in linux darwin; do - for arch in amd64 arm64; do - CGO_ENABLED=0 GOOS="${os}" GOARCH="${arch}" go build \ - -trimpath \ - -ldflags="-s -w -X=go.ate.dev/substrate/internal/version.Version=${VERSION}" \ - -o "dist/kubectl-ate-${os}-${arch}" \ - ./cmd/kubectl-ate - done - done - - - name: Create GitHub Release - if: inputs.create_release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 - with: - tag_name: ${{ steps.tag.outputs.value }} - generate_release_notes: true - files: dist/kubectl-ate-* diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c14d6448e0..0bc2b9f63d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,10 +17,9 @@ # kubectl-ate binaries attached. A maintainer edits and publishes it; see # docs/dev/releasing.md. # -# Images are published only from agent-substrate/substrate, to -# ghcr.io/agent-substrate/substrate. In any other repository (a fork) the -# images job is skipped, because GitHub bills package storage to the fork's -# owner, and the draft is created as before. +# agent-substrate/substrate and kagent-dev/substrate publish images and binaries +# under their own repository. kagent-dev/substrate also publishes Helm charts. +# Other forks create a draft without publishing artifacts. # # Run manually to publish images or draft a release for an existing tag. # A complete image set is left unchanged unless overwrite is set; an incomplete @@ -51,10 +50,12 @@ permissions: contents: read concurrency: group: release-${{ inputs.tag || github.ref_name }} +env: + KO_DOCKER_REPO: ghcr.io/${{ github.repository }} + ATE_PUBLISH_SOURCE_URL: https://github.com/${{ github.repository }} jobs: - # The only job that writes to a registry. images: - if: github.repository == 'agent-substrate/substrate' && !inputs.dry_run_images + if: (github.repository == 'agent-substrate/substrate' || github.repository == 'kagent-dev/substrate') && !inputs.dry_run_images runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -63,7 +64,6 @@ jobs: # Read by ate-setup publish release-images. env: VERSION: ${{ inputs.tag || github.ref_name }} - KO_DOCKER_REPO: ghcr.io/agent-substrate/substrate KO_DEFAULTPLATFORMS: linux/amd64,linux/arm64 # inputs.overwrite is empty on a tag push; this makes the value explicit. ATE_PUBLISH_OVERWRITE: ${{ inputs.overwrite == true }} @@ -148,13 +148,36 @@ jobs: http = true - name: Publish images run: go run ./cmd/ate-setup publish release-images - draft: + charts: needs: images - # Runs after images succeeded, or when images was skipped (any repository - # other than agent-substrate/substrate). If images failed, no draft is - # created, so a release cannot be published without its images. Skipped - # in a dry run. - if: ${{ !cancelled() && !inputs.dry_run_images && (needs.images.result == 'success' || needs.images.result == 'skipped') }} + if: github.repository == 'kagent-dev/substrate' && !inputs.dry_run_images + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + packages: write + env: + VERSION: ${{ inputs.tag || github.ref_name }} + GH_TOKEN: ${{ github.token }} + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: refs/tags/${{ inputs.tag || github.ref_name }} + persist-credentials: false + - name: Install Helm + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 + with: + version: v3.18.4 + - name: Publish charts + run: .github/scripts/release/publish-charts.sh + draft: + needs: [images, charts] + # Repositories that publish artifacts must finish those jobs before drafting. + if: >- + ${{ !cancelled() && !inputs.dry_run_images && + (needs.images.result == 'success' || needs.images.result == 'skipped') && + (needs.charts.result == 'success' || needs.charts.result == 'skipped') }} runs-on: ubuntu-latest timeout-minutes: 30 # Creating a release needs contents: write. @@ -178,10 +201,9 @@ jobs: # These jobs can write packages or releases, so they restore no cache # that another workflow could have written. cache: false - # Built before the draft exists, so a build failure leaves no draft, as an - # images failure does. A fork attaches no binaries, so it builds none. + # Build before creating the draft so a build failure leaves no draft. - name: Build kubectl-ate - if: github.repository == 'agent-substrate/substrate' + if: github.repository == 'agent-substrate/substrate' || github.repository == 'kagent-dev/substrate' env: VERSION: ${{ inputs.tag || github.ref_name }} run: go run ./cmd/ate-setup publish release-binaries --out bin/release @@ -190,11 +212,9 @@ jobs: GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag || github.ref_name }} run: hack/release/draft-release.sh "${TAG}" - # Only agent-substrate/substrate attaches binaries; a fork's draft gets - # none. Assets are replaced only while the release is a draft, so a rerun - # never changes the files of a published release. + # Assets are replaced only while the release is a draft. - name: Attach kubectl-ate - if: github.repository == 'agent-substrate/substrate' + if: github.repository == 'agent-substrate/substrate' || github.repository == 'kagent-dev/substrate' env: GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag || github.ref_name }} diff --git a/cmd/ate-setup/commands.md b/cmd/ate-setup/commands.md index 62ddeb12f1..ceb8a5bc22 100644 --- a/cmd/ate-setup/commands.md +++ b/cmd/ate-setup/commands.md @@ -126,7 +126,9 @@ to a build to use the ateworker. `publish release-images` builds and pushes every image a release publishes, all tagged with the build version; see [Installing pre-built -images](#installing-pre-built-images). +images](#installing-pre-built-images). `--source-url` (or `ATE_PUBLISH_SOURCE_URL`) +sets the OCI source repository label and defaults to the upstream GitHub URL. +Forks must set their own source URL so GHCR links the packages to their repository. `publish release-binaries` builds `kubectl-ate` for linux and darwin on amd64 and arm64 into `--out` (default `bin/release`), stamped with the build version, commit, diff --git a/cmd/ate-setup/internal/cmd/publish.go b/cmd/ate-setup/internal/cmd/publish.go index 02df1ef07f..89827c0897 100644 --- a/cmd/ate-setup/internal/cmd/publish.go +++ b/cmd/ate-setup/internal/cmd/publish.go @@ -18,6 +18,7 @@ import ( "github.com/spf13/cobra" "go.ate.dev/substrate/cmd/ate-setup/internal/config" + "go.ate.dev/substrate/cmd/ate-setup/internal/images" "go.ate.dev/substrate/cmd/ate-setup/internal/steps" ) @@ -67,7 +68,10 @@ built unless --overwrite is set. After pushing, every image is checked for every platform, and a missing one fails the command.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - opts := steps.PublishReleaseOptions{Overwrite: env.Cfg.Resolved().Bool("publish.overwrite")} + opts := steps.PublishReleaseOptions{ + Overwrite: env.Cfg.Resolved().Bool("publish.overwrite"), + SourceURL: env.Cfg.Resolved().String("publish.sourceURL"), + } return env.PublishReleaseImages(cmd.Context(), cmd.OutOrStdout(), opts) }, } @@ -110,5 +114,11 @@ func init() { Commands: []string{publishReleaseImagesPath}, Usage: "Rebuild and replace images the registry already holds for the tag", }) + config.RegisterCommand(config.Setting{ + Key: "publish.sourceURL", Env: "ATE_PUBLISH_SOURCE_URL", Flag: "source-url", + Kind: config.KindString, Default: images.SourceURL, + Commands: []string{publishReleaseImagesPath}, + Usage: "Source repository URL recorded in the release images' OCI labels", + }) config.BindCommandFlags(publishReleaseImagesPath, publishReleaseImagesCmd.Flags()) } diff --git a/cmd/ate-setup/internal/cmd/publish_test.go b/cmd/ate-setup/internal/cmd/publish_test.go new file mode 100644 index 0000000000..2c146a3303 --- /dev/null +++ b/cmd/ate-setup/internal/cmd/publish_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "testing" + + "go.ate.dev/substrate/cmd/ate-setup/internal/config" +) + +func TestPublishSourceRepository(t *testing.T) { + for _, tc := range []struct { + name string + env map[string]string + want string + }{ + {name: "upstream", want: "https://github.com/agent-substrate/substrate"}, + {name: "fork", env: map[string]string{"ATE_PUBLISH_SOURCE_URL": "https://github.com/kagent-dev/substrate"}, want: "https://github.com/kagent-dev/substrate"}, + } { + t.Run(tc.name, func(t *testing.T) { + r, err := config.Resolve(nil, config.ResolveOptions{Env: tc.env}) + if err != nil { + t.Fatal(err) + } + if got := r.String("publish.sourceURL"); got != tc.want { + t.Errorf("publish.sourceURL = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/cmd/ate-setup/internal/images/release.go b/cmd/ate-setup/internal/images/release.go index 75d30f1213..28c52156b3 100644 --- a/cmd/ate-setup/internal/images/release.go +++ b/cmd/ate-setup/internal/images/release.go @@ -33,9 +33,8 @@ import ( // lets the repository's workflows push later versions with their GITHUB_TOKEN. const SourceLabel = "org.opencontainers.image.source" -// SourceURL is the SourceLabel value for images built from this repository: -// its GitHub URL, which ghcr.io matches. It cannot be derived from ModulePath, -// which is a vanity import path. +// SourceURL is the default source repository for release images. Forks override +// it with --source-url so ghcr.io links their packages to their own repository. const SourceURL = "https://github.com/agent-substrate/substrate" // RevisionLabel is the OCI image label naming the commit an image was built diff --git a/cmd/ate-setup/internal/steps/publish.go b/cmd/ate-setup/internal/steps/publish.go index f98326e733..f4cd66da22 100644 --- a/cmd/ate-setup/internal/steps/publish.go +++ b/cmd/ate-setup/internal/steps/publish.go @@ -165,6 +165,8 @@ type PublishReleaseOptions struct { // Overwrite rebuilds and replaces images the registry already holds for // the tag. Without it, a complete set is left alone. Overwrite bool + // SourceURL identifies the repository that built the images. + SourceURL string } // releaseImageRefs returns the tagged reference of every image a release @@ -200,6 +202,9 @@ func (e *Env) PublishReleaseImages(ctx context.Context, w io.Writer, opts Publis if err := images.CheckReleaseTag(e.Cfg.KODockerRepo, tag); err != nil { return fmt.Errorf("the build version: %w; set VERSION to the release tag", err) } + if opts.SourceURL == "" { + return fmt.Errorf("publishing release images needs their source repository; set --source-url") + } log.Stepf("publish_release_images (%s)", tag) platforms, err := images.Platforms(e.Cfg.Root, e.Cfg.KODefaultPlatforms) if err != nil { @@ -234,7 +239,7 @@ func (e *Env) PublishReleaseImages(ctx context.Context, w io.Writer, opts Publis return fmt.Errorf("release images are labeled with the commit they were built from, and %s has none checked out", e.Cfg.Root) } labels := []string{ - images.SourceLabel + "=" + images.SourceURL, + images.SourceLabel + "=" + opts.SourceURL, images.RevisionLabel + "=" + revision, images.VersionLabel + "=" + tag, } diff --git a/cmd/ate-setup/internal/steps/publish_test.go b/cmd/ate-setup/internal/steps/publish_test.go index 686af7f105..65391a97a1 100644 --- a/cmd/ate-setup/internal/steps/publish_test.go +++ b/cmd/ate-setup/internal/steps/publish_test.go @@ -71,6 +71,46 @@ func TestPublishReleaseImagesNeedsARegistry(t *testing.T) { } } +func TestPublishReleaseImagesNeedsSourceURL(t *testing.T) { + t.Setenv("VERSION", "v1.2.3") + e := &Env{Cfg: &config.Config{Root: t.TempDir(), KODockerRepo: "example.com/substrate"}} + err := e.PublishReleaseImages(t.Context(), io.Discard, PublishReleaseOptions{}) + if err == nil || !strings.Contains(err.Error(), "--source-url") { + t.Errorf("PublishReleaseImages() error = %v, want one naming --source-url", err) + } +} + +func TestPublishReleaseImagesLabelsTheSource(t *testing.T) { + root := repoRoot(t) + bin := t.TempDir() + if err := os.Symlink(filepath.Join(root, "cmd/ate-setup/internal/steps/testdata/release-docker.sh"), filepath.Join(bin, "docker")); err != nil { + t.Fatal(err) + } + argsFile := filepath.Join(bin, "args") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("RELEASE_TEST_DOCKER_ARGS", argsFile) + t.Setenv("VERSION", "v1.0.0") + e := &Env{ + Cfg: &config.Config{Root: root, KODockerRepo: "ghcr.io/kagent-dev/substrate", KODefaultPlatforms: "linux/amd64,linux/arm64"}, + // Dockerfile images build first; the fixture stops before ko runs. + resolver: &ko.Runner{}, + } + err := e.PublishReleaseImages(t.Context(), io.Discard, PublishReleaseOptions{ + Overwrite: true, + SourceURL: "https://github.com/kagent-dev/substrate", + }) + if err == nil || !strings.Contains(err.Error(), "exit status 17") { + t.Fatalf("PublishReleaseImages() error = %v, want the build fixture to stop it", err) + } + args, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(strings.Split(string(args), "\n"), "--label=org.opencontainers.image.source=https://github.com/kagent-dev/substrate") { + t.Errorf("fork source label missing from build arguments:\n%s", args) + } +} + // Rerunning a release must not replace images that are already published, so // a complete set is left alone. The Root has no ko tool, so reaching the build // would fail this test. @@ -99,7 +139,7 @@ func TestPublishReleaseImagesSkipsACompleteRelease(t *testing.T) { KODockerRepo: repo, KODefaultPlatforms: "linux/amd64,linux/arm64", }} - if err := e.PublishReleaseImages(t.Context(), io.Discard, PublishReleaseOptions{}); err != nil { + if err := e.PublishReleaseImages(t.Context(), io.Discard, PublishReleaseOptions{SourceURL: "https://github.com/kagent-dev/substrate"}); err != nil { t.Errorf("PublishReleaseImages() error = %v, want nil with every image published", err) } } diff --git a/cmd/ate-setup/internal/steps/testdata/release-docker.sh b/cmd/ate-setup/internal/steps/testdata/release-docker.sh new file mode 100755 index 0000000000..8ebb3c7884 --- /dev/null +++ b/cmd/ate-setup/internal/steps/testdata/release-docker.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit -o nounset -o pipefail +printf '%s\n' "$@" >"${RELEASE_TEST_DOCKER_ARGS}" +# Stop before any build or registry access. +exit 17 diff --git a/docs/dev/releasing.md b/docs/dev/releasing.md index 4c9a7739d1..0ceac7bf13 100644 --- a/docs/dev/releasing.md +++ b/docs/dev/releasing.md @@ -1,10 +1,10 @@ # Releasing Substrate -A release is a `vX.Y.Z` tag, or `vX.Y.Z-rc.N` for a release candidate, with container images in `ghcr.io/agent-substrate/substrate` and a GitHub release whose notes are generated from the merged pull requests. How the notes are generated, and how to label and title a pull request for them, is in [Release notes](release-notes.md). +A release is a `vX.Y.Z` tag, or `vX.Y.Z-rc.N` for a release candidate, with container images in `ghcr.io//` and a GitHub release whose notes are generated from the merged pull requests. The workflow publishes artifacts for `agent-substrate/substrate` and `kagent-dev/substrate`. How the notes are generated, and how to label and title a pull request for them, is in [Release notes](release-notes.md). ## Who may release -Pushing a `vX.Y.Z` or `vX.Y.Z-PRERELEASE` tag publishes that commit's images to `ghcr.io/agent-substrate/substrate` and creates a release, under the repository's token. Whoever can push such a tag can therefore publish images for any commit. Only release managers may push one. +Pushing a `vX.Y.Z` or `vX.Y.Z-PRERELEASE` tag publishes that commit's images to the repository's GHCR namespace and creates a draft release, under the repository's token. Whoever can push such a tag can therefore publish images for any commit. Only release managers may push one. A tag ruleset on `agent-substrate/substrate` that restricts creating, updating, and deleting `v[0-9]*` tags to release managers enforces this for tag pushes. An organization admin maintains it, and it has to be in place before the first release that publishes images. @@ -12,11 +12,12 @@ The ruleset limits tag pushes only. Anyone with write access can still publish i ## Cut a release -1. Tag the release commit and push the tag. The [`release`](../../.github/workflows/release.yaml) workflow does two things, in order: - 1. The `images` job runs `ate-setup publish release-images`, which builds every release image for `linux/amd64` and `linux/arm64`, pushes it to `ghcr.io/agent-substrate/substrate/:`, and then checks that every tag names the image just pushed, for both platforms. The ko images and `envoy-dataplane`, built from its Dockerfile with `docker buildx`, all cross-compile for arm64 without emulation. - 2. Once the images are verified, the `draft` job builds the `kubectl-ate` binaries with `ate-setup publish release-binaries`, then runs [`hack/release/draft-release.sh`](../../hack/release/draft-release.sh), which creates a draft release, and attaches the binaries to it. The notes cover every change since the previous `vX.Y.Z` release, say where the release images are published and link to the list below, give the command that installs them, give the steps to check and install `kubectl-ate`, and end with a list of committers. A release candidate is marked as a prerelease. +1. Tag the tested commit on the intended release branch and push the tag. Both tag pushes and manual runs of the [`release`](../../.github/workflows/release.yaml) workflow check out that tag: + 1. The `images` job runs `ate-setup publish release-images`, which builds every release image for `linux/amd64` and `linux/arm64`, pushes it to `ghcr.io///:`, and then checks that every tag names the image just pushed, for both platforms. The ko images and `envoy-dataplane`, built from its Dockerfile with `docker buildx`, all cross-compile for arm64 without emulation. + 2. In `kagent-dev/substrate`, the `charts` job publishes all three Helm charts after the images succeed. See [Fork Helm charts](#fork-helm-charts). + 3. Once artifact publishing succeeds, the `draft` job builds the `kubectl-ate` binaries with `ate-setup publish release-binaries`, then runs [`hack/release/draft-release.sh`](../../hack/release/draft-release.sh), which creates a draft release, and attaches the binaries to it. The notes cover every change since the previous `vX.Y.Z` release, say where the release images are published and link to the list below, give the command that installs them, give the steps to check and install `kubectl-ate`, and end with a list of committers. A release candidate is marked as a prerelease. - If the `images` job or the binary build fails, no draft is created. If attaching the binaries fails, the draft exists without them. Either way, fix the cause and run the workflow again from the Actions tab with the tag as input. + If images, charts, or the binary build fails, no new draft is created. If attaching the binaries fails, the draft exists without them. Either way, fix the cause and run the workflow again from the Actions tab with the tag as input. 2. Edit the draft: - Write a summary at the top. - Rewrite each breaking change with its upgrade step. @@ -37,7 +38,7 @@ To preview the notes before tagging, run the dry run with the tag you plan to pu ## Release images -A release publishes `ate-idp-server`, `ateapi`, `atecontroller`, `atelet`, `atenet`, `ateworker-gvisor`, `ateworker-microvm`, `kubernetes-secrets` (the Kubernetes Secrets credential provider), `objectstore-plugin`, and `podcertcontroller` (`images.ReleaseComponents` in [`cmd/ate-setup/internal/images`](../../cmd/ate-setup/internal/images/images.go)), plus `envoy-dataplane` from [`cmd/dataplane/envoy`](../../cmd/dataplane/envoy), as `ghcr.io/agent-substrate/substrate/:`. The object storage plugin ships with releases until it moves out of this repository. +A release publishes `ate-idp-server`, `ateapi`, `atecontroller`, `atelet`, `atenet`, `ateworker-gvisor`, `ateworker-microvm`, `kubernetes-secrets` (the Kubernetes Secrets credential provider), `objectstore-plugin`, and `podcertcontroller` (`images.ReleaseComponents` in [`cmd/ate-setup/internal/images`](../../cmd/ate-setup/internal/images/images.go)), plus `envoy-dataplane` from [`cmd/dataplane/envoy`](../../cmd/dataplane/envoy), as `ghcr.io///:`. The object storage plugin ships with releases until it moves out of this repository. Both lists are allowlists in `images.go`: `released` for the ko images and `releasedDockerfiles` for the images built from a Dockerfile. A new image is not published until a pull request adds it there, so publishing an image is always an explicit, reviewed change. @@ -45,23 +46,34 @@ It leaves out the demos and the benchmarking fakes, which an install deploys onl Tags that begin `vX.Y.Z` start the workflow. `ate-setup` refuses any version that is not `vX.Y.Z` or `vX.Y.Z-PRERELEASE` before building, so a tag like `v1.2.3foo` publishes nothing. -Only `agent-substrate/substrate` publishes images. In a fork the `images` job is skipped, since GitHub bills package storage to the fork's owner, and the draft is created without them. - -The kagent-dev fork also provides the manually dispatched -[`release-fork`](../../.github/workflows/release-fork.yaml) workflow. It publishes -the fork's component images, including the Kubernetes Secrets credential -provider, to `ghcr.io/kagent-dev/substrate`. With `create_release` enabled, it -also publishes the three Helm charts and attaches `kubectl-ate` binaries to -the release. Without that option it publishes only images, using the supplied -tag or a branch-and-commit tag. The separate -[`agentgateway-image`](../../.github/workflows/agentgateway-image.yaml) workflow -builds the fork's agentgateway image from a selected source revision. +`agent-substrate/substrate` and `kagent-dev/substrate` publish images and binaries. +Other forks create a draft without artifacts, since GitHub bills package storage +to the fork's owner. The image publisher accepts `--source-url` or +`ATE_PUBLISH_SOURCE_URL` to set the OCI source label. The workflow sets it to the +repository's GitHub URL so GHCR links packages to the correct repository. + +The kagent-dev fork uses this workflow for every maintained release branch. +The previous `release-fork` workflow has been removed. Manual runs now require +an existing release tag and create a draft after publishing artifacts; they do +not generate branch-and-commit image tags. Image tags are versioned, and the +workflow does not move `latest`. Do not copy upstream tags into the fork as part +of branch synchronization: matching tag pushes trigger publishing. The workflow +and publisher changes must be present in a release's tagged commit. + +Keep downstream tags on downstream commits and record their upstream base in the +release notes. Suffixed tags are prereleases; the notes start at the newest +unsuffixed `vX.Y.Z` tag below the release's version. Check that baseline when +releasing from multiple branches. + +The separate [`agentgateway-image`](../../.github/workflows/agentgateway-image.yaml) +workflow builds AgentGateway from a selected source revision. The Substrate +release workflow does not rebuild that dependency or change the chart's image pin. A rerun builds nothing if every image already exists for the tag on both platforms. An incomplete set, left by a failed run, is rebuilt in full. To rebuild and replace a complete set, run the workflow manually with `overwrite` checked. Replacing images changes their digests, so once the tag has a published (non-draft) release the `images` job publishes nothing and fails, with or without `overwrite`. To test the image build without publishing anything, for example in a fork, run the workflow manually with `dry_run_images` checked. The images are pushed to a registry that exists only inside the job and are checked there. No draft is created. -GitHub creates a new package as private. After the first release that adds an image, an owner of the `agent-substrate` organization must make the package public in its package settings, otherwise `ate-setup --image-repo` cannot read it without credentials. +GitHub creates a new package as private. After the first release that adds an image, an owner of the publishing organization must make the package public in its package settings, otherwise `ate-setup --image-repo` cannot read it without credentials. To publish images by hand, check out the tag in a clean clone, log in to the registry with `docker login`, and run: @@ -70,13 +82,16 @@ VERSION=v0.3.0 KO_DEFAULTPLATFORMS=linux/amd64,linux/arm64 \ go run ./cmd/ate-setup publish release-images --ko-docker-repo ``` +For a fork, also pass `--source-url https://github.com//`. +It defaults to the upstream URL for local builds. + The images are built from the work tree, so it must hold the tag and nothing else. The build needs Go, `ko` (which `ate-setup` runs from `hack/tools/ko`), and `docker buildx` for `envoy-dataplane`. A multi-platform push needs a builder that supports it, such as one made with `docker buildx create --driver docker-container --use`; the default `docker` driver supports it only with the containerd image store. ## Release binaries The `draft` job attaches `kubectl-ate` for `linux/amd64`, `linux/arm64`, `darwin/amd64`, and `darwin/arm64`, as raw binaries named `kubectl-ate--`, each with a `.sha256` file. There is no Windows build. The binaries are static (`CGO_ENABLED=0`) and stamped with the tag, the commit, and the commit time, which `kubectl ate --version` prints. -Only `agent-substrate/substrate` attaches binaries; a fork's draft has none. A rerun replaces the binaries only while the release is still a draft. +`agent-substrate/substrate` and `kagent-dev/substrate` attach binaries. A rerun replaces the binaries only while the release is still a draft. To check and install one: @@ -90,3 +105,13 @@ To build them by hand, check out the tag in a clean clone and run: ```sh VERSION=v0.3.0 go run ./cmd/ate-setup publish release-binaries ``` + +## Fork Helm charts + +The fork publishes `substrate-crds`, `substrate`, and `substrate-podcert` to +`oci://ghcr.io/kagent-dev/substrate/helm`. Their chart version is the release tag +without `v`; their `appVersion` is the full tag, which selects the matching +component images. All charts are packaged before any chart is pushed. A failed +push may leave a partial set; rerun the workflow while the release is a draft. +The chart publisher refuses writes once the release is published, including +when `overwrite` is set for images. diff --git a/hack/release/draft-release.sh b/hack/release/draft-release.sh index d598bc9fea..5e749777af 100755 --- a/hack/release/draft-release.sh +++ b/hack/release/draft-release.sh @@ -37,9 +37,8 @@ UPSTREAM_REPO='agent-substrate/substrate' REPO="${GITHUB_REPOSITORY:-${UPSTREAM_REPO}}" SEMVER='^v[0-9]+\.[0-9]+\.[0-9]+$' SEMVER_PRE='^v[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.-]+$' -# Where the upstream release workflow publishes images (KO_DOCKER_REPO in -# .github/workflows/release.yaml). -RELEASE_IMAGE_REPO='ghcr.io/agent-substrate/substrate' +# Use the same destination as the image publisher. +RELEASE_IMAGE_REPO="${KO_DOCKER_REPO:-ghcr.io/${REPO}}" usage() { echo "usage: $0 [--dry-run] " >&2 @@ -146,10 +145,9 @@ if ! notes="$(api "generating notes" "repos/${REPO}/releases/generate-notes" \ exit 1 fi -# Only the upstream release workflow publishes images (see -# .github/workflows/release.yaml), so only its notes say where they are. +# These repositories publish images and binaries in the release workflow. # docs/dev/releasing.md lists what they cover. -if [[ "${REPO}" == "${UPSTREAM_REPO}" ]]; then +if [[ "${REPO}" == "${UPSTREAM_REPO}" || "${REPO}" == "kagent-dev/substrate" ]]; then notes+=$'\n\n## Images\n\n' notes+="Container images for this release are published as \`${RELEASE_IMAGE_REPO}/:${tag}\`" notes+=" for \`linux/amd64\` and \`linux/arm64\`. The images a release publishes are listed in" @@ -177,6 +175,12 @@ if [[ "${REPO}" == "${UPSTREAM_REPO}" ]]; then notes+='```' fi +if [[ "${REPO}" == "kagent-dev/substrate" ]]; then + notes+=$'\n\n## Helm charts\n\n' + notes+="Charts are published at \`oci://${RELEASE_IMAGE_REPO}/helm\` with version \`${tag#v}\`:" + notes+=$'\n\n- `substrate-crds`\n- `substrate`\n- `substrate-podcert`' +fi + # GitHub only lists first-time contributors; list everyone, as v0.1.0 did. # get-contributors.sh reads the range from the local clone, so both ends must # be present locally (the release workflow checks out full history). diff --git a/hack/release/release_test.go b/hack/release/release_test.go new file mode 100644 index 0000000000..070d684430 --- /dev/null +++ b/hack/release/release_test.go @@ -0,0 +1,149 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package release_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func releaseEnv(t *testing.T) (string, string) { + t.Helper() + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + for _, name := range []string{"gh", "helm"} { + if err := os.Symlink(filepath.Join(root, "hack/release/testdata", name+".sh"), filepath.Join(bin, name)); err != nil { + t.Fatal(err) + } + } + log := filepath.Join(bin, "helm.log") + if err := os.WriteFile(log, nil, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("RELEASE_TEST_LOG", log) + t.Setenv("RELEASE_TEST_STATE", "missing") + t.Setenv("RELEASE_TEST_FAIL", "") + t.Setenv("VERSION", "v1.0.0") + t.Setenv("GITHUB_REPOSITORY", "kagent-dev/substrate") + t.Setenv("GITHUB_ACTOR", "release-test") + t.Setenv("GH_TOKEN", "test-token") + t.Setenv("KO_DOCKER_REPO", "ghcr.io/kagent-dev/substrate") + return root, log +} + +func TestPublishCharts(t *testing.T) { + for _, tc := range []struct { + name, state, fail, version string + wantErr, wantPush bool + }{ + {name: "new release", state: "missing", version: "v1.0.0", wantPush: true}, + {name: "draft rerun", state: "draft", version: "v1.0.0", wantPush: true}, + {name: "prerelease", state: "missing", version: "v1.0.0-rc.1", wantPush: true}, + {name: "published", state: "published", version: "v1.0.0", wantErr: true}, + {name: "API failure", state: "unavailable", version: "v1.0.0", wantErr: true}, + {name: "package failure", state: "missing", fail: "package", version: "v1.0.0", wantErr: true}, + {name: "login failure", state: "missing", fail: "registry", version: "v1.0.0", wantErr: true}, + {name: "push failure", state: "missing", fail: "push", version: "v1.0.0", wantErr: true, wantPush: true}, + {name: "invalid version", state: "missing", version: "v1.0.0,latest", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + root, log := releaseEnv(t) + t.Setenv("VERSION", tc.version) + t.Setenv("RELEASE_TEST_STATE", tc.state) + t.Setenv("RELEASE_TEST_FAIL", tc.fail) + cmd := exec.Command("bash", filepath.Join(root, ".github/scripts/release/publish-charts.sh")) + cmd.Dir = root + out, err := cmd.CombinedOutput() + if (err != nil) != tc.wantErr { + t.Fatalf("publish charts: %v\n%s", err, out) + } + calls, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + got := string(calls) + if strings.Contains(got, "push ") != tc.wantPush { + t.Fatalf("unexpected registry writes:\n%s", got) + } + if tc.state == "published" || tc.state == "unavailable" || tc.name == "invalid version" { + if got != "" { + t.Fatalf("helm ran before release validation:\n%s", got) + } + } + if tc.wantErr { + return + } + if strings.Count(got, "push ") != 3 || strings.Count(got, "package ") != 3 { + t.Fatalf("incomplete chart set:\n%s", got) + } + for _, chart := range []string{"substrate-crds", "substrate", "substrate-podcert"} { + want := chart + "-" + strings.TrimPrefix(tc.version, "v") + ".tgz oci://ghcr.io/kagent-dev/substrate/helm" + if !strings.Contains(got, want) { + t.Errorf("missing %s in:\n%s", want, got) + } + } + if strings.Count(got, "--app-version "+tc.version) != 3 { + t.Errorf("charts do not select the release images:\n%s", got) + } + }) + } +} + +func TestDraftReleaseArtifactNotes(t *testing.T) { + for _, tc := range []struct { + repo, registry string + artifacts bool + }{ + {repo: "agent-substrate/substrate", artifacts: true}, + {repo: "kagent-dev/substrate", registry: "ghcr.io/kagent-dev/substrate", artifacts: true}, + {repo: "kagent-dev/substrate", registry: "registry.example.com/releases", artifacts: true}, + {repo: "someone/substrate"}, + } { + t.Run(tc.repo+"/"+tc.registry, func(t *testing.T) { + root, _ := releaseEnv(t) + t.Setenv("GITHUB_REPOSITORY", tc.repo) + t.Setenv("KO_DOCKER_REPO", tc.registry) + cmd := exec.Command("bash", filepath.Join(root, "hack/release/draft-release.sh"), "--dry-run", "v1.0.0") + cmd.Dir = root + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("draft notes: %v\n%s", err, out) + } + notes := string(out) + if strings.Contains(notes, "## Images") != tc.artifacts || strings.Contains(notes, "## kubectl-ate") != tc.artifacts { + t.Fatalf("incorrect artifact notes:\n%s", notes) + } + if strings.Contains(notes, "## Helm charts") != (tc.repo == "kagent-dev/substrate") { + t.Fatalf("incorrect chart notes:\n%s", notes) + } + if tc.artifacts { + repo := tc.registry + if repo == "" { + repo = "ghcr.io/" + tc.repo + } + if !strings.Contains(notes, "--image-repo "+repo+" --image-tag v1.0.0") { + t.Errorf("incorrect install image repository:\n%s", notes) + } + } + }) + } +} diff --git a/hack/release/testdata/gh.sh b/hack/release/testdata/gh.sh new file mode 100755 index 0000000000..b4c8630a41 --- /dev/null +++ b/hack/release/testdata/gh.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit -o nounset -o pipefail + +case "$*" in + "api repos/"*"/releases/tags/"*) + case "${RELEASE_TEST_STATE}" in + missing) echo 'HTTP 404' >&2; exit 1 ;; + published) echo false ;; + draft) echo true ;; + unavailable) echo 'HTTP 403' >&2; exit 1 ;; + *) exit 2 ;; + esac + ;; + "api repos/"*"/git/ref/tags/"*) ;; + "api repos/"*"/tags "*) echo v1.0.0 ;; + "api repos/"*"/releases/generate-notes "*) echo 'Generated notes.' ;; + *) echo "unexpected gh arguments: $*" >&2; exit 2 ;; +esac diff --git a/hack/release/testdata/helm.sh b/hack/release/testdata/helm.sh new file mode 100755 index 0000000000..bcad018e37 --- /dev/null +++ b/hack/release/testdata/helm.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit -o nounset -o pipefail + +printf '%s\n' "$*" >>"${RELEASE_TEST_LOG}" +if [[ "${RELEASE_TEST_FAIL:-}" == "$1" ]]; then + echo "helm $1 failed" >&2 + exit 1 +fi +case "$1" in + package) + chart="${2##*/}" + shift 2 + destination= + version= + while [[ $# -gt 0 ]]; do + case "$1" in + --destination) destination="$2" ;; + --version) version="$2" ;; + --app-version) ;; + *) exit 2 ;; + esac + shift 2 + done + touch "${destination}/${chart}-${version}.tgz" + ;; + registry) cat >/dev/null ;; + push) test -f "$2" ;; + *) exit 2 ;; +esac