-
-
Notifications
You must be signed in to change notification settings - Fork 1
chore: add publish workflow #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
| - name: Setup node | ||
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | ||
| with: | ||
| node-version: 24 | ||
| cache: 'pnpm' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does using a cache in the |
||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would node 24 from |
||
|
|
||
| - 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. installing |
||
|
|
||
| - 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 | ||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
| 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') | ||
|
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." | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -108,5 +108,8 @@ | |
| "@schematics/angular:resolver": { | ||
| "typeSeparator": "." | ||
| } | ||
| }, | ||
| "cli": { | ||
| "analytics": false | ||
| } | ||
| } | ||
| 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" }, | ||
| ] |
Uh oh!
There was an error while loading. Please reload this page.