# Release pipeline. Pushing a tag that matches the identity in versions.env # publishes both images (version + latest), creates the GitHub Release from # git-cliff, and opens a PR that refreshes CHANGELOG.md on main. # # The tag must be dsh-vX.Y.Z or dsh-vX.Y.Z-rN and must match versions.env; # tags are immutable, so a broken release is fixed with a new -rN revision. name: Release on: push: tags: - "dsh-v*" workflow_dispatch: inputs: tag: description: Existing tag to release (must match versions.env) required: true type: string # Read-only by default; the build and publish jobs add the write scopes they # need as job-level grants. permissions: contents: read concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: meta: name: Validate release identity runs-on: ubuntu-latest timeout-minutes: 15 outputs: identity: ${{ steps.meta.outputs.identity }} dsh_version: ${{ steps.meta.outputs.dsh_version }} dsh_commit: ${{ steps.meta.outputs.dsh_commit }} pnpm_version: ${{ steps.meta.outputs.pnpm_version }} release_revision: ${{ steps.meta.outputs.release_revision }} ghcr_image: ${{ steps.meta.outputs.ghcr_image }} gateway_image: ${{ steps.meta.outputs.gateway_image }} tag: ${{ steps.meta.outputs.tag }} run_tests: ${{ steps.meta.outputs.run_tests }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Validate tag against versions.env id: meta shell: bash env: # Passed through the environment, never interpolated into the script: # a tag name or dispatch input can contain shell metacharacters. REQUESTED_TAG: ${{ inputs.tag || github.ref_name }} run: | set -euo pipefail set -a # shellcheck disable=SC1091 source versions.env set +a tag="$REQUESTED_TAG" identity="$DSH_VERSION" [ -z "$RELEASE_REVISION" ] || identity="$DSH_VERSION-$RELEASE_REVISION" if [ "$tag" != "$identity" ]; then echo "tag '$tag' does not match versions.env identity '$identity'" >&2 exit 1 fi if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then git fetch origin main '+refs/heads/release/*:refs/remotes/origin/release/*' commit=$(git rev-list -n1 "refs/tags/$tag") allowed=0 for ref in origin/main origin/release/*; do if git rev-parse -q --verify "$ref" >/dev/null && git merge-base --is-ancestor "$commit" "$ref"; then allowed=1 break fi done if [ "$allowed" -ne 1 ]; then echo "tag commit $commit is not on main or a release branch" >&2 exit 1 fi # Tests already ran in PR CI when the tag is on main; only re-run # them for release-branch tags (where ci.yaml does not trigger). run_tests=true if git rev-parse -q --verify origin/main >/dev/null && git merge-base --is-ancestor "$commit" origin/main; then run_tests=false fi else echo "tag '$tag' does not exist in this repository" >&2 exit 1 fi if ! upstream="$(git ls-remote --tags --exit-code "$DSH_REPO" \ "refs/tags/$DSH_VERSION^{}" "refs/tags/$DSH_VERSION")"; then echo "upstream ref '$DSH_VERSION' not found in $DSH_REPO" >&2 exit 1 fi # Never release a ref that no longer resolves to the pinned commit: an # annotated tag has a peeled ^{} line, a lightweight tag only the ref. resolved="$(printf '%s\n' "$upstream" \ | awk -v t="refs/tags/$DSH_VERSION^{}" '$2 == t {print $1}' | head -n1)" if [ -z "$resolved" ]; then resolved="$(printf '%s\n' "$upstream" \ | awk -v t="refs/tags/$DSH_VERSION" '$2 == t {print $1}' | head -n1)" fi if [ "$resolved" != "$DSH_COMMIT" ]; then echo "upstream $DSH_REPO@$DSH_VERSION resolved to '$resolved'," \ "versions.env pins DSH_COMMIT=$DSH_COMMIT" >&2 exit 1 fi { echo "identity=$identity" echo "dsh_version=$DSH_VERSION" echo "dsh_commit=$DSH_COMMIT" echo "pnpm_version=$PNPM_VERSION" echo "release_revision=$RELEASE_REVISION" echo "ghcr_image=$GHCR_IMAGE" echo "gateway_image=$GATEWAY_IMAGE" echo "tag=$tag" echo "run_tests=$run_tests" } >> "$GITHUB_OUTPUT" - name: Verify repository pins run: ./scripts/check-versions.sh tests: name: Unit tests and lint needs: meta # The test steps are guarded individually rather than the whole job so a # main-branch tag (run_tests=false) still leaves this job "successful" and # does not cascade-skip build-main/build-gateway. runs-on: ubuntu-latest timeout-minutes: 15 defaults: run: working-directory: examples/dsh-agents-as-a-service/gateway steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python if: needs.meta.outputs.run_tests == 'true' uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.12" cache: pip cache-dependency-path: | examples/dsh-agents-as-a-service/gateway/requirements.txt examples/dsh-agents-as-a-service/gateway/requirements-dev.txt - name: Install dependencies if: needs.meta.outputs.run_tests == 'true' run: | set -euo pipefail python -m pip install --upgrade pip pip install --require-hashes -r requirements.txt -r requirements-dev.txt - name: Lint if: needs.meta.outputs.run_tests == 'true' run: | set -euo pipefail python -m ruff check . python -m ruff format --check . - name: Unit tests if: needs.meta.outputs.run_tests == 'true' run: python -m pytest -q build-main: name: Publish main image needs: [meta, tests] uses: ./.github/workflows/_build-image.yaml permissions: contents: read packages: write id-token: write attestations: write with: image: ${{ needs.meta.outputs.ghcr_image }} version: ${{ needs.meta.outputs.identity }} context: . dockerfile: Dockerfile build-args: | DSH_VERSION=${{ needs.meta.outputs.dsh_version }} DSH_COMMIT=${{ needs.meta.outputs.dsh_commit }} RELEASE_REVISION=${{ needs.meta.outputs.release_revision }} PNPM_VERSION=${{ needs.meta.outputs.pnpm_version }} VCS_REF=${{ github.sha }} tags: | ${{ needs.meta.outputs.ghcr_image }}:${{ needs.meta.outputs.identity }} ${{ needs.meta.outputs.ghcr_image }}:latest build-gateway: name: Publish gateway image needs: [meta, tests] uses: ./.github/workflows/_build-image.yaml permissions: contents: read packages: write id-token: write attestations: write with: image: ${{ needs.meta.outputs.gateway_image }} version: ${{ needs.meta.outputs.identity }} context: examples/dsh-agents-as-a-service/gateway dockerfile: Dockerfile tags: | ${{ needs.meta.outputs.gateway_image }}:${{ needs.meta.outputs.identity }} ${{ needs.meta.outputs.gateway_image }}:latest publish-release: name: Changelog and GitHub Release needs: [meta, build-main, build-gateway] runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: write pull-requests: write steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Install git-cliff env: GIT_CLIFF_VERSION: v2.14.2 # SHA-256 of git-cliff-2.14.2-x86_64-unknown-linux-gnu.tar.gz; upstream # publishes the matching SHA-512 next to the asset. Update with the version. GIT_CLIFF_SHA256: 24f397c733add5390fdceee3a2088588ab0d5f944ce00d34cb7029b888cf2db4 run: | set -euo pipefail version="${GIT_CLIFF_VERSION#v}" asset="git-cliff-${version}-x86_64-unknown-linux-gnu.tar.gz" curl -fsSL --proto '=https' -o "/tmp/$asset" \ "https://github.com/orhun/git-cliff/releases/download/$GIT_CLIFF_VERSION/$asset" printf '%s %s\n' "$GIT_CLIFF_SHA256" "/tmp/$asset" | sha256sum -c - tar -xzf "/tmp/$asset" -C /tmp install -m 0755 "/tmp/git-cliff-$version/git-cliff" /usr/local/bin/git-cliff git-cliff --version - name: Generate CHANGELOG and release notes env: IDENTITY: ${{ needs.meta.outputs.identity }} run: | set -euo pipefail git-cliff --tag "$IDENTITY" --output CHANGELOG.md git-cliff --tag "$IDENTITY" --latest --strip header --output /tmp/release-notes.md echo "### Release $IDENTITY" >> "$GITHUB_STEP_SUMMARY" head -n 40 /tmp/release-notes.md >> "$GITHUB_STEP_SUMMARY" - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} IDENTITY: ${{ needs.meta.outputs.identity }} run: | set -euo pipefail if gh release view "$IDENTITY" >/dev/null 2>&1; then echo "release $IDENTITY already exists; skipping" >&2 else gh release create "$IDENTITY" --title "$IDENTITY" --notes-file /tmp/release-notes.md --verify-tag fi - name: Open CHANGELOG pull request env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} IDENTITY: ${{ needs.meta.outputs.identity }} continue-on-error: true run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" if git diff --quiet -- CHANGELOG.md; then echo "CHANGELOG.md already up to date" exit 0 fi branch="chore/changelog-$IDENTITY" git switch -c "$branch" git add CHANGELOG.md git commit -m "chore(changelog): refresh for $IDENTITY" git push origin "$branch" gh pr create \ --base main \ --head "$branch" \ --title "chore(changelog): refresh for $IDENTITY" \ --body "Generated by git-cliff for $IDENTITY. Do not edit CHANGELOG.md by hand."