From 1303c32a96629ac4a99e895d25ca7d952d969189 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Sun, 5 Apr 2026 21:27:29 -0500 Subject: [PATCH 01/12] refactor: consolidate release pipeline into single reusable workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Absorbed release-image.yaml and release-discussion.yaml into release.yaml as optional jobs, controlled by inputs and secrets. Updated test-release.yaml to call the single consolidated workflow. ## Why The three release workflows were always chained together sequentially and shared data flow. Consolidating reduces caller complexity from three workflow calls to one while keeping each concern as a separate job. ## Notes - Breaking change for consumers currently calling release-image.yaml or release-discussion.yaml directly — they need to migrate to the extended release.yaml inputs/secrets - release_discussion job always spins up a runner when a tag is created, then skips at step level if secrets aren't set (can't use secrets in job-level if conditions) - image-registry defaults to ghcr.io, image-registry-username defaults to github.actor, image-platforms defaults to linux/amd64,linux/arm64 - Top-level permissions changed from contents:read to {} per GHA standards Signed-off-by: jmeridth --- .github/workflows/release-discussion.yaml | 52 -------- .github/workflows/release-image.yaml | 80 ------------ .github/workflows/release.yaml | 145 +++++++++++++++++++++- .github/workflows/test-release.yaml | 36 ++---- docs/release-discussion.md | 48 ------- docs/release-image.md | 34 ----- docs/release.md | 61 ++++++++- 7 files changed, 205 insertions(+), 251 deletions(-) delete mode 100644 .github/workflows/release-discussion.yaml delete mode 100644 .github/workflows/release-image.yaml delete mode 100644 docs/release-discussion.md delete mode 100644 docs/release-image.md diff --git a/.github/workflows/release-discussion.yaml b/.github/workflows/release-discussion.yaml deleted file mode 100644 index 8e9ea7d..0000000 --- a/.github/workflows/release-discussion.yaml +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: "Release Discussion" -on: - workflow_call: - inputs: - full-tag: - required: true - type: string - body: - required: true - type: string - secrets: - github-token: - required: true - discussion-repository-id: - required: true - discussion-category-id: - required: true -permissions: - contents: read -jobs: - create_discussion: - runs-on: ubuntu-latest - permissions: - contents: read - discussions: write - env: - DISCUSSION_REPOSITORY_ID: ${{ secrets.discussion-repository-id }} - DISCUSSION_CATEGORY_ID: ${{ secrets.discussion-category-id }} - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - name: Check for Discussion Repository ID - if: ${{ env.DISCUSSION_REPOSITORY_ID == '' }} - run: | - echo "discussion-repository-id secret is not set" - exit 1 - - name: Check for Discussion Category ID - if: ${{ env.DISCUSSION_CATEGORY_ID == '' }} - run: | - echo "discussion-category-id secret is not set" - exit 1 - - name: Create an Announcement Discussion for Release - uses: abirismyname/create-discussion@c2b7c825241769dda523865ae444a879f6bbd0e0 - with: - title: ${{ inputs.full-tag }} - body: ${{ inputs.body }} - repository-id: ${{ env.DISCUSSION_REPOSITORY_ID }} - category-id: ${{ env.DISCUSSION_CATEGORY_ID }} - github-token: ${{ secrets.github-token }} diff --git a/.github/workflows/release-image.yaml b/.github/workflows/release-image.yaml deleted file mode 100644 index 703aac8..0000000 --- a/.github/workflows/release-image.yaml +++ /dev/null @@ -1,80 +0,0 @@ ---- -name: "Release Image" -on: - workflow_call: - inputs: - image-name: - required: true - type: string - full-tag: - required: true - type: string - short-tag: - required: true - type: string - create-attestation: - required: false - type: boolean - default: false - secrets: - github-token: - required: true - image-registry: - required: true - image-registry-username: - required: true - image-registry-password: - required: true -permissions: - contents: read -jobs: - create_action_images: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - id-token: write - attestations: write - env: - IMAGE_REGISTRY: ${{ secrets.image-registry }} - IMAGE_REGISTRY_USERNAME: ${{ secrets.image-registry-username }} - IMAGE_REGISTRY_PASSWORD: ${{ secrets.image-registry-password }} - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd - - name: Log in to the Container registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 - with: - registry: ${{ env.IMAGE_REGISTRY }} - username: ${{ env.IMAGE_REGISTRY_USERNAME }} - password: ${{ env.IMAGE_REGISTRY_PASSWORD}} - - name: Push Docker Image - if: ${{ success() }} - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f - id: push - with: - context: . - file: ./Dockerfile - push: true - tags: | - ${{ env.IMAGE_REGISTRY }}/${{ inputs.image-name }}:latest - ${{ env.IMAGE_REGISTRY }}/${{ inputs.image-name }}:${{ inputs.full-tag }} - ${{ env.IMAGE_REGISTRY }}/${{ inputs.image-name }}:${{ inputs.short-tag }} - platforms: linux/amd64,linux/arm64 - provenance: false - sbom: false - - name: Generate artifact attestation - if: ${{ inputs.create-attestation }} - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 - with: - subject-name: ${{ env.IMAGE_REGISTRY }}/${{ inputs.image-name}} - subject-digest: ${{ steps.push.outputs.digest }} - push-to-registry: true - github-token: ${{ secrets.github-token }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 76571c4..4a8a358 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,9 +14,40 @@ on: required: false type: boolean default: true + image-name: + description: "Docker image name (e.g., owner/repo). Enables image build/push when set." + required: false + type: string + default: "" + create-attestation: + description: "Create build provenance attestation for the Docker image." + required: false + type: boolean + default: false + image-registry: + description: "Container registry URL." + required: false + type: string + default: "ghcr.io" + image-registry-username: + description: "Container registry username." + required: false + type: string + default: "" + image-platforms: + description: "Comma-separated list of target platforms for Docker build." + required: false + type: string + default: "linux/amd64,linux/arm64" secrets: github-token: required: true + image-registry-password: + required: false + discussion-category-id: + required: false + discussion-repository-id: + required: false outputs: full-tag: description: "Full tag of release" @@ -27,8 +58,9 @@ on: body: description: "Body content of release" value: ${{ jobs.create_release.outputs.body }} -permissions: - contents: read + +permissions: {} + jobs: create_release: # Release if: @@ -48,8 +80,8 @@ jobs: body: ${{ steps.release-drafter.outputs.body }} runs-on: ubuntu-latest permissions: - contents: write - pull-requests: read + contents: write # Create releases via release-drafter + pull-requests: read # Read PR labels for release-drafter steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 @@ -72,7 +104,7 @@ jobs: if: ${{ inputs.update-major-tag && needs.create_release.outputs.full-tag != '' }} runs-on: ubuntu-latest permissions: - contents: write + contents: write # Force-push major version tag steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 @@ -84,7 +116,7 @@ jobs: with: fetch-tags: true ref: ${{ needs.create_release.outputs.full-tag }} - persist-credentials: true + persist-credentials: true # Required for git push - name: Force update major tag run: | @@ -93,3 +125,104 @@ jobs: env: SHORT: ${{ needs.create_release.outputs.short-tag }} FULL: ${{ needs.create_release.outputs.full-tag }} + + release_image: + needs: create_release + if: ${{ inputs.image-name != '' && needs.create_release.outputs.full-tag != '' }} + runs-on: ubuntu-latest + permissions: + contents: read # Clone the repository + packages: write # Push container images + id-token: write # Federate via Workload Identity for attestation + attestations: write # Create build provenance attestation + env: + IMAGE_REGISTRY: ${{ inputs.image-registry }} + IMAGE_REGISTRY_USERNAME: ${{ inputs.image-registry-username || github.actor }} + IMAGE_REGISTRY_PASSWORD: ${{ secrets.image-registry-password }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + with: + egress-policy: audit + + - name: Validate image registry password + if: ${{ env.IMAGE_REGISTRY_PASSWORD == '' }} + run: | + echo "::error::image-registry-password secret is required when image-name is set" + exit 1 + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + + - name: Log in to the Container registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 + with: + registry: ${{ env.IMAGE_REGISTRY }} + username: ${{ env.IMAGE_REGISTRY_USERNAME }} + password: ${{ env.IMAGE_REGISTRY_PASSWORD }} + + - name: Push Docker Image + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 + id: push + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ env.IMAGE_REGISTRY }}/${{ inputs.image-name }}:latest + ${{ env.IMAGE_REGISTRY }}/${{ inputs.image-name }}:${{ needs.create_release.outputs.full-tag }} + ${{ env.IMAGE_REGISTRY }}/${{ inputs.image-name }}:${{ needs.create_release.outputs.short-tag }} + platforms: ${{ inputs.image-platforms }} + provenance: false + sbom: false + + - name: Generate artifact attestation + if: ${{ inputs.create-attestation }} + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ${{ env.IMAGE_REGISTRY }}/${{ inputs.image-name }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + github-token: ${{ secrets.github-token }} + + release_discussion: + needs: create_release + if: ${{ needs.create_release.outputs.full-tag != '' }} + runs-on: ubuntu-latest + permissions: + contents: read # Required by harden-runner + discussions: write # Create announcement discussions + env: + DISCUSSION_REPOSITORY_ID: ${{ secrets.discussion-repository-id }} + DISCUSSION_CATEGORY_ID: ${{ secrets.discussion-category-id }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + with: + egress-policy: audit + + - name: Validate discussion repository ID + if: ${{ env.DISCUSSION_REPOSITORY_ID == '' }} + run: | + echo "::notice::discussion-repository-id secret is not set, skipping discussion creation" + exit 0 + + - name: Validate discussion category ID + if: ${{ env.DISCUSSION_CATEGORY_ID == '' }} + run: | + echo "::notice::discussion-category-id secret is not set, skipping discussion creation" + exit 0 + + - name: Create an Announcement Discussion for Release + if: ${{ env.DISCUSSION_REPOSITORY_ID != '' && env.DISCUSSION_CATEGORY_ID != '' }} + uses: abirismyname/create-discussion@c2b7c825241769dda523865ae444a879f6bbd0e0 + with: + title: ${{ needs.create_release.outputs.full-tag }} + body: ${{ needs.create_release.outputs.body }} + repository-id: ${{ env.DISCUSSION_REPOSITORY_ID }} + category-id: ${{ env.DISCUSSION_CATEGORY_ID }} + github-token: ${{ secrets.github-token }} diff --git a/.github/workflows/test-release.yaml b/.github/workflows/test-release.yaml index 31587e1..a702c34 100644 --- a/.github/workflows/test-release.yaml +++ b/.github/workflows/test-release.yaml @@ -9,42 +9,20 @@ on: jobs: release: permissions: - contents: write - pull-requests: read + contents: write # Create releases and push major version tag + pull-requests: read # Read PR labels for release-drafter + packages: write # Push container images + id-token: write # Federate via Workload Identity for attestation + attestations: write # Create build provenance attestation + discussions: write # Create announcement discussions uses: ./.github/workflows/release.yaml with: publish: true release-config-name: release-drafter.yaml - secrets: - github-token: ${{ secrets.GITHUB_TOKEN }} - release_image: - needs: release - permissions: - contents: read - packages: write - id-token: write - attestations: write - uses: ./.github/workflows/release-image.yaml - with: image-name: ${{ github.repository }} - full-tag: ${{ needs.release.outputs.full-tag }} - short-tag: ${{ needs.release.outputs.short-tag }} create-attestation: true secrets: github-token: ${{ secrets.GITHUB_TOKEN }} - image-registry: ghcr.io - image-registry-username: ${{ github.actor }} image-registry-password: ${{ secrets.GITHUB_TOKEN }} - release_discussion: - needs: release - permissions: - contents: read - discussions: write - uses: ./.github/workflows/release-discussion.yaml - with: - full-tag: ${{ needs.release.outputs.full-tag }} - body: ${{ needs.release.outputs.body }} - secrets: - github-token: ${{ secrets.GITHUB_TOKEN }} - discussion-repository-id: ${{ secrets.DISCUSSION_REPOSITORY_ID }} discussion-category-id: ${{ secrets.DISCUSSION_CATEGORY_ID }} + discussion-repository-id: ${{ secrets.DISCUSSION_REPOSITORY_ID }} diff --git a/docs/release-discussion.md b/docs/release-discussion.md deleted file mode 100644 index 00885d9..0000000 --- a/docs/release-discussion.md +++ /dev/null @@ -1,48 +0,0 @@ -# Release Discussion Reusable Workflow - -## Inputs - -```yaml -- uses: github-community-projects/ospo-reusable-workflows/.github/workflows/release-discussion.yaml@main - permissions: - contents: read - discussions: write - with: - # Full tag of the image, usually the version (v1.0.0) - full-tag: v1.0.0 - # The body of the release, to be used in the GitHub release UI - body: | - This is a release of the ${{ github.repository }} image. - The full tag is ${{ inputs.full-tag }}. - The short tag is ${{ inputs.short-tag }}. - secrets: - # The GitHub token to use - github-token: ${{ secrets.GITHUB_TOKEN }} - # Discussion Repository ID - discussion-repository-id: ${{ secrets.DISCUSSION_REPOSITORY_ID }} - # Discussion Category ID - discussion-category-id: ${{ secrets.DISCUSSION_CATEGORY_ID }} -``` - -## Outputs - -None - -## Notes - -In order to get the discussion repository ID and category ID, you can use the GitHub GraphQL API Explorer: https://docs.github.com/en/graphql/overview/explorer with the following query (replace `OWNER` and `REPO` with the appropriate values): - -```graphql -query { - repository(owner: "OWNER", name: "REPO") { - id - discussionCategories(first: 50) { - nodes { - id - name - slug - } - } - } -} -``` diff --git a/docs/release-image.md b/docs/release-image.md deleted file mode 100644 index 69a2d0d..0000000 --- a/docs/release-image.md +++ /dev/null @@ -1,34 +0,0 @@ -# Release Image Reusable Workflow - -## Inputs - -```yaml -- uses: github-community-projects/ospo-reusable-workflows/.github/workflows/release-image.yaml@main - permissions: - contents: read - packages: write - id-token: write - attestations: write - with: - # Image name, usually owner/repository (github-community-projects/ospo-reusable-workflows) - image-name: ${{ github.repository }} - # Full tag of the image, usually the version (v1.0.0) - full-tag: v1.0.0 - # Short tag of the image, usually the major version (v1) - short-tag: v1 - # Flag to create an attestation - create-attestation: true - secrets: - # The GitHub token to use - github-token: ${{ secrets.GITHUB_TOKEN }} - # Image repository url - image-registry: ${{ secrets.IMAGE_REPOSITORY_URL }} - # Image repository username - image-registry-username: ${{ secrets.IMAGE_REPOSITORY_USERNAME }} - # Image repository password - image-registry-password: ${{ secrets.IMAGE_REPOSITORY_PASSWORD }} -``` - -## Outputs - -None diff --git a/docs/release.md b/docs/release.md index 4415c58..83fc9b7 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,5 +1,7 @@ # Release Reusable Workflow +Consolidated release workflow that handles creating releases, optionally building/pushing Docker images, and optionally creating GitHub Discussions announcements. + ## Inputs ```yaml @@ -7,17 +9,44 @@ permissions: contents: write pull-requests: read + packages: write # only needed if building images + id-token: write # only needed if creating attestations + attestations: write # only needed if creating attestations + discussions: write # only needed if creating discussions with: # Boolean flag whether to publish the release, default is true publish: true - # The name of the configuration file to use, default is release-drafter.yml + # The name of the configuration file to use # from the release-drafter/release-drafter GitHub Action release-config-name: release-drafter.yml # Boolean flag whether to update major tag to latest full semver tag, default is true update-major-tag: true + + # --- Optional: Docker image build/push --- + # Setting image-name enables the image build/push job + # Image name, usually owner/repository + image-name: ${{ github.repository }} + # Container registry URL, default is ghcr.io + image-registry: ghcr.io + # Container registry username, default is github.actor + image-registry-username: ${{ github.actor }} + # Comma-separated list of target platforms, default is linux/amd64,linux/arm64 + image-platforms: linux/amd64,linux/arm64 + # Flag to create a build provenance attestation, default is false + create-attestation: true + secrets: - # The GitHub token to use + # The GitHub token to use (required) github-token: ${{ secrets.GITHUB_TOKEN }} + # Container registry password (required when image-name is set) + image-registry-password: ${{ secrets.GITHUB_TOKEN }} + + # --- Optional: GitHub Discussion announcement --- + # Setting both discussion IDs enables the discussion creation job + # GraphQL ID of the discussion category + discussion-category-id: ${{ secrets.DISCUSSION_CATEGORY_ID }} + # GraphQL ID of the repository for discussions + discussion-repository-id: ${{ secrets.DISCUSSION_REPOSITORY_ID }} ``` ## Outputs @@ -36,3 +65,31 @@ jobs: SHORT_TAG: ${{ needs.release.outputs.short-tag }} BODY: ${{ needs.release.outputs.body }} ``` + +## Jobs + +The workflow runs up to four jobs: + +1. **create_release** - Always runs. Creates a release via release-drafter. +2. **update_major_tag** - Runs when `update-major-tag` is true. Force-updates the major version tag. +3. **release_image** - Runs when `image-name` is set. Builds and pushes a multi-platform Docker image. +4. **release_discussion** - Runs when both `discussion-category-id` and `discussion-repository-id` are set. Creates a GitHub Discussions announcement. + +## Notes + +To get the discussion repository ID and category ID, use the GitHub GraphQL API Explorer: https://docs.github.com/en/graphql/overview/explorer with the following query (replace `OWNER` and `REPO` with the appropriate values): + +```graphql +query { + repository(owner: "OWNER", name: "REPO") { + id + discussionCategories(first: 50) { + nodes { + id + name + slug + } + } + } +} +``` From 47d5ec9680c13b736cce975c01d0c2261a586a47 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Sun, 5 Apr 2026 21:41:52 -0500 Subject: [PATCH 02/12] feat: add draft-first release pattern with GoReleaser and publish jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Changed release workflow to always create a draft release first, added an optional GoReleaser job that builds binaries and uploads artifacts to the draft release, and added a publish_release job that publishes after all preceding jobs succeed or are skipped. Added repo visibility checks before attestation in both GoReleaser and image jobs. ## Why Repositories with immutable releases enabled cannot modify a release after publishing. The draft-first pattern ensures all artifacts are uploaded before the release becomes visible, and the publish gate prevents partial releases if any build job fails. ## Notes - `publish_release` uses `always()` with per-job result checks so it runs even when optional jobs are skipped, but still blocks on any failure - GoReleaser job creates and pushes the git tag itself since release-drafter with `publish: false` does not create tags - Both GoReleaser and image attestation steps now check repo visibility via the GitHub API — this adds an API call per job but prevents cryptic failures on private repos Signed-off-by: jmeridth --- .github/workflows/release.yaml | 135 +++++++++++++++++++++++++++++++-- docs/release.md | 28 +++++-- 2 files changed, 148 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4a8a358..bee6c0a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -4,6 +4,7 @@ on: workflow_call: inputs: publish: + description: "Publish the release after all jobs complete. When false, the release remains a draft." required: false type: boolean default: true @@ -20,7 +21,7 @@ on: type: string default: "" create-attestation: - description: "Create build provenance attestation for the Docker image." + description: "Create build provenance attestation for artifacts and/or Docker images." required: false type: boolean default: false @@ -39,6 +40,11 @@ on: required: false type: string default: "linux/amd64,linux/arm64" + goreleaser-config-path: + description: "Path to GoReleaser config file. Enables GoReleaser build/upload when set." + required: false + type: string + default: "" secrets: github-token: required: true @@ -78,25 +84,27 @@ jobs: full-tag: ${{ steps.release-drafter.outputs.tag_name }} short-tag: ${{ steps.get_tag_name.outputs.SHORT_TAG }} body: ${{ steps.release-drafter.outputs.body }} + release-id: ${{ steps.release-drafter.outputs.id }} runs-on: ubuntu-latest permissions: - contents: write # Create releases via release-drafter + contents: write # Create draft release via release-drafter pull-requests: read # Read PR labels for release-drafter steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 with: egress-policy: audit - - uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0 + - name: Draft release id: release-drafter + uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0 with: config-name: ${{ inputs.release-config-name }} - publish: ${{ inputs.publish }} + publish: false token: ${{ secrets.github-token }} - name: Get the Short Tag id: get_tag_name run: | - short_tag=$(echo ${{ steps.release-drafter.outputs.tag_name }} | cut -d. -f1) + short_tag=$(echo "${{ steps.release-drafter.outputs.tag_name }}" | cut -d. -f1) echo "SHORT_TAG=$short_tag" >> "$GITHUB_OUTPUT" update_major_tag: @@ -114,7 +122,7 @@ jobs: - name: Checkout Repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-tags: true + fetch-depth: 0 ref: ${{ needs.create_release.outputs.full-tag }} persist-credentials: true # Required for git push @@ -126,6 +134,77 @@ jobs: SHORT: ${{ needs.create_release.outputs.short-tag }} FULL: ${{ needs.create_release.outputs.full-tag }} + release_goreleaser: + needs: create_release + if: ${{ inputs.goreleaser-config-path != '' && needs.create_release.outputs.full-tag != '' }} + runs-on: ubuntu-latest + permissions: + contents: write # Upload release assets and push tags + id-token: write # Federate for artifact attestation + attestations: write # Generate artifact attestations + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: true # Required for git push of tags + + - name: Create and push tag + run: | + git tag "${{ needs.create_release.outputs.full-tag }}" + git push origin "${{ needs.create_release.outputs.full-tag }}" + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: Build with GoReleaser + uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean --config ${{ inputs.goreleaser-config-path }} + env: + GORELEASER_CURRENT_TAG: ${{ needs.create_release.outputs.full-tag }} + + - name: Upload artifacts to draft release + run: | + gh release upload "${{ needs.create_release.outputs.full-tag }}" \ + dist/*.tar.gz \ + dist/*.zip \ + dist/checksums.txt \ + --clobber + env: + GH_TOKEN: ${{ secrets.github-token }} + + - name: Check repository visibility + id: repo-visibility + run: | + visibility=$(gh api "repos/${{ github.repository }}" --jq '.visibility') + echo "is_public=$( [ "$visibility" = "public" ] && echo true || echo false )" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.github-token }} + + - name: Generate artifact attestation + if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public == 'true' }} + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + + - name: Skip attestation notice + if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public != 'true' }} + run: | + echo "::warning::Artifact attestation skipped — not available for private user-owned repositories. Make this repository public to enable attestation." + release_image: needs: create_release if: ${{ inputs.image-name != '' && needs.create_release.outputs.full-tag != '' }} @@ -180,8 +259,17 @@ jobs: provenance: false sbom: false - - name: Generate artifact attestation + - name: Check repository visibility if: ${{ inputs.create-attestation }} + id: repo-visibility + run: | + visibility=$(gh api "repos/${{ github.repository }}" --jq '.visibility') + echo "is_public=$( [ "$visibility" = "public" ] && echo true || echo false )" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.github-token }} + + - name: Generate artifact attestation + if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public == 'true' }} uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ${{ env.IMAGE_REGISTRY }}/${{ inputs.image-name }} @@ -189,6 +277,11 @@ jobs: push-to-registry: true github-token: ${{ secrets.github-token }} + - name: Skip attestation notice + if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public != 'true' }} + run: | + echo "::warning::Artifact attestation skipped — not available for private user-owned repositories. Make this repository public to enable attestation." + release_discussion: needs: create_release if: ${{ needs.create_release.outputs.full-tag != '' }} @@ -226,3 +319,31 @@ jobs: repository-id: ${{ env.DISCUSSION_REPOSITORY_ID }} category-id: ${{ env.DISCUSSION_CATEGORY_ID }} github-token: ${{ secrets.github-token }} + + publish_release: + needs: [create_release, update_major_tag, release_goreleaser, release_image, release_discussion] + if: > + always() && + inputs.publish && + needs.create_release.result == 'success' && + (needs.update_major_tag.result == 'success' || needs.update_major_tag.result == 'skipped') && + (needs.release_goreleaser.result == 'success' || needs.release_goreleaser.result == 'skipped') && + (needs.release_image.result == 'success' || needs.release_image.result == 'skipped') && + (needs.release_discussion.result == 'success' || needs.release_discussion.result == 'skipped') + runs-on: ubuntu-latest + permissions: + contents: write # Publish draft release + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + with: + egress-policy: audit + + - name: Publish draft release + run: | + gh api \ + --method PATCH \ + "/repos/${{ github.repository }}/releases/${{ needs.create_release.outputs.release-id }}" \ + -f draft=false + env: + GH_TOKEN: ${{ secrets.github-token }} diff --git a/docs/release.md b/docs/release.md index 83fc9b7..22d1443 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,6 +1,6 @@ # Release Reusable Workflow -Consolidated release workflow that handles creating releases, optionally building/pushing Docker images, and optionally creating GitHub Discussions announcements. +Consolidated release workflow that creates a draft release, optionally builds artifacts (GoReleaser, Docker images), creates GitHub Discussions announcements, and publishes the release after all jobs succeed. This draft-first pattern supports repositories with immutable releases enabled. ## Inputs @@ -14,7 +14,8 @@ Consolidated release workflow that handles creating releases, optionally buildin attestations: write # only needed if creating attestations discussions: write # only needed if creating discussions with: - # Boolean flag whether to publish the release, default is true + # Publish the release after all jobs complete. When false, the release + # remains a draft for manual review. Default is true. publish: true # The name of the configuration file to use # from the release-drafter/release-drafter GitHub Action @@ -22,6 +23,11 @@ Consolidated release workflow that handles creating releases, optionally buildin # Boolean flag whether to update major tag to latest full semver tag, default is true update-major-tag: true + # --- Optional: GoReleaser build/upload --- + # Setting goreleaser-config-path enables the GoReleaser job + # Path to GoReleaser config file (e.g., .goreleaser.yaml) + goreleaser-config-path: .goreleaser.yaml + # --- Optional: Docker image build/push --- # Setting image-name enables the image build/push job # Image name, usually owner/repository @@ -32,7 +38,9 @@ Consolidated release workflow that handles creating releases, optionally buildin image-registry-username: ${{ github.actor }} # Comma-separated list of target platforms, default is linux/amd64,linux/arm64 image-platforms: linux/amd64,linux/arm64 - # Flag to create a build provenance attestation, default is false + # Flag to create build provenance attestations, default is false + # Attestation is only available for public repositories. Private repos + # will see a warning and skip attestation automatically. create-attestation: true secrets: @@ -68,16 +76,20 @@ jobs: ## Jobs -The workflow runs up to four jobs: +The workflow runs up to six jobs: -1. **create_release** - Always runs. Creates a release via release-drafter. +1. **create_release** - Always runs. Creates a draft release via release-drafter. 2. **update_major_tag** - Runs when `update-major-tag` is true. Force-updates the major version tag. -3. **release_image** - Runs when `image-name` is set. Builds and pushes a multi-platform Docker image. -4. **release_discussion** - Runs when both `discussion-category-id` and `discussion-repository-id` are set. Creates a GitHub Discussions announcement. +3. **release_goreleaser** - Runs when `goreleaser-config-path` is set. Builds Go binaries, uploads artifacts to the draft release, and optionally creates attestations. +4. **release_image** - Runs when `image-name` is set. Builds and pushes a multi-platform Docker image, and optionally creates attestations. +5. **release_discussion** - Runs when both `discussion-category-id` and `discussion-repository-id` secrets are set. Creates a GitHub Discussions announcement. +6. **publish_release** - Runs when `publish` is true and all preceding jobs succeed (or are skipped). Publishes the draft release. ## Notes -To get the discussion repository ID and category ID, use the GitHub GraphQL API Explorer: https://docs.github.com/en/graphql/overview/explorer with the following query (replace `OWNER` and `REPO` with the appropriate values): +- The draft-first pattern supports repositories with **immutable releases** enabled. The release is created as a draft, artifacts are uploaded, and only then is it published. +- Artifact attestation requires a **public repository**. Private user-owned or organization repositories on free plans will see a warning and skip attestation automatically. +- To get the discussion repository ID and category ID, use the GitHub GraphQL API Explorer: https://docs.github.com/en/graphql/overview/explorer with the following query (replace `OWNER` and `REPO` with the appropriate values): ```graphql query { From 1914b6ccb6e9f09b0a27b902d60fdcc8ce158191 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Sun, 5 Apr 2026 22:25:47 -0500 Subject: [PATCH 03/12] fix: address review findings for release workflow correctness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Moved tag creation into create_release job, removed update_major_tag job and its input, added GoReleaser config validation via yq to enforce release.disable: true, fixed -f to -F for boolean in publish_release, made go-version-file configurable, made upload globs resilient with nullglob, consolidated discussion validation into a single input check, and pinned release_image checkout to the tagged commit. ## Why The draft-first pattern changed when git tags are created, but downstream jobs hadn't been updated to account for that. Multiple review agents independently identified race conditions, a silent publish failure, and fragile assumptions that needed fixing before this is mergeable. ## Notes - Removing the `update-major-tag` input is a breaking change for callers that explicitly set it — major tag is now always pushed in create_release - The yq install adds a step to the GoReleaser job; mikefarah/yq action is SHA-pinned but is a new third-party dependency - GoReleaser config validation only checks `release.disable` not `changelog.disable` — the latter is recommended but not enforced since it won't cause conflicts Signed-off-by: jmeridth --- .github/workflows/release.yaml | 106 ++++++++++++++++----------------- docs/release.md | 29 ++++++--- 2 files changed, 74 insertions(+), 61 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bee6c0a..72672ad 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -11,10 +11,6 @@ on: release-config-name: required: true type: string - update-major-tag: - required: false - type: boolean - default: true image-name: description: "Docker image name (e.g., owner/repo). Enables image build/push when set." required: false @@ -45,6 +41,11 @@ on: required: false type: string default: "" + go-version-file: + description: "Path to go.mod or go.work file for Go version detection. Used by the GoReleaser job." + required: false + type: string + default: "go.mod" secrets: github-token: required: true @@ -94,6 +95,11 @@ jobs: uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 with: egress-policy: audit + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: true # Required for git push of tags - name: Draft release id: release-drafter uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0 @@ -106,40 +112,19 @@ jobs: run: | short_tag=$(echo "${{ steps.release-drafter.outputs.tag_name }}" | cut -d. -f1) echo "SHORT_TAG=$short_tag" >> "$GITHUB_OUTPUT" - - update_major_tag: - needs: create_release - if: ${{ inputs.update-major-tag && needs.create_release.outputs.full-tag != '' }} - runs-on: ubuntu-latest - permissions: - contents: write # Force-push major version tag - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout Repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - ref: ${{ needs.create_release.outputs.full-tag }} - persist-credentials: true # Required for git push - - - name: Force update major tag + - name: Create and push tags run: | - git tag -f "${SHORT}" "${FULL}" - git push -f origin "${SHORT}" - env: - SHORT: ${{ needs.create_release.outputs.short-tag }} - FULL: ${{ needs.create_release.outputs.full-tag }} + git tag "${{ steps.release-drafter.outputs.tag_name }}" + git tag -f "${{ steps.get_tag_name.outputs.SHORT_TAG }}" "${{ steps.release-drafter.outputs.tag_name }}" + git push origin "${{ steps.release-drafter.outputs.tag_name }}" + git push -f origin "${{ steps.get_tag_name.outputs.SHORT_TAG }}" release_goreleaser: needs: create_release if: ${{ inputs.goreleaser-config-path != '' && needs.create_release.outputs.full-tag != '' }} runs-on: ubuntu-latest permissions: - contents: write # Upload release assets and push tags + contents: write # Upload release assets id-token: write # Federate for artifact attestation attestations: write # Generate artifact attestations steps: @@ -152,17 +137,30 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - persist-credentials: true # Required for git push of tags + persist-credentials: false + + - name: Install yq + uses: mikefarah/yq@f71c26247e2b5278a5e07e662b2c6e3a1a72e67e # v4.45.4 + with: + cmd: echo "yq installed" - - name: Create and push tag + - name: Validate GoReleaser config has release disabled run: | - git tag "${{ needs.create_release.outputs.full-tag }}" - git push origin "${{ needs.create_release.outputs.full-tag }}" + config="${{ inputs.goreleaser-config-path }}" + if ! [ -f "$config" ]; then + echo "::error::GoReleaser config file not found: $config" + exit 1 + fi + release_disabled=$(yq '.release.disable' "$config") + if [ "$release_disabled" != "true" ]; then + echo "::error::GoReleaser config must have 'release: disable: true' to prevent conflicting with the draft release created by this workflow. See https://github.com/github-community-projects/ospo-reusable-workflows/blob/main/docs/release.md#goreleaser-configuration" + exit 1 + fi - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version-file: go.mod + go-version-file: ${{ inputs.go-version-file }} - name: Build with GoReleaser uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 @@ -175,10 +173,14 @@ jobs: - name: Upload artifacts to draft release run: | + shopt -s nullglob + files=(dist/*.tar.gz dist/*.zip dist/checksums.txt) + if [ ${#files[@]} -eq 0 ]; then + echo "::error::No artifacts found in dist/ to upload" + exit 1 + fi gh release upload "${{ needs.create_release.outputs.full-tag }}" \ - dist/*.tar.gz \ - dist/*.zip \ - dist/checksums.txt \ + "${files[@]}" \ --clobber env: GH_TOKEN: ${{ secrets.github-token }} @@ -232,6 +234,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + ref: ${{ needs.create_release.outputs.full-tag }} persist-credentials: false - name: Set up Docker Buildx @@ -298,20 +301,18 @@ jobs: with: egress-policy: audit - - name: Validate discussion repository ID - if: ${{ env.DISCUSSION_REPOSITORY_ID == '' }} - run: | - echo "::notice::discussion-repository-id secret is not set, skipping discussion creation" - exit 0 - - - name: Validate discussion category ID - if: ${{ env.DISCUSSION_CATEGORY_ID == '' }} + - name: Check discussion inputs + id: check-inputs run: | - echo "::notice::discussion-category-id secret is not set, skipping discussion creation" - exit 0 + if [ -z "${DISCUSSION_REPOSITORY_ID}" ] || [ -z "${DISCUSSION_CATEGORY_ID}" ]; then + echo "::notice::discussion-repository-id and/or discussion-category-id secrets are not set, skipping discussion creation" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi - name: Create an Announcement Discussion for Release - if: ${{ env.DISCUSSION_REPOSITORY_ID != '' && env.DISCUSSION_CATEGORY_ID != '' }} + if: ${{ steps.check-inputs.outputs.skip == 'false' }} uses: abirismyname/create-discussion@c2b7c825241769dda523865ae444a879f6bbd0e0 with: title: ${{ needs.create_release.outputs.full-tag }} @@ -321,12 +322,11 @@ jobs: github-token: ${{ secrets.github-token }} publish_release: - needs: [create_release, update_major_tag, release_goreleaser, release_image, release_discussion] + needs: [create_release, release_goreleaser, release_image, release_discussion] if: > always() && inputs.publish && needs.create_release.result == 'success' && - (needs.update_major_tag.result == 'success' || needs.update_major_tag.result == 'skipped') && (needs.release_goreleaser.result == 'success' || needs.release_goreleaser.result == 'skipped') && (needs.release_image.result == 'success' || needs.release_image.result == 'skipped') && (needs.release_discussion.result == 'success' || needs.release_discussion.result == 'skipped') @@ -344,6 +344,6 @@ jobs: gh api \ --method PATCH \ "/repos/${{ github.repository }}/releases/${{ needs.create_release.outputs.release-id }}" \ - -f draft=false + -F draft=false env: GH_TOKEN: ${{ secrets.github-token }} diff --git a/docs/release.md b/docs/release.md index 22d1443..c8d5b08 100644 --- a/docs/release.md +++ b/docs/release.md @@ -20,13 +20,13 @@ Consolidated release workflow that creates a draft release, optionally builds ar # The name of the configuration file to use # from the release-drafter/release-drafter GitHub Action release-config-name: release-drafter.yml - # Boolean flag whether to update major tag to latest full semver tag, default is true - update-major-tag: true # --- Optional: GoReleaser build/upload --- # Setting goreleaser-config-path enables the GoReleaser job # Path to GoReleaser config file (e.g., .goreleaser.yaml) goreleaser-config-path: .goreleaser.yaml + # Path to go.mod or go.work file for Go version detection, default is go.mod + go-version-file: go.mod # --- Optional: Docker image build/push --- # Setting image-name enables the image build/push job @@ -78,12 +78,25 @@ jobs: The workflow runs up to six jobs: -1. **create_release** - Always runs. Creates a draft release via release-drafter. -2. **update_major_tag** - Runs when `update-major-tag` is true. Force-updates the major version tag. -3. **release_goreleaser** - Runs when `goreleaser-config-path` is set. Builds Go binaries, uploads artifacts to the draft release, and optionally creates attestations. -4. **release_image** - Runs when `image-name` is set. Builds and pushes a multi-platform Docker image, and optionally creates attestations. -5. **release_discussion** - Runs when both `discussion-category-id` and `discussion-repository-id` secrets are set. Creates a GitHub Discussions announcement. -6. **publish_release** - Runs when `publish` is true and all preceding jobs succeed (or are skipped). Publishes the draft release. +1. **create_release** - Always runs. Creates a draft release via release-drafter, then creates and pushes the full and major version git tags. +2. **release_goreleaser** - Runs when `goreleaser-config-path` is set. Builds Go binaries, uploads artifacts to the draft release, and optionally creates attestations. +3. **release_image** - Runs when `image-name` is set. Builds and pushes a multi-platform Docker image, and optionally creates attestations. +4. **release_discussion** - Runs when both `discussion-category-id` and `discussion-repository-id` secrets are set. Creates a GitHub Discussions announcement. +5. **publish_release** - Runs when `publish` is true and all preceding jobs succeed (or are skipped). Publishes the draft release. + +## GoReleaser Configuration + +When using the `goreleaser-config-path` input, your GoReleaser config **must** disable release and changelog management since this workflow handles both via release-drafter: + +```yaml +release: + disable: true + +changelog: + disable: true +``` + +Without these settings, GoReleaser will attempt to create its own GitHub release, conflicting with the draft release created by release-drafter. ## Notes From 39795fa20e571c8958a465cc23bcf60184e14509 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Sun, 5 Apr 2026 22:41:40 -0500 Subject: [PATCH 04/12] docs: add deprecation stubs for release-image and release-discussion workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Restored release-image.yaml and release-discussion.yaml as thin stubs that accept the original inputs/secrets but immediately fail with deprecation errors and migration instructions. Restored docs with CAUTION banners and before/after migration examples. ## Why Existing callers of the removed workflows would get a confusing "workflow not found" error. The stubs provide a clear error message with a link to migration docs, making the transition path obvious. ## Notes - Stubs preserve the original input/secret signatures so callers don't hit schema validation errors before seeing the deprecation message - Each stub spins up a runner just to emit the error — unavoidable since reusable workflows must have at least one job Signed-off-by: jmeridth --- .github/workflows/release-discussion.yaml | 32 ++++++++++++ .github/workflows/release-image.yaml | 41 +++++++++++++++ docs/release-discussion.md | 61 +++++++++++++++++++++++ docs/release-image.md | 48 ++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 .github/workflows/release-discussion.yaml create mode 100644 .github/workflows/release-image.yaml create mode 100644 docs/release-discussion.md create mode 100644 docs/release-image.md diff --git a/.github/workflows/release-discussion.yaml b/.github/workflows/release-discussion.yaml new file mode 100644 index 0000000..a1cb045 --- /dev/null +++ b/.github/workflows/release-discussion.yaml @@ -0,0 +1,32 @@ +--- +name: "Release Discussion (Deprecated)" +on: + workflow_call: + inputs: + full-tag: + required: true + type: string + body: + required: true + type: string + secrets: + github-token: + required: true + discussion-repository-id: + required: true + discussion-category-id: + required: true + +permissions: {} + +jobs: + deprecated: + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Deprecation notice + run: | + echo "::error::This workflow (release-discussion.yaml) has been deprecated and consolidated into release.yaml." + echo "::error::Migrate to the consolidated release workflow by setting the 'discussion-category-id' and 'discussion-repository-id' secrets on release.yaml instead." + echo "::error::See migration guide: https://github.com/github-community-projects/ospo-reusable-workflows/blob/main/docs/release-discussion.md" + exit 1 diff --git a/.github/workflows/release-image.yaml b/.github/workflows/release-image.yaml new file mode 100644 index 0000000..5805693 --- /dev/null +++ b/.github/workflows/release-image.yaml @@ -0,0 +1,41 @@ +--- +name: "Release Image (Deprecated)" +on: + workflow_call: + inputs: + image-name: + required: true + type: string + full-tag: + required: true + type: string + short-tag: + required: true + type: string + create-attestation: + required: false + type: boolean + default: false + secrets: + github-token: + required: true + image-registry: + required: true + image-registry-username: + required: true + image-registry-password: + required: true + +permissions: {} + +jobs: + deprecated: + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Deprecation notice + run: | + echo "::error::This workflow (release-image.yaml) has been deprecated and consolidated into release.yaml." + echo "::error::Migrate to the consolidated release workflow by setting the 'image-name' input on release.yaml instead." + echo "::error::See migration guide: https://github.com/github-community-projects/ospo-reusable-workflows/blob/main/docs/release-image.md" + exit 1 diff --git a/docs/release-discussion.md b/docs/release-discussion.md new file mode 100644 index 0000000..d8db327 --- /dev/null +++ b/docs/release-discussion.md @@ -0,0 +1,61 @@ +# Release Discussion Reusable Workflow + +> [!CAUTION] +> This workflow has been deprecated and consolidated into the [Release workflow](release.md). Calling `release-discussion.yaml` directly will fail with an error. Migrate by setting the `discussion-category-id` and `discussion-repository-id` secrets on `release.yaml` instead. + +## Migration + +Replace your existing `release-discussion.yaml` call: + +```yaml +# Before (deprecated) +release_discussion: + needs: release + uses: github-community-projects/ospo-reusable-workflows/.github/workflows/release-discussion.yaml@main + with: + full-tag: ${{ needs.release.outputs.full-tag }} + body: ${{ needs.release.outputs.body }} + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} + discussion-repository-id: ${{ secrets.DISCUSSION_REPOSITORY_ID }} + discussion-category-id: ${{ secrets.DISCUSSION_CATEGORY_ID }} +``` + +With the consolidated `release.yaml` secrets: + +```yaml +# After +release: + uses: github-community-projects/ospo-reusable-workflows/.github/workflows/release.yaml@main + with: + release-config-name: release-drafter.yaml + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} + discussion-category-id: ${{ secrets.DISCUSSION_CATEGORY_ID }} + discussion-repository-id: ${{ secrets.DISCUSSION_REPOSITORY_ID }} +``` + +Key changes: +- `full-tag` and `body` no longer need to be passed — they are handled internally +- Discussion IDs moved from required secrets to optional secrets + +See the full [Release workflow documentation](release.md) for all available inputs. + +## Notes + +To get the discussion repository ID and category ID, use the GitHub GraphQL API Explorer: https://docs.github.com/en/graphql/overview/explorer with the following query (replace `OWNER` and `REPO` with the appropriate values): + +```graphql +query { + repository(owner: "OWNER", name: "REPO") { + id + discussionCategories(first: 50) { + nodes { + id + name + slug + } + } + } +} +``` diff --git a/docs/release-image.md b/docs/release-image.md new file mode 100644 index 0000000..4598169 --- /dev/null +++ b/docs/release-image.md @@ -0,0 +1,48 @@ +# Release Image Reusable Workflow + +> [!CAUTION] +> This workflow has been deprecated and consolidated into the [Release workflow](release.md). Calling `release-image.yaml` directly will fail with an error. Migrate by setting the `image-name` input on `release.yaml` instead. + +## Migration + +Replace your existing `release-image.yaml` call: + +```yaml +# Before (deprecated) +release_image: + needs: release + uses: github-community-projects/ospo-reusable-workflows/.github/workflows/release-image.yaml@main + with: + image-name: ${{ github.repository }} + full-tag: ${{ needs.release.outputs.full-tag }} + short-tag: ${{ needs.release.outputs.short-tag }} + create-attestation: true + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} + image-registry: ghcr.io + image-registry-username: ${{ github.actor }} + image-registry-password: ${{ secrets.GITHUB_TOKEN }} +``` + +With the consolidated `release.yaml` inputs: + +```yaml +# After +release: + uses: github-community-projects/ospo-reusable-workflows/.github/workflows/release.yaml@main + with: + release-config-name: release-drafter.yaml + image-name: ${{ github.repository }} + create-attestation: true + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} + image-registry-password: ${{ secrets.GITHUB_TOKEN }} +``` + +Key changes: +- `full-tag` and `short-tag` no longer need to be passed — they are handled internally +- `image-registry` defaults to `ghcr.io` +- `image-registry-username` defaults to `github.actor` +- Registry credentials moved from required secrets to optional (with defaults) + +See the full [Release workflow documentation](release.md) for all available inputs. From 2b06e1a3115d97295e780d57b0e677020512662a Mon Sep 17 00:00:00 2001 From: jmeridth Date: Mon, 6 Apr 2026 16:57:14 -0500 Subject: [PATCH 05/12] fix: force-tag full release and gate discussion on publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Added -f flag to full tag creation and push in the create_release job, and gated the release_discussion job on inputs.publish. ## Why Without -f, workflow reruns fail with "tag already exists" when the full tag was created before a downstream job failed. The short tag already used -f, so this was an inconsistency. The discussion job ran unconditionally, which would announce draft releases publicly before they were published. ## Notes - Force-pushing tags rewrites git history for that tag — acceptable here since the tag points to the same commit on rerun - Callers using `publish: false` will no longer get discussion announcements, which is a behavior change for any existing consumers relying on that Signed-off-by: jmeridth --- .github/workflows/release.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 72672ad..ebd608d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -114,9 +114,9 @@ jobs: echo "SHORT_TAG=$short_tag" >> "$GITHUB_OUTPUT" - name: Create and push tags run: | - git tag "${{ steps.release-drafter.outputs.tag_name }}" + git tag -f "${{ steps.release-drafter.outputs.tag_name }}" git tag -f "${{ steps.get_tag_name.outputs.SHORT_TAG }}" "${{ steps.release-drafter.outputs.tag_name }}" - git push origin "${{ steps.release-drafter.outputs.tag_name }}" + git push -f origin "${{ steps.release-drafter.outputs.tag_name }}" git push -f origin "${{ steps.get_tag_name.outputs.SHORT_TAG }}" release_goreleaser: @@ -287,7 +287,7 @@ jobs: release_discussion: needs: create_release - if: ${{ needs.create_release.outputs.full-tag != '' }} + if: ${{ inputs.publish && needs.create_release.outputs.full-tag != '' }} runs-on: ubuntu-latest permissions: contents: read # Required by harden-runner From 613657f4f026e3b7086f22ce395ef7d2eb1f8321 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Sat, 11 Apr 2026 22:26:45 -0500 Subject: [PATCH 06/12] doc: update README - drop important notification about org change, it has been months - update README with new important about relase-image and release-discussion workflow deprecations Signed-off-by: jmeridth --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a0b7a34..965c408 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Reusable Workflows > [!IMPORTANT] -> This repository has been transferred from `github/ospo-reusable-workflows` to `github-community-projects/ospo-reusable-workflows`. Please update any references to point to the new location. +> The Release Image and Release Discussion workflows are now deprecated. The have been consolidated into the Release workflow. This is a placeholder repo for multiple GitHub Actions we use in open source projects. From e449076963541b0e0a6ee856319a057d3f38cc50 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Sat, 11 Apr 2026 22:45:44 -0500 Subject: [PATCH 07/12] fix: correct yq action version Signed-off-by: jmeridth --- .github/workflows/release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ebd608d..4284f41 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -140,7 +140,7 @@ jobs: persist-credentials: false - name: Install yq - uses: mikefarah/yq@f71c26247e2b5278a5e07e662b2c6e3a1a72e67e # v4.45.4 + uses: mikefarah/yq@0f4fb8d35ec1a939d78dd6862f494d19ec589f19 # v4.52.5 with: cmd: echo "yq installed" From 53a0992ac0c00ba6034a40916b5babb03578fa73 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Sat, 11 Apr 2026 22:55:36 -0500 Subject: [PATCH 08/12] fix: move conditional to create discussion sooner Signed-off-by: jmeridth --- .github/workflows/release.yaml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4284f41..dad3591 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -287,7 +287,7 @@ jobs: release_discussion: needs: create_release - if: ${{ inputs.publish && needs.create_release.outputs.full-tag != '' }} + if: ${{ secrets.discussion-repository-id && secrest.discussion-category-id && inputs.publish && needs.create_release.outputs.full-tag != '' }} runs-on: ubuntu-latest permissions: contents: read # Required by harden-runner @@ -301,16 +301,6 @@ jobs: with: egress-policy: audit - - name: Check discussion inputs - id: check-inputs - run: | - if [ -z "${DISCUSSION_REPOSITORY_ID}" ] || [ -z "${DISCUSSION_CATEGORY_ID}" ]; then - echo "::notice::discussion-repository-id and/or discussion-category-id secrets are not set, skipping discussion creation" - echo "skip=true" >> "$GITHUB_OUTPUT" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi - - name: Create an Announcement Discussion for Release if: ${{ steps.check-inputs.outputs.skip == 'false' }} uses: abirismyname/create-discussion@c2b7c825241769dda523865ae444a879f6bbd0e0 From ac9d6394400b74111edb4e66bd9a06e0f13951da Mon Sep 17 00:00:00 2001 From: jmeridth Date: Sat, 11 Apr 2026 23:01:33 -0500 Subject: [PATCH 09/12] fix: put discussion back the way it was Signed-off-by: jmeridth --- .github/workflows/release.yaml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index dad3591..a9dcdb9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -287,7 +287,7 @@ jobs: release_discussion: needs: create_release - if: ${{ secrets.discussion-repository-id && secrest.discussion-category-id && inputs.publish && needs.create_release.outputs.full-tag != '' }} + if: ${{ inputs.publish && needs.create_release.outputs.full-tag != '' }} runs-on: ubuntu-latest permissions: contents: read # Required by harden-runner @@ -297,10 +297,21 @@ jobs: DISCUSSION_CATEGORY_ID: ${{ secrets.discussion-category-id }} steps: - name: Harden the runner (Audit all outbound calls) + if: ${{ steps.check-inputs.outputs.skip == 'false' }} uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 with: egress-policy: audit + - name: Check discussion inputs + id: check-inputs + run: | + if [ -z "${DISCUSSION_REPOSITORY_ID}" ] || [ -z "${DISCUSSION_CATEGORY_ID}" ]; then + echo "::notice::discussion-repository-id and/or discussion-category-id secrets are not set, skipping discussion creation" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Create an Announcement Discussion for Release if: ${{ steps.check-inputs.outputs.skip == 'false' }} uses: abirismyname/create-discussion@c2b7c825241769dda523865ae444a879f6bbd0e0 From f617110df080acede9ea0e37642c306cbe744605 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Sat, 11 Apr 2026 23:09:30 -0500 Subject: [PATCH 10/12] fix: add create-discussion boolean input on release workflow Signed-off-by: jmeridth --- .github/workflows/release.yaml | 7 ++++++- docs/release.md | 11 ++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a9dcdb9..dbc4bd4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -21,6 +21,11 @@ on: required: false type: boolean default: false + create-discussion: + description: "Create discussion about release" + required: false + type: boolean + default: false image-registry: description: "Container registry URL." required: false @@ -287,7 +292,7 @@ jobs: release_discussion: needs: create_release - if: ${{ inputs.publish && needs.create_release.outputs.full-tag != '' }} + if: ${{ inputs.create-discussion && inputs.publish && needs.create_release.outputs.full-tag != '' }} runs-on: ubuntu-latest permissions: contents: read # Required by harden-runner diff --git a/docs/release.md b/docs/release.md index c8d5b08..88d3977 100644 --- a/docs/release.md +++ b/docs/release.md @@ -9,10 +9,10 @@ Consolidated release workflow that creates a draft release, optionally builds ar permissions: contents: write pull-requests: read - packages: write # only needed if building images - id-token: write # only needed if creating attestations - attestations: write # only needed if creating attestations - discussions: write # only needed if creating discussions + packages: write + id-token: write + attestations: write + discussions: write with: # Publish the release after all jobs complete. When false, the release # remains a draft for manual review. Default is true. @@ -42,6 +42,7 @@ Consolidated release workflow that creates a draft release, optionally builds ar # Attestation is only available for public repositories. Private repos # will see a warning and skip attestation automatically. create-attestation: true + create-discussion: true secrets: # The GitHub token to use (required) @@ -81,7 +82,7 @@ The workflow runs up to six jobs: 1. **create_release** - Always runs. Creates a draft release via release-drafter, then creates and pushes the full and major version git tags. 2. **release_goreleaser** - Runs when `goreleaser-config-path` is set. Builds Go binaries, uploads artifacts to the draft release, and optionally creates attestations. 3. **release_image** - Runs when `image-name` is set. Builds and pushes a multi-platform Docker image, and optionally creates attestations. -4. **release_discussion** - Runs when both `discussion-category-id` and `discussion-repository-id` secrets are set. Creates a GitHub Discussions announcement. +4. **release_discussion** - Runs when `create-discussion` is set. Both `discussion-category-id` and `discussion-repository-id` secrets are required if so. Creates a GitHub Discussions announcement. 5. **publish_release** - Runs when `publish` is true and all preceding jobs succeed (or are skipped). Publishes the draft release. ## GoReleaser Configuration From 9ea51703c5837ae71386df88a84d4adef64f0f75 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Sat, 11 Apr 2026 23:12:03 -0500 Subject: [PATCH 11/12] fix: move check-inputs up to be used Signed-off-by: jmeridth --- .github/workflows/release.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index dbc4bd4..1beb581 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -301,12 +301,6 @@ jobs: DISCUSSION_REPOSITORY_ID: ${{ secrets.discussion-repository-id }} DISCUSSION_CATEGORY_ID: ${{ secrets.discussion-category-id }} steps: - - name: Harden the runner (Audit all outbound calls) - if: ${{ steps.check-inputs.outputs.skip == 'false' }} - uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 - with: - egress-policy: audit - - name: Check discussion inputs id: check-inputs run: | @@ -317,6 +311,12 @@ jobs: echo "skip=false" >> "$GITHUB_OUTPUT" fi + - name: Harden the runner (Audit all outbound calls) + if: ${{ steps.check-inputs.outputs.skip == 'false' }} + uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + with: + egress-policy: audit + - name: Create an Announcement Discussion for Release if: ${{ steps.check-inputs.outputs.skip == 'false' }} uses: abirismyname/create-discussion@c2b7c825241769dda523865ae444a879f6bbd0e0 From 080fce0972fa6f70926cdb9e72ab78dd7fe9ba81 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Sat, 11 Apr 2026 23:44:17 -0500 Subject: [PATCH 12/12] fix: validate discussions are enabled before creating release discussion ## What Added a GraphQL check to verify that Discussions are enabled on the target repository before attempting to create a release discussion. Renamed the step from "Check discussion inputs" to "Validate discussion prerequisites" to reflect the broader scope. ## Why When `create-discussion: true` is set with valid repository/category IDs but Discussions is disabled on the target repo, the workflow would fail at the create-discussion action with an unhelpful error. This validates the prerequisite upfront and skips gracefully with a notice instead. ## Notes - Uses GraphQL with the repository node ID so it correctly handles cases where the discussion target is a different repo than where the workflow runs Signed-off-by: jmeridth --- .github/workflows/release.yaml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1beb581..7bf0fc6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -301,12 +301,30 @@ jobs: DISCUSSION_REPOSITORY_ID: ${{ secrets.discussion-repository-id }} DISCUSSION_CATEGORY_ID: ${{ secrets.discussion-category-id }} steps: - - name: Check discussion inputs + - name: Validate discussion prerequisites id: check-inputs + env: + GH_TOKEN: ${{ secrets.github-token }} run: | if [ -z "${DISCUSSION_REPOSITORY_ID}" ] || [ -z "${DISCUSSION_CATEGORY_ID}" ]; then echo "::notice::discussion-repository-id and/or discussion-category-id secrets are not set, skipping discussion creation" echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + has_discussions=$(gh api graphql -f query=" + query(\$id: ID!) { + node(id: \$id) { + ... on Repository { + hasDiscussionsEnabled + } + } + } + " -f id="${DISCUSSION_REPOSITORY_ID}" --jq '.data.node.hasDiscussionsEnabled') + + if [ "$has_discussions" != "true" ]; then + echo "::notice::Discussions are not enabled on the target repository, skipping discussion creation" + echo "skip=true" >> "$GITHUB_OUTPUT" else echo "skip=false" >> "$GITHUB_OUTPUT" fi