|
| 1 | +name: Release |
| 2 | + |
| 3 | +# Triggered ONLY on annotated tag push matching `v*.*.*`. The |
| 4 | +# expected sequence is: |
| 5 | +# |
| 6 | +# 1. Commit + push CHANGELOG bump on main (manual). |
| 7 | +# 2. `git tag -a vX.Y.Z -m "..."` against that commit (manual). |
| 8 | +# 3. `git push origin vX.Y.Z` — this workflow fires. |
| 9 | +# |
| 10 | +# We do NOT re-run unit tests here; the tag must already point at a |
| 11 | +# commit that's green on the `ci.yml` workflow. Re-testing would |
| 12 | +# double the wall-clock for tag bumps without catching anything CI |
| 13 | +# wouldn't. |
| 14 | +on: |
| 15 | + push: |
| 16 | + tags: ['v*.*.*'] |
| 17 | + |
| 18 | +# The GITHUB_TOKEN needs explicit write scopes for the publish steps — |
| 19 | +# the default is read-only. `packages: write` is for ghcr.io; `contents: |
| 20 | +# write` is for the Release entry. |
| 21 | +permissions: |
| 22 | + contents: write |
| 23 | + packages: write |
| 24 | + |
| 25 | +jobs: |
| 26 | + # ------------------------------------------------------------------- |
| 27 | + # Docker images — server + web in parallel via matrix |
| 28 | + # ------------------------------------------------------------------- |
| 29 | + build-images: |
| 30 | + name: Image · ${{ matrix.component }} |
| 31 | + runs-on: ubuntu-latest |
| 32 | + strategy: |
| 33 | + matrix: |
| 34 | + component: [server, web] |
| 35 | + steps: |
| 36 | + - uses: actions/checkout@v4 |
| 37 | + |
| 38 | + - name: Reclaim runner disk |
| 39 | + # `Dockerfile.server` links 40+ test binaries + ships a stripped |
| 40 | + # release build; the default ubuntu-latest ~14 GiB freespace is |
| 41 | + # not enough. Mirror what ci.yml's rust job does — drop the |
| 42 | + # preinstalled CodeQL / dotnet / Android toolchains before |
| 43 | + # buildx allocates layers. |
| 44 | + run: | |
| 45 | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ |
| 46 | + /opt/hostedtoolcache/CodeQL /usr/local/.ghcup |
| 47 | + df -h / |
| 48 | +
|
| 49 | + - name: Set up QEMU |
| 50 | + uses: docker/setup-qemu-action@v3 |
| 51 | + |
| 52 | + - name: Set up Buildx |
| 53 | + uses: docker/setup-buildx-action@v3 |
| 54 | + |
| 55 | + - name: Log in to GHCR |
| 56 | + uses: docker/login-action@v3 |
| 57 | + with: |
| 58 | + registry: ghcr.io |
| 59 | + username: ${{ github.actor }} |
| 60 | + password: ${{ secrets.GITHUB_TOKEN }} |
| 61 | + |
| 62 | + - name: Resolve version + image metadata |
| 63 | + id: meta |
| 64 | + run: | |
| 65 | + set -eu |
| 66 | + VERSION="${GITHUB_REF_NAME#v}" |
| 67 | + echo "version=$VERSION" >> "$GITHUB_OUTPUT" |
| 68 | + # Lowercase repo owner — GHCR rejects mixed case. This |
| 69 | + # matches the default in the project's Helm values |
| 70 | + # (`ghcr.io/thinkwatch/...`) so a tag bump publishes to the |
| 71 | + # repo Helm already points at. |
| 72 | + OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')" |
| 73 | + echo "image=ghcr.io/$OWNER/think-watch-${{ matrix.component }}" >> "$GITHUB_OUTPUT" |
| 74 | + # Tag `:latest` ONLY for stable releases — anything that |
| 75 | + # looks like a pre-release (`0.x.y`, `-rc`, `-beta`, `-alpha`) |
| 76 | + # stays version-pinned. Operators chase `:latest` at their |
| 77 | + # own risk; betas don't get to surprise them. |
| 78 | + PRERELEASE=false |
| 79 | + case "$GITHUB_REF_NAME" in |
| 80 | + v0.*|*-rc.*|*-beta.*|*-alpha.*) PRERELEASE=true ;; |
| 81 | + esac |
| 82 | + echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT" |
| 83 | +
|
| 84 | + - name: Build & push image |
| 85 | + uses: docker/build-push-action@v6 |
| 86 | + with: |
| 87 | + context: . |
| 88 | + file: deploy/docker/Dockerfile.${{ matrix.component }} |
| 89 | + platforms: linux/amd64,linux/arm64 |
| 90 | + push: true |
| 91 | + # Always publish the version tag; only publish `:latest` |
| 92 | + # for stable releases. The `if:` on the latter would |
| 93 | + # require splitting the step — `cat <<EOF` form keeps it |
| 94 | + # in one place and lets buildx skip the empty tag silently. |
| 95 | + tags: | |
| 96 | + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }} |
| 97 | + ${{ steps.meta.outputs.prerelease == 'false' && format('{0}:latest', steps.meta.outputs.image) || '' }} |
| 98 | + # GHCR-side OCI labels — what `helm install` / `docker scout` |
| 99 | + # surface in cluster inventories. Pull the source / version / |
| 100 | + # license / authors from the tag rather than the Dockerfile |
| 101 | + # so they stay in lockstep with the GitHub Release. |
| 102 | + labels: | |
| 103 | + org.opencontainers.image.source=https://github.com/${{ github.repository }} |
| 104 | + org.opencontainers.image.version=${{ steps.meta.outputs.version }} |
| 105 | + org.opencontainers.image.revision=${{ github.sha }} |
| 106 | + org.opencontainers.image.licenses=BUSL-1.1 |
| 107 | + cache-from: type=gha,scope=${{ matrix.component }} |
| 108 | + cache-to: type=gha,scope=${{ matrix.component }},mode=max |
| 109 | + |
| 110 | + # ------------------------------------------------------------------- |
| 111 | + # Helm chart — packaged with the same version as the tag |
| 112 | + # ------------------------------------------------------------------- |
| 113 | + package-helm: |
| 114 | + name: Helm chart |
| 115 | + runs-on: ubuntu-latest |
| 116 | + steps: |
| 117 | + - uses: actions/checkout@v4 |
| 118 | + - uses: azure/setup-helm@v4 |
| 119 | + with: |
| 120 | + version: latest |
| 121 | + - name: Resolve version |
| 122 | + id: meta |
| 123 | + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" |
| 124 | + - name: Package chart |
| 125 | + # `--app-version` is the image tag the chart's deployment |
| 126 | + # templates reach for by default (via `tw.imageTag`). Pinning |
| 127 | + # it here keeps a fresh `helm install think-watch-X.Y.Z.tgz` |
| 128 | + # pointing at the matching image bundle from the build-images |
| 129 | + # job above. |
| 130 | + run: | |
| 131 | + helm package deploy/helm/think-watch \ |
| 132 | + --version "${{ steps.meta.outputs.version }}" \ |
| 133 | + --app-version "${{ steps.meta.outputs.version }}" \ |
| 134 | + --destination . |
| 135 | + - name: Upload chart artifact |
| 136 | + uses: actions/upload-artifact@v4 |
| 137 | + with: |
| 138 | + name: helm-chart |
| 139 | + path: think-watch-*.tgz |
| 140 | + retention-days: 30 |
| 141 | + |
| 142 | + # ------------------------------------------------------------------- |
| 143 | + # GitHub Release — attaches the Helm chart, links the images |
| 144 | + # ------------------------------------------------------------------- |
| 145 | + create-release: |
| 146 | + name: GitHub Release |
| 147 | + needs: [build-images, package-helm] |
| 148 | + runs-on: ubuntu-latest |
| 149 | + steps: |
| 150 | + - uses: actions/checkout@v4 |
| 151 | + - name: Download Helm chart artifact |
| 152 | + uses: actions/download-artifact@v4 |
| 153 | + with: |
| 154 | + name: helm-chart |
| 155 | + path: . |
| 156 | + - name: Extract release notes from CHANGELOG |
| 157 | + id: notes |
| 158 | + # awk-based section extraction so we don't pull in another |
| 159 | + # dep just for `## [X.Y.Z]` markdown chunking. If the |
| 160 | + # CHANGELOG doesn't carry an entry for this tag, fail loudly |
| 161 | + # — releasing without notes hides what changed from operators. |
| 162 | + run: | |
| 163 | + set -eu |
| 164 | + VERSION="${GITHUB_REF_NAME#v}" |
| 165 | + awk -v ver="$VERSION" ' |
| 166 | + /^## \[/{ |
| 167 | + if ($0 ~ "^## \\[" ver "\\]") { found=1; next } |
| 168 | + else if (found) { exit } |
| 169 | + } |
| 170 | + found |
| 171 | + ' CHANGELOG.md > release-notes.md |
| 172 | + if [ ! -s release-notes.md ]; then |
| 173 | + echo "::error::No CHANGELOG entry for $VERSION — refusing to publish a release without notes." |
| 174 | + exit 1 |
| 175 | + fi |
| 176 | + # Add a leading reference to the GHCR images so users on the |
| 177 | + # Release page can copy-paste them without hunting through |
| 178 | + # the chart values. |
| 179 | + OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')" |
| 180 | + { |
| 181 | + echo "## Images" |
| 182 | + echo "" |
| 183 | + echo "- \`ghcr.io/$OWNER/think-watch-server:$VERSION\`" |
| 184 | + echo "- \`ghcr.io/$OWNER/think-watch-web:$VERSION\`" |
| 185 | + echo "" |
| 186 | + echo "## Helm" |
| 187 | + echo "" |
| 188 | + echo '```bash' |
| 189 | + echo "helm install think-watch ./think-watch-$VERSION.tgz \\" |
| 190 | + echo " --namespace thinkwatch --create-namespace" |
| 191 | + echo '```' |
| 192 | + echo "" |
| 193 | + echo "---" |
| 194 | + echo "" |
| 195 | + cat release-notes.md |
| 196 | + } > release-body.md |
| 197 | + - name: Detect pre-release flag |
| 198 | + id: meta |
| 199 | + run: | |
| 200 | + PRERELEASE=false |
| 201 | + case "$GITHUB_REF_NAME" in |
| 202 | + v0.*|*-rc.*|*-beta.*|*-alpha.*) PRERELEASE=true ;; |
| 203 | + esac |
| 204 | + echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT" |
| 205 | + - name: Create release |
| 206 | + uses: softprops/action-gh-release@v2 |
| 207 | + with: |
| 208 | + tag_name: ${{ github.ref_name }} |
| 209 | + name: ThinkWatch ${{ github.ref_name }} |
| 210 | + body_path: release-body.md |
| 211 | + prerelease: ${{ steps.meta.outputs.prerelease }} |
| 212 | + files: | |
| 213 | + think-watch-*.tgz |
0 commit comments