diff --git a/.github/workflows/release-self.yml b/.github/workflows/release-self.yml index d88d2ce..d681bbd 100644 --- a/.github/workflows/release-self.yml +++ b/.github/workflows/release-self.yml @@ -8,7 +8,7 @@ on: required: false type: string dry_run: - description: 'Render the version and notes only. Do not tag or create a release.' + description: 'Render the version and notes only. Do not create or update a release, or create a tag.' required: false type: boolean default: true @@ -17,6 +17,13 @@ on: required: false type: boolean default: false + create_tag: + description: 'Create the release tag now instead of when the draft is published. Leave unchecked to only update the draft.' + required: false + type: boolean + default: false + push: + branches: [main] permissions: contents: read @@ -31,6 +38,10 @@ jobs: permissions: contents: write with: - version: ${{ inputs.version }} - dry_run: ${{ inputs.dry_run }} - force: ${{ inputs.force }} + # A push keeps the draft up to date; it never tags or publishes. The + # `inputs` context is empty on push, so each value is selected by the + # event and still has the declared type. + version: ${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }} + dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + force: ${{ github.event_name == 'workflow_dispatch' && inputs.force }} + create_tag: ${{ github.event_name == 'workflow_dispatch' && inputs.create_tag }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa2c974..81ccc5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,12 @@ name: Release # Reusable workflow that computes version and notes with git-cliff and -# creates a draft release and its tag. Publishing the draft triggers -# repo-specific build jobs (Docker, Helm, docs, assets). +# creates or updates a draft release. The tag is created during a run when +# `create_tag` is enabled (the default), or by publishing the draft. Publishing +# the draft triggers repo-specific build jobs (Docker, Helm, docs, assets). # -# The caller's stub carries the triggers (workflow_dispatch). See "Release -# workflow" in the README. +# The caller's stub carries the triggers (workflow_dispatch, optionally push +# to the default branch). See "Release workflow" in the README. on: workflow_call: @@ -16,7 +17,7 @@ on: type: string default: '' dry_run: - description: 'Render the version and notes only. Do not tag or create a release.' + description: 'Render the version and notes only. Do not create or update a release, or create a tag.' required: false type: boolean default: true @@ -35,6 +36,11 @@ on: required: false type: string default: '.release-tooling/cliff.toml' + create_tag: + description: 'Create the release tag now instead of when the draft is published.' + required: false + type: boolean + default: false outputs: version: description: 'The computed/released version.' @@ -42,9 +48,12 @@ on: sha: description: 'The commit SHA being released.' value: ${{ jobs.prepare.outputs.sha }} - created: - description: 'Whether a tag and draft release were created.' - value: ${{ jobs.prepare.outputs.created }} + draft_updated: + description: 'Whether the draft release was created or updated.' + value: ${{ jobs.prepare.outputs.draft_updated }} + tag_created: + description: 'Whether this run created the release tag.' + value: ${{ jobs.prepare.outputs.tag_created }} permissions: contents: read @@ -59,7 +68,8 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} sha: ${{ steps.version.outputs.sha }} - created: ${{ steps.release.outputs.created }} + draft_updated: ${{ steps.release.outputs.draft_updated }} + tag_created: ${{ steps.tag.outputs.tag_created }} steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -107,13 +117,16 @@ jobs: with: tool: git-cliff@2.13.1 - - name: Guard - no draft release already pending + # A draft whose tag does not exist yet is the normal, update-in-place + # case. A draft whose tag does exist is a release already cut with + # create_tag, so a second run must not touch it. + - name: Guard - no tagged draft release already pending env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - draft_tags=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 30 \ + draft_tags=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 100 \ --json tagName,isDraft --jq '.[] | select(.isDraft) | .tagName') blocking_drafts=() @@ -124,7 +137,7 @@ jobs: done if [ ${#blocking_drafts[@]} -gt 0 ]; then - echo "::error::A draft release is already pending: ${blocking_drafts[*]}. Publish it to ship, or delete the draft to abandon it. Release tags cannot be deleted where protect-release-tags is active, so if its tag exists that version is burned: cut the next patch." + echo "::error::A draft release with an existing tag is already pending: ${blocking_drafts[*]}. Publish it to ship, or delete the draft to abandon it. Release tags cannot be deleted where protect-release-tags is active, so if its tag exists that version is burned: cut the next patch." exit 1 fi @@ -174,9 +187,12 @@ jobs: force: ${{ inputs.force }} - name: Guard - something releasable landed + id: releasable if: ${{ inputs.version == '' }} env: PREVIOUS_TAG: ${{ steps.ctx.outputs.previous }} + EVENT_NAME: ${{ github.event_name }} + REF_TYPE: ${{ github.ref_type }} run: | set -euo pipefail @@ -185,12 +201,23 @@ jobs: ' ctx.json) if [ "$releasable_count" -eq 0 ]; then + # A push to the default branch has nothing to maintain; only an + # explicitly requested release (manual dispatch or tag push) should + # fail for this. + if [ "$EVENT_NAME" = "push" ] && [ "$REF_TYPE" = "branch" ]; then + echo "::notice::No feat/fix/perf or breaking commits since ${PREVIOUS_TAG}; leaving the draft release untouched." + echo "releasable=false" >> "$GITHUB_OUTPUT" + exit 0 + fi echo "::error::No feat/fix/perf or breaking commits since ${PREVIOUS_TAG}. Pass an explicit version to override." exit 1 fi + echo "releasable=true" >> "$GITHUB_OUTPUT" + - name: Compute version id: version + if: ${{ steps.releasable.outputs.releasable != 'false' }} env: INPUT_VERSION: ${{ inputs.version }} COMPUTED_VERSION: ${{ steps.ctx.outputs.computed }} @@ -217,6 +244,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Render release notes + if: ${{ steps.releasable.outputs.releasable != 'false' }} env: VERSION: ${{ steps.version.outputs.version }} SHA: ${{ steps.version.outputs.sha }} @@ -239,9 +267,14 @@ jobs: cat notes.md } >> "$GITHUB_STEP_SUMMARY" - - name: Create draft release and tag + # A draft release does not create its tag; GitHub materialises it on + # publish. A push run reuses the pending draft: it updates the same + # version in place, or renames the draft when the computed version moves. + # Updating target_commitish keeps the commit that publish will tag at the + # latest commit the notes cover. + - name: Create or update draft release id: release - if: ${{ !inputs.dry_run }} + if: ${{ !inputs.dry_run && steps.releasable.outputs.releasable != 'false' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} @@ -249,19 +282,86 @@ jobs: run: | set -euo pipefail - create_draft_release() { - local prerelease_flag=() - if [[ "$VERSION" == *-* ]]; then - prerelease_flag=(--prerelease) - fi + prerelease=false + if [[ "$VERSION" == *-* ]]; then + prerelease=true + fi + + release_state=$(gh release view "$VERSION" --repo "$GITHUB_REPOSITORY" \ + --json isDraft --jq 'if .isDraft then "draft" else "published" end' 2>/dev/null || echo "missing") + + if [ "$release_state" = "published" ]; then + echo "::error::Release ${VERSION} is already published and cannot be updated." + exit 1 + fi + + draft_source="" + if [ "$release_state" = "draft" ]; then + draft_source="$VERSION" + else + # The computed version moves when a feature lands on top of fixes. + # Reuse the pending draft (and anything already attached to it) by + # renaming it, instead of leaving a stale draft behind. + candidates=() + draft_tags=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 100 \ + --json tagName,isDraft --jq '.[] | select(.isDraft) | .tagName') + while IFS= read -r tag; do + [ -n "$tag" ] || continue + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "::error::A draft release with an existing tag is already pending: ${tag}. Publish it or delete the draft before running this workflow." + exit 1 + fi + candidates+=("$tag") + done <<< "$draft_tags" + + case "${#candidates[@]}" in + 0) ;; + 1) draft_source="${candidates[0]}" ;; + *) + echo "::error::Multiple draft releases are pending: ${candidates[*]}. Delete all but one and re-run." + exit 1 + ;; + esac + fi + + if [ -z "$draft_source" ]; then + echo "Creating draft release ${VERSION} at ${SHA}." gh release create "$VERSION" \ --repo "$GITHUB_REPOSITORY" \ --draft \ --title "$VERSION" \ --notes-file notes.md \ --target "$SHA" \ - "${prerelease_flag[@]}" - } + --prerelease="$prerelease" + else + if [ "$draft_source" != "$VERSION" ]; then + echo "Renaming draft release ${draft_source} to ${VERSION}." + else + echo "Updating draft release ${VERSION} at ${SHA}." + fi + gh release edit "$draft_source" \ + --repo "$GITHUB_REPOSITORY" \ + --tag "$VERSION" \ + --title "$VERSION" \ + --notes-file notes.md \ + --target "$SHA" \ + --prerelease="$prerelease" + fi + + echo "draft_updated=true" >> "$GITHUB_OUTPUT" + + # The draft is written first, so a failed tag leaves an updateable draft + # and no tag behind. Publishing the draft creates the tag anyway; this + # step exists for callers that need the tag before publishing. + - name: Create tag + id: tag + if: ${{ !inputs.dry_run && inputs.create_tag && steps.releasable.outputs.releasable != 'false' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + SHA: ${{ steps.version.outputs.sha }} + run: | + set -euo pipefail create_tag() { gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ @@ -269,29 +369,17 @@ jobs: -f "sha=${SHA}" > /dev/null } - # A draft release does not create its tag; GitHub materialises it on - # publish. Create the draft first, then the tag, so a failure never - # leaves a tag without a release and this workflow never has to delete - # a tag. - if ! create_draft_release; then - echo "::error::Creating the draft release failed. No tag was created; if a draft for ${VERSION} exists, delete it before retrying." - exit 1 - fi - if ! create_tag; then - echo "::error::Creating tag ${VERSION} failed; deleting the draft release. This workflow never deletes tags." - if ! gh release delete "$VERSION" --repo "$GITHUB_REPOSITORY" --yes; then - echo "::error::Could not delete draft release ${VERSION}. Delete it manually before retrying; if ${VERSION} is already tagged, cut the next patch instead." - fi + echo "::error::Creating tag ${VERSION} failed. The draft release is intact. If the tag does not exist, re-run to retry; if it does, publish the draft or cut the next patch." exit 1 fi - echo "created=true" >> "$GITHUB_OUTPUT" + echo "tag_created=true" >> "$GITHUB_OUTPUT" - name: Dry run notice if: ${{ inputs.dry_run }} run: | - echo "Dry run: nothing tagged or created. Re-run with dry_run unchecked to release." >> "$GITHUB_STEP_SUMMARY" + echo "Dry run: nothing tagged, created or updated. Re-run with dry_run unchecked to release." >> "$GITHUB_STEP_SUMMARY" validate-retry: name: Validate asset retry diff --git a/README.md b/README.md index 985434b..2e83981 100644 --- a/README.md +++ b/README.md @@ -612,76 +612,59 @@ Modify `flake.nix` to adjust Go version, packages, and pre-commit hooks: ### Release workflow -The shared release workflow lives in `opendefensecloud/dev-kit/.github/workflows/release.yml`, computes version and notes with git-cliff, and creates a draft release and its tag. Publishing the draft triggers repo-specific build jobs (Docker, Helm, docs, assets). Notes are rendered from merge commits and grouped by conventional type using the shared `cliff.toml` in dev-kit, so consumers do not keep their own copy. +The shared release workflow lives in +`opendefensecloud/dev-kit/.github/workflows/release.yml`, computes version and +notes with git-cliff, and creates or updates a draft release. An existing draft +for the computed version is updated in place, so a caller can keep the draft +current on every push to the default branch. + +The tag is created by publishing the draft, or during a manual run when +`create_tag` is enabled. Publishing the draft triggers repo-specific build jobs +(Docker, Helm, docs, assets). Notes are rendered from merge commits and grouped +by conventional type using the shared `cliff.toml` in dev-kit, so consumers do +not keep their own copy. #### Using the reusable workflow -Add a caller workflow in your repo (e.g. `.github/workflows/release.yml`): +Add a job that calls the shared workflow. The caller owns the triggers and inputs: ```yaml -name: Release -run-name: ${{ inputs.retry_assets_for && format('Retry assets {0}', inputs.retry_assets_for) || format('Release {0}{1}', inputs.version || '(auto)', inputs.dry_run && ' (dry run)' || '') }} - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to release, e.g. v0.4.0. Leave empty and it is computed from the commits. The tag is created for you.' - required: false - type: string - dry_run: - description: 'Render the version and notes only. Do not tag or create a release.' - required: false - type: boolean - default: true - force: - description: 'Release even though commits are missing from the notes. Recovery only.' - required: false - type: boolean - default: false - retry_assets_for: - description: 'Recovery only. Tag of an existing DRAFT release whose asset upload failed. Skips version and notes, just rebuilds and re-attaches. Uncheck dry_run.' - required: false - type: string - -permissions: - contents: read - -concurrency: - group: release - cancel-in-progress: false - jobs: - prepare: + release: uses: opendefensecloud/dev-kit/.github/workflows/release.yml@<40-char-sha> # permissions: contents: write - with: - version: ${{ inputs.version }} - dry_run: ${{ inputs.dry_run }} - force: ${{ inputs.force }} - retry_assets_for: ${{ inputs.retry_assets_for }} - # cliff_config: cliff.toml # optional override; default is the shared config +``` + +See [`.github/workflows/release-self.yml`](.github/workflows/release-self.yml) for the complete caller used by this repo: `workflow_dispatch` inputs, `push: branches: [main]`, and the event-based input selection. + +#### Keeping the draft up to date on pushes + +`release-self.yml` also shows the push wiring. The `inputs` context is empty on `push`, so the caller selects each value by event; passing an empty string to a boolean input fails workflow validation. A push run passes `create_tag: false`, so it never creates a tag or publishes. If no `feat`, `fix`, `perf` or breaking change landed since the previous tag, the run succeeds without touching the draft. A manual run still fails in that case unless `version` is supplied. +#### Attaching release assets + +If your caller builds assets, add a job that depends on the release job and gate it on both the call result and the draft output. The example assumes the caller also exposes `retry_assets_for` for recovery: + +```yaml assets: - name: Build and attach release assets - needs: prepare + needs: release # result == 'success' matters: validation of a retry target happens in the - # called workflow, and always() alone would still start this job when it - # failed. created is empty on the retry path, so retry covers that case. - if: ${{ always() && needs.prepare.result == 'success' && (needs.prepare.outputs.created == 'true' || inputs.retry_assets_for != '') }} + # called workflow, and always() alone would still start this job when it failed. + # draft_updated is empty on the retry path, so the retry input covers that case. + if: ${{ always() && needs.release.result == 'success' && (needs.release.outputs.draft_updated == 'true' || inputs.retry_assets_for != '') }} runs-on: ubuntu-24.04 permissions: contents: write id-token: write attestations: write env: - VERSION: ${{ inputs.retry_assets_for || needs.prepare.outputs.version }} + VERSION: ${{ inputs.retry_assets_for || needs.release.outputs.version }} steps: - name: Checkout code uses: actions/checkout@<40-char-sha> # v7 with: - ref: ${{ needs.prepare.outputs.sha || inputs.retry_assets_for }} + ref: ${{ needs.release.outputs.sha || inputs.retry_assets_for }} fetch-depth: 0 persist-credentials: false # ... build, sign, attest, upload assets to the draft release ... @@ -691,14 +674,15 @@ jobs: - **Shared `cliff.toml`**: No config file is needed by default. The workflow checks out `cliff.toml` from dev-kit at the same commit as the reusable workflow and uses it. Pass `cliff_config` to use a repository-specific file instead; the bundled notes guard then falls back to best-effort checks for that override. - **Release-notes check**: The workflow checks out its own tooling at the commit that defines it (`job.workflow_sha`) and runs the bundled `check-release-notes` action, so consumers do not need to copy the check. `job.workflow_*` is available on github.com, not GHES. -- **Permissions**: The caller must grant `contents: write` to the `prepare` job (and to any jobs that create tags/releases or upload assets). +- **Permissions**: The caller must grant `contents: write` to the job that calls the shared workflow (and to any jobs that create tags/releases or upload assets). - **Default branch only**: Releases must be cut from the repository's default branch. A branch trigger must name the default branch; a tag trigger is accepted only when the tag points at a commit on it. Other triggers are rejected. - **Merge commits**: The shared config renders the merge commits on the default branch. A commit pushed directly to the branch is reported by the notes guard and fails the release unless `force` is set. Squash and rebase merges are treated the same way. - **Prior release tag**: The repository must already have a release tag. git-cliff derives the changelog boundary from the previous tag, and the workflow fails if there is none. Passing an explicit `version` does not bypass this. -- **Tag creation**: The draft release is created first, then its tag. GitHub creates a draft's tag only when the release is published, so the workflow creates it explicitly; if that fails, it deletes the draft. A failed release therefore leaves no tag behind, and the workflow never deletes a tag. -- **Tag immutability**: Where a `protect-release-tags` ruleset is active, a `v*` tag cannot be moved or deleted. Publish a pending draft to ship it; abandoning a draft whose tag exists burns that version, so cut the next patch. -- **Publishing the draft**: After assets are attached to the draft release, publish it to trigger downstream workflows (Docker, Helm, docs). Prerelease versions (containing `-`) are marked as prereleases automatically. -- **Asset retry**: `retry_assets_for` skips version and notes and must name an existing **draft** release. The workflow rejects a published or missing release, and rejects the combination with `dry_run`. Asset jobs must gate on `needs..result == 'success'` (see the stub above), otherwise `always()` would run them after a failed validation. +- **Draft maintenance**: A non-dry run creates the draft for the computed version or updates the existing draft in place (title, notes, prerelease flag and target commit). If the computed version moves while the draft is still unpublished, the draft is renamed to the new version and updated. There is one pending draft, not one per push. The `draft_updated` output is `true` whenever the draft is ready, whether this run created or updated it. +- **Tag creation**: The tag is created during the run when `create_tag` is true (the default); a push-triggered run passes `false`, so it only maintains the draft. Otherwise publishing the draft creates the tag from the draft's target commit. The draft is written before the tag, so a failed tag leaves an updateable draft and no tag, and re-running retries. The workflow never deletes a tag. +- **Tag immutability**: Where a `protect-release-tags` ruleset is active, a `v*` tag cannot be moved or deleted. Before a tag exists, abandon a draft by deleting it. Once a tag exists (for example after a run with `create_tag`), publish that draft or cut the next patch: deleting the draft leaves the tag behind and burns the version. +- **Publishing the draft**: Publishing creates the tag at the draft's target commit (the latest commit covered by the last run) and triggers downstream workflows (Docker, Helm, docs). Prerelease versions (containing `-`) are marked as prereleases automatically. Publishing a draft whose tag already exists leaves that tag in place. +- **Asset retry**: `retry_assets_for` skips version and notes and must name an existing **draft** release. The workflow rejects a published or missing release, and rejects the combination with `dry_run`. Asset jobs must gate on `needs..result == 'success'` (see Attaching release assets above), otherwise `always()` would run them after a failed validation. ## Design Decisions