name: Release run-name: Release v${{ github.event.inputs.version }} from ${{ github.ref_name }} # THE ONLY workflow in this repository that declares workflow_dispatch. # # The rickub dispatch API (POST /api/v1/repos/{owner}/{repo}/actions/dispatch, # and `rickub run dispatch`) triggers EVERY workflow_dispatch workflow on the # ref, so keeping this trigger unique means a dispatch can never accidentally # fan out into other pipelines. Cut releases from the web UI ("Run workflow"), # which can target a single workflow. See RELEASING.md. on: workflow_dispatch: inputs: version: description: "Version to release, without the leading v (e.g. 1.2.3)" required: true type: string dry_run: description: "Build and verify everything, but do not publish the release" required: false default: false type: boolean # Creating a release requires write access; the default token is read-only. permissions: contents: write concurrency: group: release-${{ github.event.inputs.version }} cancel-in-progress: false jobs: release: name: build and publish release runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: # Full history + tags so the "tag already exists" check is meaningful. fetch-depth: 0 - name: Validate inputs id: check env: VERSION_INPUT: ${{ github.event.inputs.version }} DRY_RUN_INPUT: ${{ github.event.inputs.dry_run }} run: | set -euo pipefail version="$(printf '%s' "${VERSION_INPUT}" | tr -d ' \t\r\n')" if [ -z "${version}" ]; then echo "::error::version input is empty" exit 1 fi if [ "${version#v}" != "${version}" ]; then echo "::error::Do not include the leading 'v'. Pass '${version#v}', not '${version}'." exit 1 fi if ! printf '%s' "${version}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then echo "::error::version must be MAJOR.MINOR.PATCH (e.g. 1.2.3), got '${version}'" exit 1 fi tag="v${version}" # Normalise the boolean input to an unambiguous string output; # step outputs are always strings, so `!= 'true'` is safe downstream. dry_run=false case "$(printf '%s' "${DRY_RUN_INPUT}" | tr '[:upper:]' '[:lower:]')" in true|1|yes|on) dry_run=true ;; esac # Cross-check the VERSION file. A mismatch is usually a forgotten bump # PR; it is a warning, not a hard failure, so hotfixes stay possible. if [ -f VERSION ]; then base="$(tr -d ' \t\r\n' < VERSION)" if [ "${base}" != "${version}" ]; then echo "::warning::VERSION file says '${base}' but you are releasing '${version}'." \ "Open a PR bumping VERSION to '${version}' so future RCs are numbered from it." fi else echo "::warning::No VERSION file at the repository root." fi { echo "version=${version}" echo "tag=${tag}" echo "dry_run=${dry_run}" } >> "$GITHUB_OUTPUT" echo "Releasing ${tag} at ${GITHUB_SHA} (dry_run=${dry_run})" - name: Refuse to overwrite an existing release env: TAG: ${{ steps.check.outputs.tag }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail # 1. Local/remote git tag — authoritative and always available. git fetch --tags --force --quiet || true if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null 2>&1; then echo "::error::Tag ${TAG} already exists. Bump the version instead of re-releasing." exit 1 fi if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then echo "::error::Tag ${TAG} already exists on the remote." exit 1 fi # 2. Release API — best effort. $GITHUB_API_URL already carries the # /gh prefix of the rickub REST shim. Anything other than a clear # 200 is treated as "cannot tell" so a shim/proxy hiccup does not # block a legitimate release (the git tag check above is the gate). code="$(curl -sS -o /tmp/release-probe.json -w '%{http_code}' \ -H "Authorization: Bearer ${GH_TOKEN}" \ -H "Accept: application/vnd.github+json" \ "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" || echo 000)" case "${code}" in 200) echo "::error::A release for ${TAG} already exists (API returned 200)." exit 1 ;; 404) echo "No existing release for ${TAG}." ;; *) echo "::warning::Could not check for an existing ${TAG} release (HTTP ${code});" \ "relying on the git tag check. A 403 here means the CI proxy still blocks" \ "/gh paths — see RELEASING.md." ;; esac - name: Set up Go uses: actions/setup-go@v5 with: go-version-file: go.mod cache: true - name: go vet run: go vet ./... - name: go test run: go test ./... -count=1 - name: Build distribution run: bash scripts/build-dist.sh "${{ steps.check.outputs.version }}" - name: Release notes id: notes env: TAG: ${{ steps.check.outputs.tag }} run: | set -euo pipefail { echo "## rickub ${TAG}" echo echo "- Commit: \`${GITHUB_SHA}\` (\`${GITHUB_REF_NAME}\`)" echo "- Built by \`${GITHUB_WORKFLOW}\` run #${GITHUB_RUN_NUMBER}" echo echo '### Install' echo echo 'Download the archive for your platform, verify it, and drop the binary on your PATH:' echo echo '```sh' echo "tar -xzf rickub_${TAG#v}_\$(uname -s | tr '[:upper:]' '[:lower:]')_amd64.tar.gz" echo 'sha256sum -c SHA256SUMS --ignore-missing' echo 'install -m 0755 rickub /usr/local/bin/rickub' echo '```' echo echo '### Checksums' echo echo '```' cat dist/SHA256SUMS echo '```' } > "${RUNNER_TEMP}/release-notes.md" echo "path=${RUNNER_TEMP}/release-notes.md" >> "$GITHUB_OUTPUT" - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: rickub-${{ steps.check.outputs.version }} path: dist/ if-no-files-found: error retention-days: 90 - name: Publish release if: steps.check.outputs.dry_run != 'true' uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.check.outputs.tag }} name: ${{ steps.check.outputs.tag }} body_path: ${{ steps.notes.outputs.path }} # The tag does not exist yet: the rickub release shim creates it at # target_commitish, so the dispatched ref's commit is what gets tagged. target_commitish: ${{ github.sha }} draft: false prerelease: false files: | dist/*.tar.gz dist/SHA256SUMS fail_on_unmatched_files: true - name: Dry run summary if: steps.check.outputs.dry_run == 'true' run: | set -euo pipefail echo "::notice::Dry run — ${{ steps.check.outputs.tag }} was NOT published." echo "Artifacts that would have been released:" ls -l dist/