Skip to content

chore: share the release workflow and cut releases here (ENG-9233) - #3

Merged
johnsca merged 9 commits into
mainfrom
johnsca/chore/ENG-9233/standardize-release
Oct 20, 2026
Merged

johnsca merged 9 commits into
mainfrom
johnsca/chore/ENG-9233/standardize-release

Conversation

@johnsca

@johnsca johnsca commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

ENG-9233

what

Adds cut-release.yml, a workflow_call workflow that works out the next version from the tag history and publishes a GitHub release for it. It is the release workflow from terraform-azure-stacklet-cost-setup, lifted so the other five module repositories call it instead of copying it.

Adds release.yml, the dispatch caller, so this repository cuts its own releases down the same path the module repositories will. Documents cut-release in the README beside run-script, with the caller block to copy.

why

Five Terraform module repositories have no release workflow, and the sixth already had this one. Copying it five more times leaves six sets of the same guards to keep in step. The guards are the part worth sharing, because a published release is immutable and a slip in one cannot be taken back.

The lift departs from the original in four places.

  1. The bump input is a string rather than a choice, because workflow_call has no choice type. A new step rejects anything but patch, minor, or major. Without it the tag action reads an unrecognised force_bump as no bump, falls back to the commit messages, and publishes a version nobody asked for.
  2. The branch guard compares against the calling repository's default branch rather than the literal main, so it holds for a caller whose default branch has another name.
  3. The version job gains the Harden Runner step run-script already uses. That job fetches and runs a third-party action, while the publish job runs nothing but gh.
  4. The run claims the tag with POST git/refs before creating the release, rather than letting gh release create make it. The old order answers a name taken in the gap by releasing the tag that is there and ignoring --target, which publishes an immutable release against a commit nobody chose. An EXIT trap removes the ref if the release call then fails, so a retry still works out the same number. It is armed before the write rather than after, which is safe because the cleanup confirms the tag is this run's before deleting it. Raised by cubic on this PR.

The caller keeps the workflow_dispatch trigger and the concurrency group. Both matter only on the workflow a person dispatches, and a called workflow cannot serialise its caller.

testing

  • just lint passes.
  • uvx zizmor@1.23.1 -v --gh-token=... .github/workflows/ reports no findings, with the five online audits scheduled rather than skipped.
  • Each action pin resolves to a commit via gh api repos/<owner>/<repo>/commits/<sha>.
  • Ran the publish step against a stubbed gh on ten paths: a clean publish leaves the tag, a failing release call deletes it, both refusal paths create nothing, a tag someone else holds survives the pre-check and the claim whether it sits on this commit or another, and neither a release that landed while the client died nor a failing release lookup loses its tag. A tag moved after the claim survives. A run killed before it records the claim leaves the tag, which is the deliberate side of that trade.
  • Ran the bump check against patch, minor, major, Major, PATCH, empty, majorly, and patch minor, accepting only the three lowercase values.
  • After merge, dispatch Release on main with dry run checked, and confirm the summary reports v0.0.0 as previous and v1.0.0 as new.
  • Dispatch Release with a major bump and confirm v1.0.0 is published against the dispatched commit.

docs

The README gains a cut-release section under Reusable workflows. The pinning note at the top no longer says this repository publishes no release tags, because after this it does.

@johnsca

johnsca commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai please review

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 19, 2026

Copy link
Copy Markdown

@cubic-dev-ai please review

@johnsca I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files

