diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 83d4fa9..bff16c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,39 +1,129 @@ name: release on: - push: - tags: ['v*'] + workflow_run: + workflows: [Tests] + types: [completed] + branches: [main] + workflow_dispatch: permissions: + actions: read contents: write +concurrency: + group: provider-release + cancel-in-progress: false + jobs: goreleaser: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.event == 'push' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.workflow_run.head_sha || 'main' }} - uses: actions/setup-go@v5 with: go-version: '1.26.5' cache: true - - name: Verify tag is the current main commit + - name: Resolve release version + id: release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_RUN_SHA: ${{ github.event.workflow_run.head_sha }} run: | - git fetch --no-tags origin main + set -euo pipefail + version="$(tr -d '[:space:]' < VERSION)" + if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "VERSION must contain a semantic version such as 0.2.2" >&2 + exit 1 + fi + grep -Fqx "## ${version}" CHANGELOG.md + + tag="v${version}" + git fetch --force --tags origin main test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" + if [ -n "${WORKFLOW_RUN_SHA}" ]; then + test "${WORKFLOW_RUN_SHA}" = "$(git rev-parse HEAD)" + else + ci_sha="$(gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow test.yml \ + --branch main \ + --commit "$(git rev-parse HEAD)" \ + --event push \ + --status success \ + --limit 1 \ + --json headSha \ + --jq '.[0].headSha // ""')" + test "${ci_sha}" = "$(git rev-parse HEAD)" + fi + if git rev-parse --verify --quiet "refs/tags/${tag}^{commit}" >/dev/null; then + tag_exists=true + else + tag_exists=false + fi + + release_status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --header 'Accept: application/vnd.github+json' \ + --header "Authorization: Bearer ${GITHUB_TOKEN}" \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${tag}")" + case "${release_status}" in + 200) release_exists=true ;; + 404) release_exists=false ;; + *) + echo "GitHub release lookup failed with HTTP ${release_status}" >&2 + exit 1 + ;; + esac + if [ "${release_exists}" = "true" ]; then + test "${tag_exists}" = "true" + elif [ "${tag_exists}" = "true" ]; then + test "$(git rev-parse "refs/tags/${tag}^{commit}")" = "$(git rev-parse HEAD)" + fi + + printf 'tag=%s\n' "${tag}" >> "${GITHUB_OUTPUT}" + printf 'commit=%s\n' "$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + printf 'tag_exists=%s\n' "${tag_exists}" >> "${GITHUB_OUTPUT}" + printf 'release_exists=%s\n' "${release_exists}" >> "${GITHUB_OUTPUT}" - name: Verify source and tests + if: steps.release.outputs.release_exists != 'true' run: | + set -euo pipefail go mod tidy git diff --exit-code -- go.mod go.sum + go build ./... go test ./... - name: Import GPG key + if: steps.release.outputs.release_exists != 'true' uses: crazy-max/ghaction-import-gpg@v6 id: import_gpg with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.PASSPHRASE }} + - name: Create release tag + if: steps.release.outputs.release_exists != 'true' && steps.release.outputs.tag_exists != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_SHA: ${{ steps.release.outputs.commit }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + git tag "${RELEASE_TAG}" "${RELEASE_SHA}" + gh api --method POST \ + "repos/${GITHUB_REPOSITORY}/git/refs" \ + --raw-field "ref=refs/tags/${RELEASE_TAG}" \ + --raw-field "sha=${RELEASE_SHA}" >/dev/null - name: Run GoReleaser + if: steps.release.outputs.release_exists != 'true' uses: goreleaser/goreleaser-action@v6 with: version: latest diff --git a/.goreleaser.yml b/.goreleaser.yml index cd56610..ded9dc1 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -39,6 +39,8 @@ signs: - "${artifact}" release: + use_existing_draft: true + replace_existing_artifacts: true extra_files: - glob: 'terraform-registry-manifest.json' name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' diff --git a/AGENTS.md b/AGENTS.md index 8586035..949ac18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,8 @@ Keep implementation, documentation, examples, tests, commits, and releases gener and a generic example. 3. Regenerate documentation after every schema or example change. 4. Update `docs/api-coverage.md` and `CHANGELOG.md` when coverage or behaviour changes. -5. Run the smallest relevant checks, then the complete release gate before tagging. +5. Run the smallest relevant checks, then the complete release gate before changing + `VERSION` for a release. ```sh gofmt -w @@ -56,8 +57,10 @@ run `make generate`. Never bypass a generated-doc diff. ## Releases -- Use semantic version tags from a tested commit on `main`. -- Update the changelog before tagging. -- Let the tag-triggered GitHub Actions workflow run GoReleaser and create signed assets. +- Keep the exact semantic version in `VERSION` and add the matching changelog heading. +- Merge the tested `VERSION` change to `main`; do not push release tags manually. +- After the exact `main` test workflow succeeds, the release workflow verifies the + commit and version, creates the matching tag, and runs GoReleaser. A manual dispatch + safely retries an interrupted current-`main` release without moving an existing tag. - Verify the test workflow, release workflow, checksums, signature, manifest, and latest published version before considering a release complete. diff --git a/README.md b/README.md index 26ea015..c04a872 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,9 @@ make fmt # gofmt Read-only acceptance tests require `AIRLOCK_URL`, `AIRLOCK_API_KEY`, and `TF_ACC=1`. Mutation acceptance tests additionally require `AIRLOCK_ACC_MUTATION=1` and should only be run against an isolated Airlock environment with disposable `tf-acc-*` objects. Never commit live Airlock URLs, API keys, hostnames, user details, group names, or response fixtures. See [AGENTS.md](./AGENTS.md) for architecture, safety, validation, and release conventions. +Releases use the exact semantic version in [`VERSION`](./VERSION). After the exact `main` +test workflow succeeds, gated GitHub Actions creates the matching tag and signed +GoReleaser assets when that version has not already been published. ## Contributing diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..ee1372d --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.2.2