From 9a82ca27a315eab0b0148bbeebb91825295cf014 Mon Sep 17 00:00:00 2001 From: Gerome Grignon Date: Wed, 5 Aug 2026 21:16:00 +0200 Subject: [PATCH] chore: add publish workflow --- .github/workflows/publish.yml | 131 ++++++++++++++++++++++++++ .github/workflows/release-start.yml | 140 ++++++++++++++++++++++++++++ angular.json | 3 + cliff.toml | 64 +++++++++++++ 4 files changed, 338 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/release-start.yml create mode 100644 cliff.toml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..1da2766 --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 + + - name: Setup node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: 'pnpm' + 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 + + - 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 + + - 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 diff --git a/.github/workflows/release-start.yml b/.github/workflows/release-start.yml new file mode 100644 index 0000000..c624ec1 --- /dev/null +++ b/.github/workflows/release-start.yml @@ -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 + 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') + 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." diff --git a/angular.json b/angular.json index 58fc417..a010f28 100644 --- a/angular.json +++ b/angular.json @@ -108,5 +108,8 @@ "@schematics/angular:resolver": { "typeSeparator": "." } + }, + "cli": { + "analytics": false } } diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..2b19765 --- /dev/null +++ b/cliff.toml @@ -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 = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^docs", group = "Documentation" }, + { message = "^test", group = "Tests" }, + { message = "^chore\\(release\\)", skip = true }, + { message = "^chore\\(deps.*\\)", group = "Dependencies" }, + { message = "^(chore|ci|build|style)", group = "Maintenance" }, + { message = ".*", group = "Other Changes" }, +]