Skip to content

feat: add reusable release.yml workflow (SK-417)#9

Merged
RobEasthope merged 2 commits into
mainfrom
sk-417-reusable-releaseyml-workflow
Jun 26, 2026
Merged

feat: add reusable release.yml workflow (SK-417)#9
RobEasthope merged 2 commits into
mainfrom
sk-417-reusable-releaseyml-workflow

Conversation

@RobEasthope

@RobEasthope RobEasthope commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

What

Adds reusable-release.yml (on: workflow_call) — the last commodity Layer-2 reusable workflow (SK-417, under SK-411). Ports the estate's hardened release flow (build-once → npm OIDC Trusted Publishing → GitHub Packages mirror → tag + GitHub release; ASW-328/326/323) into one source of truth, replacing the per-repo release.yml copies that would otherwise drift (SK-384).

Shape

Three least-privileged jobs:

Job Permissions Role
🏗️ Build & pack contents: read Unprivileged build-once; packs the single tarball both legs ship (ASW-328)
🚀 Release (npm) contents/id-token/issues: write OIDC Trusted Publishing (no NPM_TOKEN), version-vs-tag gate, tag + GitHub release, failure issue
📦 Publish to GitHub Packages contents: read · packages/id-token/attestations: write Mirror leg + build-provenance attestation over the same tarball

Decisions

  • Inlined the two infrastructure/scripts/publish-*.sh rather than calling the consumer's per-repo copies — centralises the logic and kills the SK-384 drift. The load-repo-config outputs the per-repo copy read become with: inputs.
  • No secrets: block — npm publish is OIDC, GitHub Packages + gh + the failure issue use the automatic GITHUB_TOKEN. Only permissions cross the caller boundary.
  • Build job reuses Layer-1 setup-project + build (pinned @d0a5949, matching reusable-build-test.yml); publish legs hand-rolled (they need setup-node registry-url/scope) and pin pnpm/Node to the same SHAs so the toolchain can't drift between build and publish.

Consumer contract (documented in CLAUDE.md)

  • npm Trusted Publishing validates the caller filename (workflow_ref), not this reusable callee — each consumer registers its own release.yml caller on npmjs.com; id-token: write on both caller and callee.
  • Consumer must define a branch-protected npm-release environment (absent → GitHub silently auto-creates it unprotected, losing the ASW-326 ref gate).
  • Caller owns the trigger: push-to-main + cancel-in-progress: false, no workflow_dispatch (ASW-326).

Verification

  • ✅ actionlint (incl. shellcheck over every run: block), yamllint, markdownlint, prettier all clean.
  • Live publish unproven here. The "npm validates the caller filename through a reusable workflow" assumption can only be proven by one real consumer publish with TP configured — that's consumer-rollout work, not this PR.

Out of scope / follow-ups

  • Consumer caller adoption + npmjs TP registration (separate rollout issue).
  • Optional extraction of Layer-1 publish-npm / publish-github-packages composite actions.

Summary by CodeRabbit

  • New Features

    • Added a reusable release workflow for publishing packages and creating GitHub Releases with version-matched notes.
    • Release publishing now includes built-in checks to avoid duplicate publishes and can optionally generate provenance and a GitHub Packages mirror.
  • Chores

    • Improved release pipeline safety with tighter permissions, shared build artefacts, and failure tracking for release runs.
  • Documentation

    • Updated workflow documentation to cover the new reusable release process and its usage rules.

Port the estate's hardened release flow (build-once -> npm OIDC Trusted
Publishing -> GitHub Packages mirror -> tag + GitHub release; ASW-328/326/323)
into a workflow_call reusable workflow, replacing the per-repo release.yml
copies that would otherwise drift (SK-384).

Three least-privileged jobs: an unprivileged build job that packs the single
tarball both legs ship; an npm leg publishing via OIDC (no NPM_TOKEN) with a
version-vs-tag gate, tag + release, and failure notification; and a separate
GitHub Packages mirror job carrying packages:write and a build-provenance
attestation over the same tarball.

Drops the two per-repo couplings of the reference: the local load-repo-config
action becomes with: inputs, and the infrastructure/scripts/publish-*.sh
scripts are inlined to centralise the logic. The build job reuses the Layer-1
setup-project + build actions; the publish legs are hand-rolled (they need
setup-node registry-url/scope) and pin pnpm/setup-node to the same SHAs.

