Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
name: Publish

# When a release PR (branch "release/v*", opened by release-start.yml) is
# merged into main:
# 1. `npm-publish` — build the library and publish it to npm using trusted
# publishing (OIDC — no npm token; provenance attached automatically).
# 2. `finalize-release` — regenerate organized release notes with git-cliff
# (from conventional commits), retarget the tag to the merge commit, and
# flip the draft release to published. Only runs if npm publish succeeded,
# so a release never goes public for a version that failed to ship.
#
# Security notes:
# - Actions pinned to full commit SHAs (tags can be moved/hijacked).
# - Only same-repo branches qualify: a fork PR named "release/*" can't publish.
# - Jobs are split so the npm-publish job (which runs repo build scripts) has
# no write access to the repo, and the release job (contents: write) runs
# no package code — only git-cliff over git history.
# - No git credentials persisted; untrusted inputs passed via `env`.

on:
pull_request:
types: [closed]
branches: [main]

concurrency:
group: npm-publish
cancel-in-progress: false

jobs:
npm-publish:
if: >-
github.event.pull_request.merged == true &&
github.event.pull_request.head.repo.full_name == github.repository &&
startsWith(github.event.pull_request.head.ref, 'release/')
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required for npm trusted publishing
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Pin to the PR's merge commit, not `main`: main can move between
# the merge event and this job running, and the published artifact
# must match exactly what was reviewed and merged.
ref: ${{ github.event.pull_request.merge_commit_sha }}
persist-credentials: false

- name: Setup pnpm
# No `version` input on purpose: the action installs the exact version
# from the `packageManager` field in package.json (single source of
# truth), and fails loudly if that field is ever removed.
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8

Comment thread
geromegrignon marked this conversation as resolved.
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'pnpm'

@JasonWeinzierl JasonWeinzierl Aug 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

does using a cache in the publish job open us up to the risk of cache poisoning?

registry-url: 'https://registry.npmjs.org'

- run: pnpm install --frozen-lockfile

- name: Test
run: pnpm run test:lib

- name: Test Schematics
run: pnpm run test:schematics

- name: Build
run: pnpm run build:lib

- name: Update npm CLI (trusted publishing requires npm >= 11.5.1)
run: npm install -g npm@^11.5.1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

would node 24 from setup-node come with an up to date version of npm that makes this unnecessary?


- name: Publish to npm
working-directory: dist/openng/cashew
run: npm publish --access public

finalize-release:
needs: npm-publish
runs-on: ubuntu-latest
permissions:
contents: write # edit + publish the release, create the tag
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Same pinning as npm-publish: notes are generated from the exact
# merge commit the release ships, not whatever main has become.
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0 # git-cliff needs full history and tags
persist-credentials: false

- name: Extract version from branch name
id: version
env:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
VERSION="${HEAD_REF#release/}"
VERSION="${VERSION#v}"
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Branch name does not contain a valid semver version"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Generate organized release notes
env:
VERSION: ${{ steps.version.outputs.version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # for git-cliff's contributor lookup
run: npx --yes git-cliff@2.13.1 --unreleased --tag "v$VERSION" --strip all --output release-notes.md

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

installing git-cliff in a job with a write token could be a security gap. can we generate the release notes in a separate job?


- name: Publish GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
TAG="v${VERSION}"
if gh release view "$TAG" > /dev/null 2>&1; then
gh release edit "$TAG" \
--target "$MERGE_SHA" \
--title "$TAG" \
--notes-file release-notes.md \
--draft=false
else
echo "::warning::No draft release found for $TAG; creating it directly."
gh release create "$TAG" \
--target "$MERGE_SHA" \
--title "$TAG" \
--notes-file release-notes.md
fi
140 changes: 140 additions & 0 deletions .github/workflows/release-start.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
name: Start release

# Flow:
# 1. You create a DRAFT release manually in the GitHub UI (choose the new
# tag, e.g. v5.4.0, and click "Generate release notes" for raw notes).
# GitHub Actions receives no events for draft releases, so this workflow
# polls for them on a schedule (and can also be run manually from the
# Actions tab for an immediate pickup).
# 2. When it finds a draft with no open release PR, it opens the
# version-bump PR.
# 3. Merging that PR triggers publish.yml: npm publish, then the draft is
# published with notes reorganized by git-cliff.
#
# Idempotent by design: runs are no-ops when there is no draft, when an open
# release PR already exists, or when main already carries the version.
#
# Security notes:
# - Actions pinned to full commit SHAs; untrusted values passed via `env`.

on:
schedule:
- cron: '*/15 * * * *' # poll for new draft releases

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

do we get any benefit from the "create a draft release" flow? versus just executing this job via workflow_dispatch directly? Running this poll every 15min is a lot of no-op runs.

workflow_dispatch:
inputs:
tag:
description: 'Tag of the draft release (e.g. v5.4.0). Defaults to the most recently created draft.'
required: false
type: string

permissions:
contents: write
pull-requests: write

concurrency:
group: release-start
cancel-in-progress: false

jobs:
start-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: main

- name: Find draft release
id: draft
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_TAG: ${{ inputs.tag }}
run: |
if [ -n "$INPUT_TAG" ]; then
TAG=$(gh api "repos/${GITHUB_REPOSITORY}/releases" \
--jq '[.[] | select(.draft == true and .tag_name == env.INPUT_TAG)][0].tag_name // empty')
Comment thread
geromegrignon marked this conversation as resolved.
if [ -z "$TAG" ]; then
echo "::error::No draft release found with tag '$INPUT_TAG'"
exit 1
fi
else
TAG=$(gh api "repos/${GITHUB_REPOSITORY}/releases" \
--jq '[.[] | select(.draft == true)] | sort_by(.created_at) | last | .tag_name // empty')
if [ -z "$TAG" ]; then
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
echo "::error::No draft release found. Create one in the GitHub UI first (Releases -> Draft a new release)."
exit 1
fi
echo "No draft release found; nothing to do."
exit 0
fi
fi
echo "Found draft release: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"

- name: Validate version and check for existing PR
id: version
if: steps.draft.outputs.tag != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.draft.outputs.tag }}
run: |
VERSION="${TAG#v}"
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Draft release tag '$TAG' is not a valid semver version (expected e.g. v5.4.0)"
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" > /dev/null 2>&1; then
echo "::error::Tag v${VERSION} already exists as a git tag"
exit 1
fi
OPEN_PRS=$(gh pr list --head "release/v${VERSION}" --base main --state open --json number --jq 'length')
if [ "$OPEN_PRS" -gt 0 ]; then
echo "Release PR for v${VERSION} already open; nothing to do."
echo "proceed=false" >> "$GITHUB_OUTPUT"
else
echo "proceed=true" >> "$GITHUB_OUTPUT"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Bump library version
if: steps.version.outputs.proceed == 'true'
working-directory: projects/openng/cashew
env:
VERSION: ${{ steps.version.outputs.version }}
run: npm pkg set version="$VERSION"