Architecture diagram
sequenceDiagram
    participant Dev as Developer
    participant GH as GitHub Actions
    participant CR as cut-release.yml
    participant VR as version job
    participant PB as publish job
    participant GHAPI as GitHub API

    Note over Dev,GHAPI: Release workflow via manual dispatch

    Dev->>GH: Dispatch release (bump, dry_run)
    GH->>GH: Enforce concurrency group
    GH->>CR: Call workflow (bump, dry_run, STEP_SECURITY_API_KEY)

    CR->>VR: Run version job (read-only token)
    VR->>VR: Harden Runner (if API key provided)
    VR->>VR: Validate bump is patch|minor|major
    alt Invalid bump
        VR-->>CR: Fail - request rejected
    end
    VR->>VR: Validate target is default branch
    alt Non-default branch
        VR-->>CR: Fail - release refused
    end
    VR->>GHAPI: Checkout full history (fetch-depth: 0)
    VR->>GHAPI: Run github-tag-action (force_bump, push: false)
    GHAPI-->>VR: next_tag, changelog, previous_tag
    VR->>VR: Report version summary
    VR->>VR: Require first release to be major
    alt No tags and bump != major
        VR-->>CR: Fail - first release must be major
    end
    VR-->>CR: Outputs new_tag, changelog

    CR->>PB: Run publish job (write token, if success && !dry_run)
    PB->>PB: Refuse run_attempt > 1
    alt Re-run detected
        PB-->>CR: Fail - re-run refused
    end
    PB->>GHAPI: Check tag collision (matching-refs)
    alt Tag already exists
        PB-->>CR: Fail - release over existing tag refused
    else Tag free
        PB->>GHAPI: gh release create (tag, target commit, notes)
        GHAPI-->>PB: Release published
    end
    PB-->>CR: Complete
    CR-->>GH: Workflow finished
    GH-->>Dev: Status and summary
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread .github/workflows/cut-release.yml
Comment thread README.md Outdated
Comment thread .github/workflows/cut-release.yml
@johnsca

johnsca commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai please review again

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 19, 2026

Copy link
Copy Markdown

@cubic-dev-ai please review again

@johnsca I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files

Architecture diagram
sequenceDiagram
    participant DEV as Developer
    participant GH as GitHub Actions
    participant CR as cut-release.yml
    participant REL as release.yml
    participant RD as Release Dispatch
    participant TAG as git-tag-action
    participant API as GitHub API
    participant RELS as GitHub Releases
    
    Note over DEV,RELS: Release Workflow Dispatch
    DEV->>GH: Dispatch Release workflow
    GH->>REL: Run release.yml
    REL->>CR: Call cut-release.yml (workflow_call)
    
    Note over CR: Version Job (read-only)
    CR->>CR: Validate bump input (patch/minor/major)
    alt Dry run or invalid bump
        CR-->>DEV: Stop (validate or summarize)
    end
    
    CR->>CR: Check default branch
    alt Not default branch
        CR-->>DEV: Refuse release
    end
    
    CR->>GH: Checkout repo (fetch-depth: 0)
    CR->>TAG: Work out next version
    TAG->>TAG: Read tag history
    TAG-->>CR: New tag & changelog
    
    alt No previous tag
        alt Bump not major
            CR-->>DEV: Require first release = major
        end
    end
    
    CR-->>REL: Pass version info
    
    opt Dry run false
        Note over CR: Publish Job (write access)
        CR->>CR: Check run attempt (block re-runs)
        CR->>API: Check if tag exists
        alt Tag already exists
            CR-->>DEV: Refuse (tag taken)
        end
        
        Note over CR: Claim tag ref first
        CR->>API: POST git/refs (create tag)
        CR->>API: Create release (target commit)
        alt Release fails
            CR->>API: Delete tag (cleanup)
            CR-->>DEV: Report failure
        else Release succeeds
            AP I-->>RELS: Publish release
            CR-->>DEV: Report success
        end
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread .github/workflows/cut-release.yml Outdated
Comment thread .github/workflows/cut-release.yml Outdated
@johnsca

johnsca commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai please review again

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 19, 2026

Copy link
Copy Markdown

@cubic-dev-ai please review again

@johnsca I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files