No secrets: block — OIDC + the automatic GITHUB_TOKEN cover everything; only
permissions cross the caller boundary. Document the npm-validates-the-caller
OIDC nuance, the branch-protected npm-release environment prerequisite, and the
caller-owns-trigger / no-workflow_dispatch rule in CLAUDE.md.
@claude

claude Bot commented Jun 26, 2026

Copy link
Copy Markdown

Claude finished @RobEasthope's task in 6m 15s —— View job


Code Review: reusable-release.yml (SK-417)


Overall: This is a well-constructed addition. The two major findings from the initial review (mirror-leg failures never opening the tracking issue; download/resolve running on no-publish merges) have been correctly addressed in f7b8949. One medium-severity shell quoting bug in the changelog notes assembly, plus a handful of minor observations below.


Medium — $'\n\n' inside a double-quoted string (line 418)

notes="${notes}${notes:+$'\n\n'}${body}"

Inside a double-quoted string, $'\n\n' is not treated as ANSI-C quoting by bash — $'...' is only processed as ANSI-C when it appears as a standalone token. What you get here is $' (expanding $ with ' as a non-identifier, producing an empty or literal $) followed by \n\n', not two blank lines.

The practical effect is that multiple matching changelog entries are concatenated without the intended blank-line separator, so the GitHub release notes body looks slightly wrong when a release PR bundles several merges. It doesn't affect single-entry releases.

Fix — assign the literal outside the outer quoting context:

nl=$'\n\n'
notes="${notes}${notes:+${nl}}${body}"

Fix this →


Low — CodeRabbit's "tag before mirror" finding (deferred, noted for completeness)

The PR description correctly classifies this as a pre-existing property of the estate's reference release.yml, not a regression, and defers it to SK-457. The current behaviour is: if the mirror fails on first run, a re-run of only the mirror job finds the tag already exists, should_publish goes false, and the mirror is permanently skipped — recovery requires re-running the whole workflow. The PR runbook notes this. Deferral is reasonable; just ensuring SK-457 is tracked.