- name: Create pull request
if: steps.version.outputs.proceed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
run: |
BRANCH="release/v${VERSION}"

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

git checkout -b "$BRANCH"
git add projects/openng/cashew/package.json

if git diff --cached --quiet; then
echo "package.json is already at ${VERSION}; nothing to do."
exit 0
fi

git commit -m "chore(release): ${VERSION}"

# Refresh the remote-tracking ref (if the branch exists from an
# earlier attempt), then push with a lease: stale branches are
# overwritten idempotently, but a push that lands between the fetch
# and ours is never clobbered.
git fetch origin "+refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}" 2>/dev/null || true
git push --force-with-lease origin "$BRANCH"

gh pr create \
--base main \
--head "$BRANCH" \
--title "chore(release): v${VERSION}" \
--body "Bumps \`@openng/cashew\` to \`${VERSION}\`, matching the draft release [v${VERSION}](https://github.com/${GITHUB_REPOSITORY}/releases).

Merging this PR publishes the package to npm via trusted publishing, then publishes the release with reorganized notes."
3 changes: 3 additions & 0 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,5 +108,8 @@
"@schematics/angular:resolver": {
"typeSeparator": "."
}
},
"cli": {
"analytics": false
}
}
64 changes: 64 additions & 0 deletions cliff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# git-cliff configuration — used by .github/workflows/publish.yml to generate
# organized release notes from conventional commits.
# https://git-cliff.org/docs/configuration

[changelog]
header = ""
body = """
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}

{% for commit in commits -%}
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/openng-foundation/cashew/commit/{{ commit.id }}))
{% endfor %}
{% endfor %}
{% if github.contributors | length != 0 %}
### Contributors

{% for contributor in github.contributors -%}
- [@{{ contributor.username }}](https://github.com/{{ contributor.username }})
{% endfor %}
{% endif -%}
{% set first_timers = github.contributors | filter(attribute="is_first_time", value=true) -%}
{% if first_timers | length != 0 %}
### New Contributors

{% for contributor in first_timers -%}
- @{{ contributor.username }} made their first contribution{% if contributor.pr_number %} in #{{ contributor.pr_number }}{% endif %}
{% endfor %}
{% endif %}
{% if previous.version and version %}
**Full changelog:** [{{ previous.version }}...{{ version }}](https://github.com/openng-foundation/cashew/compare/{{ previous.version }}...{{ version }})
{% endif %}
"""
footer = ""
trim = true

# Enables github.contributors in templates. The token is supplied via the
# GITHUB_TOKEN env var (set in publish.yml) — never hardcoded here.
[remote.github]
owner = "openng-foundation"
repo = "cashew"

[git]
conventional_commits = true
filter_unconventional = false
split_commits = false
protect_breaking_commits = true
filter_commits = false
tag_pattern = "v[0-9].*"
topo_order = false
sort_commits = "oldest"

commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^perf", group = "<!-- 2 -->Performance" },
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
{ message = "^docs", group = "<!-- 4 -->Documentation" },
{ message = "^test", group = "<!-- 5 -->Tests" },
{ message = "^chore\\(release\\)", skip = true },
{ message = "^chore\\(deps.*\\)", group = "<!-- 7 -->Dependencies" },
{ message = "^(chore|ci|build|style)", group = "<!-- 6 -->Maintenance" },
{ message = ".*", group = "<!-- 8 -->Other Changes" },
]
Loading