Architecture diagram
sequenceDiagram
    participant Dev as Developer
    participant GH as GitHub Actions
    participant Caller as Caller Workflow (release.yml)
    participant CutRelease as Called Workflow (cut-release.yml)
    participant VersionJob as version job
    participant PublishJob as publish job
    participant Harden as Harden Runner (step-security)
    participant Checkout as actions/checkout
    participant TagAction as github-tag-action
    participant GHAPI as GitHub API (git refs, releases)

    Note over Dev,GHAPI: Release cut flow

    Dev->>GH: Dispatch workflow_dispatch (bump, dry_run)
    GH-->>Caller: Trigger with inputs

    Caller->>GH: concurrency: release (serialize)
    Caller->>CutRelease: workflow_call with permissions contents:write

    CutRelease->>VersionJob: Start job (contents: read)
    Note over CutRelease,VersionJob: Harden Runner enabled if STEP_SECURITY_API_KEY set
    VersionJob->>Harden: Harden Runner (egress policy)
    VersionJob->>VersionJob: Validate bump in {patch, minor, major} (case-sensitive)
    VersionJob->>VersionJob: Refuse non-default branch (reads calling repo default_branch)

    VersionJob->>Checkout: Fetch full tag history (fetch-depth: 0)
    Checkout-->>VersionJob: Repository code with all tags
    VersionJob->>TagAction: Work out next version (force_bump, push: false)
    TagAction-->>VersionJob: previous_tag, new_tag, changelog
    VersionJob->>VersionJob: Report version summary to step summary
    VersionJob-->>CutRelease: Outputs (new_tag, changelog)

    alt Not dry run (success() && !inputs.dry_run)
        CutRelease->>PublishJob: Start job (contents: write)
        Note over CutRelease,PublishJob: No checkout, no third-party actions, only gh CLI
        PublishJob->>PublishJob: Refuse re-run (run_attempt != 1)

        PublishJob->>GHAPI: Check matching refs for tags/TAG
        alt Tag already exists
            GHAPI-->>PublishJob: Tag exists
            PublishJob->>PublishJob: Refuse (exit 1)
        else Tag does not exist
            PublishJob->>PublishJob: Arm EXIT trap (claimed=0)
            PublishJob->>GHAPI: POST git/refs to claim tag ref (tag at current commit)

            alt Claim succeeds
                PublishJob->>PublishJob: Set claimed=1
                PublishJob->>GHAPI: Create release for tag (with changelog)
                alt Release creates successfully
                    GHAPI-->>PublishJob: Release published
                    Note over PublishJob: Tag permanently locked in
                else Release fails
                    GHAPI-->>PublishJob: Error
                    PublishJob->>PublishJob: Cleanup: verify no release, then DELETE tag ref
                    Note over PublishJob: Safe retry possible (same version number)
                end
            else Claim fails (tag taken by another run/race)
                GHAPI-->>PublishJob: 422 error
                PublishJob->>PublishJob: Exit (tag belongs to someone else)
            end
        end
    else dry_run is true
        Note over CutRelease: Publish job skipped entirely
    end

    Note over GHAPI,Caller: Caller workflow ends, concurrency released
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread .github/workflows/cut-release.yml
@johnsca
johnsca marked this pull request as ready for review October 19, 2026 20:46
@johnsca
johnsca requested a review from a team as a code owner October 19, 2026 20:46

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.
Architecture diagram
sequenceDiagram
    participant Dispatch as Dispatch Caller<br/>(release.yml)
    participant CutWorkflow as Cut-Release Workflow<br/>(cut-release.yml)
    participant VersionJob as Version Job<br/>(read-only)
    participant PublishJob as Publish Job<br/>(write)
    participant DefaultBranch as Caller's Default Branch
    participant GitRefs as GitHub Ref API
    participant TagAction as Version Tool<br/>(mathieudutour/github-tag-action)
    participant HardenRunner as Harden Runner<br/>(step-security)
    participant Releases as GitHub Release API

    Note over Dispatch,CutWorkflow: Manual dispatch with bump + dry_run inputs

    Dispatch->>CutWorkflow: Call workflow with bump & dry_run<br/>permissions: contents write
    CutWorkflow->>VersionJob: Start version calculation<br/>permissions: contents read

    alt STEP_SECURITY_API_KEY is set
        VersionJob->>HardenRunner: Enable egress policy
    end

    VersionJob->>VersionJob: Validate bump value<br/>must be patch, minor, or major
    VersionJob->>VersionJob: Check branch matches<br/>caller's default branch

    alt Wrong branch
        VersionJob-->>Dispatch: FAIL - refuse release<br/>from non-default branch
    end

    VersionJob->>DefaultBranch: Fetch full tag history<br/>with all branches

    VersionJob->>TagAction: Calculate next version<br/>with forced bump value

    TagAction-->>VersionJob: previous_tag, new_tag, changelog

    alt First release (previous_version = 0.0.0) and bump != major
        VersionJob-->>Dispatch: FAIL - first release<br/>must be major
    else Dry run requested
        VersionJob-->>Dispatch: Report version in<br/>step summary and stop
    else Publishing allowed
        VersionJob-->>PublishJob: Pass new_tag + changelog<br/>if previous job succeeded
    end

    PublishJob->>GitRefs: Check if tag already exists
    alt Tag already exists
        PublishJob-->>Dispatch: FAIL - refuse to publish<br/>over existing tag
    end

    PublishJob->>GitRefs: Claim tag ref<br/>with POST git/refs
    PublishJob->>PublishJob: Set ownership flag<br/>for cleanup trap

    opt Initial tag claim fails
        PublishJob-->>Dispatch: FAIL - tag already taken
    end

    PublishJob->>PublishJob: Handle re-run attempt<br/>refuse if run_attempt != 1

    PublishJob->>Releases: Create GitHub release<br/>with claimed tag

    alt Release creation succeeds
        Releases-->>PublishJob: Release published
    else Release creation fails
        PublishJob->>GitRefs: Delete owned tag ref<br/>via EXIT trap
    end

    PublishJob-->>Dispatch: Report success with<br/>version summary