Positive observations

  • Least-privilege jobscontents: read only on build, packages: write isolated to the mirror job, issues: write dropped from release and isolated to notify-failure. Clean.
  • notify-failure terminal jobneeds: [build, release, publish-github-packages] + if: failure() correctly captures failures in all three legs, including build-time failures that the previous inline notifier missed.
  • Download/resolve gating — both steps in release are now correctly gated on steps.gate.outputs.publish == 'true', so no-op feature merges skip the artifact download entirely.
  • Idempotency probes — the npm view + 404 distinction on both publish legs is solid; a genuine "already published" hit exits 0 without re-publishing.
  • ASW-330 host validation — hardcoding EXPECTED_REGISTRY and refusing to publish if the input drifts is exactly right; the GITHUB_TOKEN bearer must never be sent to a data-driven host.
  • Single-tarball assertion — the set -- .pack/*.tgz; [ "$#" -ne 1 ] guard in both pack and resolve steps mirrors each other consistently.
  • PNPM_HOME assertion — the : "${PNPM_HOME:?…}" guard fails fast with a clear message rather than silently falling back to the bundled npm.
  • retention-days: 1 — tight TTL on the credential-adjacent tarball artifact is well-justified in the comment.
  • persist-credentials: false on all three checkout steps — correct; tags/release go via the gh API, npm via OIDC, Packages via NODE_AUTH_TOKEN.
  • fetch-depth: 0 only where needed (the release job's tag gate) — build and mirror jobs stay shallow.

CLAUDE.md additions

The documentation additions are accurate, follow the British English requirement throughout, and correctly capture the three counter-intuitive caller-side prerequisites (TP validates the caller filename, not this callee; the unprotected environment auto-creation footgun; no workflow_dispatch). Nothing to add.


Summary: One shell quoting bug worth fixing before consumers go live (it'll silently mangle release notes on bundled releases); everything else is clean. The security architecture and the post-review fixes are solid.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@RobEasthope, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 48 minutes and 53 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0dd204a9-fb4c-4c37-b4e7-4dfd398663a0

📥 Commits

Reviewing files that changed from the base of the PR and between 310c194 and f7b8949.

📒 Files selected for processing (1)
  • .github/workflows/reusable-release.yml
📝 Walkthrough

Walkthrough

Adds a reusable release workflow that builds one npm tarball, gates publishing on tag/version state, publishes to npm with OIDC, optionally mirrors to GitHub Packages with attestation, creates a GitHub Release, and updates failure tracking. CLAUDE.md documents the workflow.

Changes

Reusable release workflow

Layer / File(s) Summary
Workflow contract and documentation
.github/workflows/reusable-release.yml, CLAUDE.md
Defines the reusable workflow inputs, root permissions, and the new release-workflow documentation section.
Build tarball artefact
.github/workflows/reusable-release.yml
Checks out the project, runs the build script, packs one npm tarball, and uploads it with short retention.
Release publish and GitHub release
.github/workflows/reusable-release.yml
Checks out with full history, installs the requested npm version, gates publishing on the version/tag check, publishes the tarball via Trusted Publishing with optional provenance, creates the GitHub Release, and updates the failure-tracking issue on error.
GitHub Packages mirror
.github/workflows/reusable-release.yml
Downloads the same tarball, creates an attestation, validates the registry host, and publishes to GitHub Packages with GITHUB_TOKEN.

Sequence Diagram(s)

sequenceDiagram
  participant "Caller workflow" as CallerWorkflow
  participant "reusable release workflow" as ReusableWorkflow
  participant "build job" as BuildJob
  participant "GitHub Actions artefact" as ArtifactStore
  participant "release job" as ReleaseJob
  participant "npm registry" as NpmRegistry
  participant "GitHub Releases" as GitHubReleases
  participant "GitHub Issues" as GitHubIssues

  CallerWorkflow->>ReusableWorkflow: workflow_call
  ReusableWorkflow->>BuildJob: run build job
  BuildJob->>ArtifactStore: upload npm tarball
  ReusableWorkflow->>ReleaseJob: run release job
  ReleaseJob->>ArtifactStore: download npm tarball
  ReleaseJob->>NpmRegistry: npm view version tag
  alt version tag is missing
    ReleaseJob->>NpmRegistry: npm publish tarball
    ReleaseJob->>GitHubReleases: create release for the version tag
  end
  alt release job fails
    ReleaseJob->>GitHubIssues: open or append failure-tracking issue
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding a reusable release workflow.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sk-417-reusable-releaseyml-workflow
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch sk-417-reusable-releaseyml-workflow

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 26, 2026

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/reusable-release.yml:
- Around line 421-450: The current failure notifier in the release workflow only
lives inside the main release job, so mirror-job failures can end the run
without opening or updating the tracking issue. Add a separate final notifier
job that depends on both publish jobs and uses the same issue/comment logic as
the existing “Notify on release failure” step, with a workflow-level failure
condition so it runs when either publish leg fails. Keep the existing GH issue
search/create/comment behavior, but move or duplicate it into that final job so
failures from the mirror path are always captured.
- Around line 243-246: The release flow in reusable-release.yml is using the
tag-based should_publish gate too early, which can let GitHub Packages publish
before the mirror leg finishes and then skip the mirror on reruns. Update the
workflow around the should_publish output, the tag creation/release step, and
the publish jobs so tagging happens only after the optional mirror completes, or
derive each registry’s publish decision from its own state instead of tag
existence. Focus on the reusable-release job outputs, the tag/release creation
block, and the mirror/publish job ordering.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d0420031-14cf-4961-8091-8bbc6fa5fbe2

📥 Commits

Reviewing files that changed from the base of the PR and between 9b7e7dc and 310c194.

📒 Files selected for processing (2)
  • .github/workflows/reusable-release.yml
  • CLAUDE.md

Comment on lines +243 to +246
outputs:
# Reused by publish-github-packages so it gates on the same condition
# without re-detecting (true = version bumped, not yet tagged = publish).
should_publish: ${{ steps.gate.outputs.publish }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Defer tag creation until after the mirror leg completes.

Line 246 makes the GitHub Packages job depend on the tag-based should_publish gate, but Line 412 creates that tag before the mirror runs. If the first run reaches the GitHub release step and the mirror later fails, a rerun flips should_publish to false and skips the mirror entirely, leaving npm and GitHub Packages out of sync. Move the tag/release step into a final job after the optional mirror, or gate each registry from its own publish state instead of tag existence.

Also applies to: 379-417, 459-467

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/reusable-release.yml around lines 243 - 246, The release
flow in reusable-release.yml is using the tag-based should_publish gate too
early, which can let GitHub Packages publish before the mirror leg finishes and
then skip the mirror on reruns. Update the workflow around the should_publish
output, the tag creation/release step, and the publish jobs so tagging happens
only after the optional mirror completes, or derive each registry’s publish
decision from its own state instead of tag existence. Focus on the
reusable-release job outputs, the tag/release creation block, and the
mirror/publish job ordering.

Comment thread .github/workflows/reusable-release.yml Outdated
…7 review)

Address the AI code-review findings on PR #9:

- Assert exactly one tarball in both "Resolve tarball path" steps (mirroring
  the pack step) so a corrupt/multi-file artifact fails loudly rather than as a
  confusing multi-line $TARBALL "no such file" mid-publish (claude-review).
- Gate the release job's artifact download + resolve on should_publish so a
  no-op feature merge skips the wasted download (claude-review).
- Move the failure notifier into a dedicated terminal job that needs all three
  jobs (build + both publish legs) and fires on if: failure(). Previously the
  notify step lived only in the release job, so a GitHub Packages mirror failure
  (which lands after the npm leg has gone green) ended the run silently with no
  tracking issue (CodeRabbit). Now build, npm and mirror failures are all
  captured; the release job drops issues: write accordingly.

Deferred (SK-457): CodeRabbit's tag-before-mirror rerun desync — a pre-existing
property of the estate's reference release.yml; the fix should land on both the
reference and this reusable copy, out of scope for the port.
@RobEasthope

Copy link
Copy Markdown
Contributor Author

Review triage (claude-review + CodeRabbit)

Addressed in f7b8949:

Finding Source Action
Resolve tarball path used ls instead of an "exactly one .tgz" assertion claude (Medium) Fixed — both resolve steps now mirror the pack step's set -- … assert guard, so a corrupt/multi-file artifact fails loudly instead of as a confusing multi-line $TARBALL.
Mirror-leg failure never opened the tracking issue (notifier lived only in release) CodeRabbit (Major) Fixed — moved the notifier into a dedicated terminal notify-failure job that needs: [build, release, publish-github-packages] + if: failure(). Now build, npm and mirror failures are all captured (the previous version also silently missed build failures). release drops issues: write.
Download/Resolve ran on no-publish merges claude (Minor) Fixed — gated both on should_publish == 'true'.
Tag created before the mirror → npm/Packages can desync on a fresh re-push / "re-run all" CodeRabbit (Major) Deferred → SK-457. This is a pre-existing property of the estate's reference release.yml, not a regression from the port. The mirror is already idempotent and "Re-run failed jobs" recovers correctly (the new runbook says so); the proper fix restructures release sequencing and must land on the reference too, so it's tracked separately to avoid SK-384 drift.
Duplicate node -p …version read in gate + tag steps claude (Nit) Skipped — cosmetic.

All checks green before these changes; actionlint/yamllint/markdownlint/prettier clean locally on the update.

@RobEasthope

Copy link
Copy Markdown
Contributor Author

Incremental review pass (on f7b8949) — triage

Both AI reviewers re-ran and are green. No further code changes warranted:

  • claude-review — new Medium: $'\n\n' inside the changelog-notes concatenation (${notes:+$'\n\n'})Not a bug; skipped. ANSI-C quoting is applied to the alternate-value word of ${var:+word} even within the surrounding double quotes. Verified empirically with od -c on bash 3.2.57 and bash 5.3.15 — the original and the suggested nl=$'\n\n' form produce byte-identical output (first entry·\n·\n·second entry). The separator works as intended.
  • CodeRabbit — notifier (mirror failures silent) → addressed in f7b8949 (dedicated notify-failure job); the inline thread is now marked outdated.
  • CodeRabbit — tag created before mirror (rerun desync) → still the deferred item, tracked in SK-457 (pre-existing in the estate reference; fix lands on both copies).

So the only remaining open thread is the SK-457 deferral. The earlier CHANGES_REQUESTED is from that thread + the now-outdated notifier thread, not from anything unaddressed.

@RobEasthope
RobEasthope dismissed coderabbitai[bot]’s stale review June 26, 2026 20:10

Triaged on the PR: the actionable findings (notifier, tarball guard, gated download) are fixed in f7b8949; the remaining tag-before-mirror item is a pre-existing property of the estate reference, deferred to SK-457. Dismissing the stale review to merge.

@RobEasthope
RobEasthope merged commit 18e081c into main Jun 26, 2026
5 checks passed
@RobEasthope
RobEasthope deleted the sk-417-reusable-releaseyml-workflow branch June 26, 2026 20:11
RobEasthope added a commit that referenced this pull request Jun 30, 2026
## What

Reconstructs `shared-workflows`' **entire release history** and
graduates the current code to a stable **`v1.0.0`** (A-585). The repo
publishes no npm package, so a "release" here is a **git tag (`vX.Y.Z`)
+ GitHub release** over the reusable workflows and composite actions.

## Versioning methodology

The merged PR **titles are not reliable** — Conventional-Commit
discipline was loose when the repo was stood up (the GO/NO GO gate
shipped as `ci:`, the versioned ruleset as `chore:`). So every PR was
re-classified by **what it actually changed to the product surface**
(the `reusable-*.yml` workflows + `.github/actions/*` consumers use, and
their reference docs), read from the diff. Pre-1.0 rules: `feature` →
minor, `fix` → patch, breaking → minor (stay in `0.x`), and `1.0.0` is a
deliberate "graduate to stable" milestone.

| Version | PR | True category | Headline |
|---|---|---|---|
| 0.1.0 | #1 | feature | bootstrap: 3 reusable workflows |
| 0.2.0 | #5 | feature | Layer-1 composite-action layer |
| 0.3.0 | #6 | feature | reusable-lint bundle |
| 0.4.0 | #7 | feature | reusable build-test bundle + `build` action |
| 0.4.1 | #8 | fix | repoint build-test pins so the build lane resolves
|
| 0.5.0 | #9 | feature | reusable pkg-release workflow |
| 0.6.0 | #15 | refactor **(breaking)** | rename release → pkg-release |
| 0.7.0 | #16 | **feature** *(titled `ci:`)* | GO/NO GO aggregator gate
+ docs |
| 0.8.0 | #17 | **feature** *(titled `chore:`)* | versioned canonical
estate ruleset |
| 1.0.0 | HEAD | milestone | graduate to stable public surface |

Non-release PRs (self-host CI, ADR, dep bump, SK→A rename, skill
adoptions/re-syncs, check-run rename) are folded into the body of the
release window they merged in — the way a release-please changelog
aggregates.

## Changes

- **`changelog/`** — hand-authored dated entries (one per release) + the
schema doc (`changelog/README.md`), adapted from the estate's
`eslint-config`/`agent-skills` convention.
- **`package.json`** — `0.0.0` → `1.0.0` (stays `private`; the version
is the release anchor, not an npm publish).
- **`reusable-lint.yml`** — pin the five sibling Layer-1 actions to the
**`v0.8.0`** tag (`@7f543be`) instead of the bare pre-release commit,
so Dependabot can track them (`v0.8.0` is the latest backfilled tag that
carries every action and predates this commit, avoiding a self-reference
to `v1.0.0`).
- **`CLAUDE.md` / `README`** — replace the "no release tag exists yet"
wording.

## After merge

The git tags + GitHub releases are cut once this lands:
`v0.1.0`–`v0.8.0` at their historical merge commits, `v1.0.0` at this
PR's squash-merge commit, with notes sourced from each `changelog/`
entry. This unblocks consumers pinning a tag (+ Dependabot) instead of a
moving SHA (A-420 / A-446).

## Deferred (follow-up)

- **release-please automation** + a tag-only release workflow — the
forward release process is a separate decision now the backfill exists.

## Note for review

> Backfilling git tags at historical commits is a deliberate divergence
from the estate precedent (`eslint-config`/`agent-skills` backfilled
changelog *entries* but tagged only going forward) — chosen here because
A-585 explicitly wants the *full tag history* reconstructed.

The pre-existing Prettier drift on the vendored `.claude`/`.agents`
skill bundles is untouched (out of scope).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Marked the reusable workflows and composite actions as stable at
**v1.0.0**.
  * Updated workflow references to use the newer version tags.

* **Documentation**
  * Clarified release and versioning guidance across the repository.
* Added and expanded changelog documentation and release notes
structure.

* **Chores**
  * Updated internal workflow pins to the latest approved release.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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.

1 participant