diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 567187eb..033ee663 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,15 @@ name: CI # Pull requests run the Windows tests only when Go sources, modules, test data, -# tools/, the configuration examples or this workflow change. Pushes, tags and -# releases always run them. +# tools/, the configuration examples or this workflow change. Pushes always run +# them. +# +# A release is built by exactly one run: the push of its tag, or the dispatch +# on its tag from the Create tag workflow. That run builds and signs the +# binaries once, pushes the container images and creates a draft release with +# the same executables, so the images and the release assets match. The draft +# is published by hand; with immutable releases, its assets and tag are locked +# from then on. on: push: branches: @@ -15,9 +22,6 @@ on: - "v*" pull_request: workflow_dispatch: - release: - types: - - published permissions: {} @@ -43,7 +47,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - # Pushes to main and the release branches, tags, releases and manual + # Pushes to main, the release branches and tags, and manual # runs always test, so a gap in the filter below cannot let a # regression reach a branch that ships. if [[ "$EVENT_NAME" != "pull_request" || -z "$BASE_SHA" ]]; then @@ -85,6 +89,18 @@ jobs: persist-credentials: 'false' fetch-depth: '0' + # A published release is immutable. Rebuilding its tag would push images + # whose executable differs from the release assets, so stop before that. + - name: Check that the release is not published + if: ${{ startsWith(github.ref, 'refs/tags/') }} + env: + GH_TOKEN: ${{ github.token }} + run: | + if is_draft="$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft 2>/dev/null)" && [[ "$is_draft" == "false" ]]; then + echo "::error::Release $GITHUB_REF_NAME is already published" + exit 1 + fi + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: # renovate: golang=go @@ -142,6 +158,10 @@ jobs: # renovate: github=goreleaser/goreleaser version: v2.18.3 args: build --clean + env: + # Several tags can point at one commit, such as an rc on the last + # beta. Build the version of the tag this run is for. + GORELEASER_CURRENT_TAG: ${{ github.ref_name }} - name: Verify Go overlay run: go run ./tools/go-overlay verify dist/windows_exporter-amd64.exe dist/windows_exporter-arm64.exe @@ -150,7 +170,11 @@ jobs: run: | mkdir -p output - version="$(git describe --tags --always)" + if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then + version="$GITHUB_REF_NAME" + else + version="$(git describe --tags --always)" + fi version="${version#v}" version="${version//+/_}" printf '%s\n' "$version" | tee output/VERSION @@ -449,15 +473,14 @@ jobs: annotations: ${{ steps.meta.outputs.labels }} publish: - # Publishing a release fires both a tag push and a release:published run for - # the same tag. Upload only from the release run: it is the only one where - # the release is guaranteed to exist, and uploading from both runs would - # race on the same asset names. - if: github.repository == 'prometheus-community/windows_exporter' && github.event_name == 'release' + # Draft the release only once the images are pushed, so a published release + # always has them. + if: github.repository == 'prometheus-community/windows_exporter' && startsWith(github.ref, 'refs/tags/') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') runs-on: ubuntu-26.04 needs: - package - windows-test + - docker permissions: contents: write steps: @@ -466,16 +489,33 @@ jobs: with: name: windows_exporter_binaries - - name: Publish Artifacts + - name: Create draft release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The job runs without a checkout, so gh cannot infer the repository. GH_REPO: ${{ github.repository }} run: | - gh release upload "$GITHUB_REF_NAME" \ - windows_exporter-*.exe \ - windows_exporter-*.msi \ - sha256sums.txt + assets=(windows_exporter-*.exe windows_exporter-*.msi sha256sums.txt) + + # A re-run replaces the assets of its own draft. A published release + # is immutable and must not be touched. + if is_draft="$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft 2>&1)"; then + if [[ "$is_draft" != "true" ]]; then + echo "::error::Release $GITHUB_REF_NAME is already published" + exit 1 + fi + gh release upload "$GITHUB_REF_NAME" --clobber "${assets[@]}" + exit 0 + elif [[ "$is_draft" != *"release not found"* ]]; then + echo "::error::Cannot look up release $GITHUB_REF_NAME: $is_draft" + exit 1 + fi + + args=(--draft --verify-tag --generate-notes --title "$GITHUB_REF_NAME") + if [[ "$GITHUB_REF_NAME" == *-* ]]; then + args+=(--prerelease) + fi + gh release create "$GITHUB_REF_NAME" "${args[@]}" "${assets[@]}" windows-test: name: Windows tests (${{ matrix.os }}) @@ -1831,9 +1871,8 @@ jobs: super-linter: name: Super Linter - # Super Linter cannot derive the commit range VALIDATE_ALL_CODEBASE=false - # needs from release events; the tag push run already lints the same commit. - if: github.event_name != 'release' + # The push run for the branch a tag is cut from already linted the commit. + if: ${{ !startsWith(github.ref, 'refs/tags/') }} runs-on: ubuntu-26.04 permissions: contents: read @@ -1884,6 +1923,7 @@ jobs: - build - package - docker + - publish - windows-test - lint - super-linter diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 00000000..1c1c7372 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,64 @@ +name: Create tag +run-name: ${{ inputs.tag }} + +# Creates a release tag on the commit the workflow runs on and starts CI for it. +# A tag created with GITHUB_TOKEN does not trigger the tag push run, but a +# workflow_dispatch does, so CI runs exactly once for the tag. That run builds +# and signs the binaries, pushes the container images and creates the draft +# release. Pushing a tag from a local clone triggers the same run directly. +on: + workflow_dispatch: + inputs: + tag: + description: Tag to create, such as v1.2.3 or v1.2.3-beta.1 + required: true + type: string + +permissions: {} + +jobs: + create: + name: Create tag + if: github.repository == 'prometheus-community/windows_exporter' + runs-on: ubuntu-26.04 + permissions: + actions: write + contents: write + steps: + - name: Create tag and start CI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + run: | + # The CI build derives the Windows file version from the tag and + # accepts only these forms: alpha.N and beta.N up to 99, rc.N up to + # 899. + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.([0-9]+))?$ ]]; then + echo "::error::Tag $TAG must look like v1.2.3, v1.2.3-beta.1 or v1.2.3-rc.1" + exit 1 + fi + number="${BASH_REMATCH[3]}" + case "${BASH_REMATCH[2]}" in + alpha|beta) limit=99 ;; + rc) limit=899 ;; + esac + if [[ -n "$number" ]] && (( 10#$number > limit )); then + echo "::error::Pre-release number of tag $TAG does not fit the file version scheme" + exit 1 + fi + + # A retry after a failed dispatch finds the tag already on this + # commit and only starts CI again. + if existing="$(gh api "repos/$GH_REPO/git/ref/tags/$TAG" --jq .object.sha 2>/dev/null)"; then + if [[ "$existing" != "$GITHUB_SHA" ]]; then + echo "::error::Tag $TAG already exists on $existing" + exit 1 + fi + else + gh api --method POST "repos/$GH_REPO/git/refs" \ + -f "ref=refs/tags/$TAG" \ + -f "sha=$GITHUB_SHA" + fi + + gh workflow run ci.yml --ref "$TAG" diff --git a/AGENTS.md b/AGENTS.md index 90a2f84c..069707cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,15 @@ their runtime behavior. for `alpha.N`/`beta.N`, 100+N for `rc.N` and 1000 for the release, so MDM tools that compare file versions update from one beta to the next and to the final release. Windows Installer itself ignores the fourth field. +- A release is built once, by the CI run for its tag: a tag push, or the + dispatch that the Create tag workflow + ([`tag.yml`](.github/workflows/tag.yml)) starts after creating the tag with + `GITHUB_TOKEN`, which cannot trigger the push run itself. That run pushes the + container images and creates a draft release with the same signed + executables; a maintainer publishes it. Releases are immutable once + published, so never upload assets after publishing, and never let a second + run build the same tag. Start releases only with that workflow or a tag push, + never by creating a release with a new tag in the GitHub UI. New collectors need discussion with maintainers first. Prefer the configurable `performancecounter` collector where it can provide the requested metrics, as