Loading

Re-trigger cubic

@albertodonato albertodonato left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thanks!

johnsca and others added 9 commits October 20, 2026 14:41
[ENG-9233](https://stacklet.atlassian.net/browse/ENG-9233)

What
----

- Adds `cut-release.yml`, a `workflow_call` workflow that works out the next
  version from the tag history and publishes a GitHub release for it. It is
  the release workflow from `terraform-azure-stacklet-cost-setup`, lifted so
  the other five module repositories call it instead of copying it.
- Adds `release.yml`, the dispatch caller, so this repository cuts its own
  releases down the same path the module repositories will.
- Documents `cut-release` in the README beside `run-script`, with the caller
  block to copy.

Why
----

Five Terraform module repositories have no release workflow, and the sixth
already had this one. Copying it five more times leaves six sets of the same
guards to keep in step. The guards are the part worth sharing, because a
published release is immutable and a slip in one cannot be taken back.

The lift departs from the original in three places.

1. The `bump` input is a string rather than a choice, because `workflow_call`
   has no choice type. A new step rejects anything but patch, minor, or major.
   Without it the tag action reads an unrecognised `force_bump` as no bump,
   falls back to the commit messages, and publishes a version nobody asked
   for.
2. The branch guard compares against the calling repository's default branch
   rather than the literal `main`, so it holds for a caller whose default
   branch has another name.
3. The version job gains the Harden Runner step `run-script` already uses.
   That job fetches and runs a third-party action, while the publish job runs
   nothing but `gh`.

The caller keeps the `workflow_dispatch` trigger and the `concurrency` group.
Both matter only on the workflow a person dispatches, and a called workflow
cannot serialise its caller.

Testing
----

- [x] `just lint` passes.
- [x] `uvx zizmor@1.23.1 -v --gh-token=... .github/workflows/` reports no
      findings, with the five online audits scheduled rather than skipped.
- [x] Each action pin resolves to a commit via `gh api repos/<o>/<r>/commits/<sha>`.
- [ ] After merge, dispatch Release on main with dry run checked, and confirm
      the summary reports v0.0.0 as previous and v1.0.0 as new.
- [ ] Dispatch Release with a major bump and confirm v1.0.0 is published
      against the dispatched commit.

Docs
----

The README gains a `cut-release` section under Reusable workflows. The pinning
note at the top no longer says this repository publishes no release tags,
because after this it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…_branches, fix the re-run wording

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… deleting

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…k the bump case-sensitively

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@johnsca
johnsca force-pushed the johnsca/chore/ENG-9233/standardize-release branch from 26e5738 to dfced5f Compare October 20, 2026 14:44
@johnsca
johnsca merged commit 6fa4d7c into main Oct 20, 2026
6 checks passed
@johnsca
johnsca deleted the johnsca/chore/ENG-9233/standardize-release branch October 20, 2026 14:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants