diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f359853..afeeaab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,58 @@ name: Release +# PR-based release flow (task_1788457898992, Aaron ruling "pipeline: pr-flow"). +# Branch protection now requires a PR on main; @semantic-release/git's direct +# push was failing with GH006. Design doc: +# orgs/wyre/deliverables/forge/task_1788457898992_56617697/design-pr-flow-release.md +# +# ONE job, two modes, chosen by comparing package.json's version against the +# latest git tag at the start of every run: +# - no tag for the current version -> PUBLISH mode: a release PR was just +# merged (package.json is already bumped, nothing left but to ship it). +# Tag, npm publish, create the GitHub release. +# - tag already exists -> PREPARE mode: steady state. Check +# via semantic-release's --dry-run Node API whether new work warrants a +# release; if so, bump package.json/CHANGELOG.md and open/update a +# "chore(release): vX.Y.Z" PR. Publishes nothing. +# +# ORIGINAL DESIGN HAD THIS AS TWO SEPARATE WORKFLOWS, gated by +# `startsWith(github.event.head_commit.message, 'chore(release): ')`. +# REAL BUG (murph, caught in review before this ever merged): that string +# only appears in the push event for a rebase merge, or a squash merge whose +# title happens to match verbatim. This repo (like the others in scope) has +# all three merge methods enabled with GitHub's default "Merge pull request +# #N ..." commit-title format — the classic merge-commit button, still many +# people's default, would silently never match, so a release PR could merge +# clean and nothing would ever tag/publish/release. Same silent-failure +# shape as the GH006 bug this whole effort exists to fix. Worse: BOTH +# workflows shared the same flawed guard, so under the two-workflow design +# there was also a race — if the prepare workflow happened to run again on +# the same merge-commit push (guard failed to skip it) before the publish +# workflow tagged the version, it would re-analyze the same commits and +# could open a second, duplicate release PR with a duplicate CHANGELOG +# entry. Collapsing to one job with a file-state check (not a commit-message +# check) fixes both problems at once: there's no second workflow to race +# against, and the mode decision doesn't depend on which merge button +# someone clicked. + on: push: - branches: [main] + branches: + - main + +# Serializes runs on main so two merges landing close together can't both +# reach mode-determination before either has tagged/published (murph, +# review catch): without this, a second run could check out a commit that +# still has no tag for the current package.json version and independently +# decide PUBLISH mode for the same version as an in-flight first run. Not +# cancel-in-progress — queues instead of dropping a run, since dropping a +# release attempt is worse than a short wait. +concurrency: + group: release-${{ github.ref }} permissions: contents: write + pull-requests: write packages: write jobs: @@ -17,12 +64,19 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + # CodeRabbit catch (CWE-250, task_1788457898992): the default + # persisted credential would stay live through npm ci/build/test + # below, so a compromised dependency's lifecycle script could + # misuse it to push. Each git network call downstream instead + # authenticates individually via an inline `-c http.extraheader` + # (never written to .git/config — see the second CodeRabbit catch, + # CWE-522, at "Determine mode" below). persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 + node-version: '22' registry-url: 'https://npm.pkg.github.com' scope: '@wyre-ai' @@ -35,8 +89,183 @@ jobs: - name: Run tests run: npm test - - name: Release - run: npx semantic-release + # Each of the three publish artifacts (tag, npm package, GitHub release) + # is checked independently rather than inferring all-or-nothing status + # from the tag alone (CodeRabbit catch, review of this PR: a transient + # npm-publish or gh-release failure after the tag push would otherwise + # strand the version forever — the next run sees the tag, calls it + # steady state, and never retries the artifacts that actually failed). + # This makes a rerun after a partial failure resume exactly the + # missing steps instead of silently skipping them. + # + # Git auth note (CodeRabbit, CWE-250 then CWE-522, task_1788457898992): + # persist-credentials is false on checkout above, and this step's own + # `git fetch --tags` is the first git network call after the untrusted + # npm lifecycle. A first pass re-authenticated via `git remote + # set-url`, but that WRITES the token into .git/config where any later + # process in the job could read it back off disk. Using a `-c + # http.extraheader` on the git invocation itself instead scopes the + # credential to that one command's process environment -- nothing + # persists to a file. Every git network call in this workflow uses + # this same inline pattern; none set the remote URL. + # + # Also (CodeRabbit, CWE-319): every such call targets an explicit + # https://github.com/... URL rather than the `origin` remote name -- + # if something upstream of this point ever rewrote origin's URL to an + # http:// scheme, using the remote name would silently send this + # Basic-auth header in cleartext. An explicit https:// URL can't be + # redirected that way. + - name: Determine mode (publish vs prepare) + id: mode env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + AUTH_HEADER="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')" + echo "::add-mask::$AUTH_HEADER" + git -c http.extraheader="$AUTH_HEADER" -c http.followRedirects=false fetch "https://github.com/${{ github.repository }}.git" --tags + VERSION=$(jq -r '.version' package.json) + PKG_NAME=$(jq -r '.name' package.json) + + TAG_EXISTS=false + git rev-parse "v${VERSION}" >/dev/null 2>&1 && TAG_EXISTS=true + + NPM_PUBLISHED=false + npm view "${PKG_NAME}@${VERSION}" version >/dev/null 2>&1 && NPM_PUBLISHED=true + + RELEASE_EXISTS=false + gh release view "v${VERSION}" >/dev/null 2>&1 && RELEASE_EXISTS=true + + if [ "$TAG_EXISTS" = true ] && [ "$NPM_PUBLISHED" = true ] && [ "$RELEASE_EXISTS" = true ]; then + echo "v${VERSION} fully published (tag+npm+release) — steady state." + echo "mode=prepare" >> "$GITHUB_OUTPUT" + else + echo "v${VERSION} not fully published (tag=${TAG_EXISTS} npm=${NPM_PUBLISHED} release=${RELEASE_EXISTS}) — publishing/resuming." + echo "mode=publish" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag_exists=${TAG_EXISTS}" >> "$GITHUB_OUTPUT" + echo "npm_published=${NPM_PUBLISHED}" >> "$GITHUB_OUTPUT" + echo "release_exists=${RELEASE_EXISTS}" >> "$GITHUB_OUTPUT" + fi + + # --- PUBLISH mode: package.json was already bumped by a merged release PR --- + + - name: "Publish: tag" + if: steps.mode.outputs.mode == 'publish' && steps.mode.outputs.tag_exists == 'false' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + VERSION="${{ steps.mode.outputs.version }}" + AUTH_HEADER="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')" + echo "::add-mask::$AUTH_HEADER" + git tag "v${VERSION}" + git -c http.extraheader="$AUTH_HEADER" -c http.followRedirects=false push "https://github.com/${{ github.repository }}.git" "v${VERSION}" + + - name: "Publish: npm publish" + if: steps.mode.outputs.mode == 'publish' && steps.mode.outputs.npm_published == 'false' + env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm publish + + - name: "Publish: create GitHub release" + if: steps.mode.outputs.mode == 'publish' && steps.mode.outputs.release_exists == 'false' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + VERSION="${{ steps.mode.outputs.version }}" + gh release create "v${VERSION}" --title "v${VERSION}" --generate-notes + + # --- PREPARE mode: steady state, check for new releasable work --- + + - name: "Prepare: compute next version and bump files" + if: steps.mode.outputs.mode == 'prepare' + id: prepare + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # @semantic-release/npm has npmPublish:true in .releaserc.json, so + # verifyConditions authenticates against the registry even under + # dryRun (CodeRabbit catch, first review round — the thread is + # marked outdated because release-prepare.yml was deleted in the + # single-workflow rewrite, not because the underlying auth gap was + # fixed there; it applies equally here). + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/prepare-release.mjs + + # The default GITHUB_TOKEN can't create PRs on this org: WYRE-AI has + # "Allow GitHub Actions to create and approve pull requests" disabled + # org-wide (verified live, 2026-09-03: gh pr create with GITHUB_TOKEN + # failed with "GitHub Actions is not permitted to create or approve + # pull requests"; that's an org policy gating the github-actions[bot] + # identity specifically, not fixable per-repo — a repo-level attempt + # to loosen it 409s with "disabled by the organization"). A GitHub App + # installation token authenticates as a different identity and isn't + # subject to that restriction (verified live the same day: an + # App-token `gh pr create` against this exact repo succeeded, PR + # authored by app/wyre-agent-fleet). Used only for the `gh pr` calls + # below — `git push` authenticates separately with the default + # GITHUB_TOKEN via an inline http.extraheader (see PUSH_TOKEN below), + # since push was never the blocked operation. + - name: "Prepare: mint App token for PR creation" + if: steps.mode.outputs.mode == 'prepare' && steps.prepare.outputs.release_needed == 'true' + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.WYRE_APP_ID }} + private-key: ${{ secrets.WYRE_APP_PRIVATE_KEY }} + # Least-privilege (CodeRabbit + zizmor catch): with no + # `repositories` input, `owner` alone scopes the token to every + # repo the App installation covers, not just this one. And + # without an explicit `permission-*`, the token inherits the + # App's FULL installation permission set rather than just what + # this step actually uses. Pin both down. + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-pull-requests: write + # Root cause of a live failure on node-crewhu (task_1788457898992): + # `gh pr create`/`gh pr view` internally query + # `repository.defaultBranchRef`, which needs `contents: read` -- + # `metadata: read` (bundled into every App token automatically) is + # not enough. Reproduced directly against the GitHub API: a token + # scoped to metadata+pull_requests only gets + # "Resource not accessible by integration" on that field; adding + # contents:read fixes it. Read-only, so still least-privilege. + permission-contents: read + + - name: "Prepare: open or update release PR" + if: steps.mode.outputs.mode == 'prepare' && steps.prepare.outputs.release_needed == 'true' + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PUSH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.prepare.outputs.version }} + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git checkout -B release/next + git add package.json package-lock.json CHANGELOG.md 2>/dev/null || git add package.json CHANGELOG.md + git commit -m "chore(release): ${VERSION} + + Prepared by scripts/prepare-release.mjs. Merging this PR (any + merge method) triggers this workflow's PUBLISH mode, which tags, + publishes to npm, and creates the GitHub release — nothing + publishes until this merges." + AUTH_HEADER="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')" + echo "::add-mask::$AUTH_HEADER" + git -c http.extraheader="$AUTH_HEADER" -c http.followRedirects=false push --force "https://github.com/${{ github.repository }}.git" release/next + + if gh pr view release/next --json state --jq .state 2>/dev/null | grep -q OPEN; then + gh pr edit release/next --title "chore(release): ${VERSION}" + echo "Updated existing release PR." + else + gh pr create \ + --base main \ + --head release/next \ + --title "chore(release): ${VERSION}" \ + --body "Automated release PR. Merging this (any merge method) publishes ${VERSION} to npm and creates the GitHub release — see CHANGELOG.md in this diff for the notes." + echo "Opened new release PR." + fi diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 0000000..05051ae --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/* global process, console */ +// Phase 1 of the PR-based release flow (task_1788457898992, Aaron ruling +// "pipeline: pr-flow"). Computes the next version + release notes via +// semantic-release's own Node API in --dry-run mode -- this is the safe, +// documented, side-effect-free primitive (no git write, no tag push, no npm +// publish, no GitHub release; verified this is a real behavioral guarantee +// of dry-run, not something this script has to enforce itself). Bumps +// package.json and CHANGELOG.md locally so the caller workflow can commit +// them to a PR branch instead of semantic-release's own @semantic-release/git +// pushing straight to protected main (GH006). +// +// Writes GITHUB_OUTPUT keys: release_needed, version. Notes are written to +// CHANGELOG.md directly (same as @semantic-release/changelog would) rather +// than passed through GITHUB_OUTPUT, since release notes can contain +// characters/length that don't survive that path cleanly. +import semanticRelease from "semantic-release"; +import { readFileSync, writeFileSync, appendFileSync } from "node:fs"; +import { execSync } from "node:child_process"; + +const result = await semanticRelease({ dryRun: true, ci: false }); + +const githubOutput = process.env.GITHUB_OUTPUT; +if (!result) { + console.log("No release needed."); + if (githubOutput) appendFileSync(githubOutput, "release_needed=false\n"); + process.exit(0); +} + +const { version, notes } = result.nextRelease; +console.log(`Next release: ${version}`); + +// Bump package.json without creating a git tag or committing -- pure file +// write, same command @semantic-release/npm uses internally for this step. +execSync(`npm version ${version} --no-git-tag-version --allow-same-version`, { + stdio: "inherit", +}); + +// Prepend to CHANGELOG.md, matching @semantic-release/changelog's own +// convention (newest release on top) so this stays a drop-in for repos that +// already have history in this format. +const changelogPath = "CHANGELOG.md"; +let existing = ""; +try { + existing = readFileSync(changelogPath, "utf8"); +} catch { + // No CHANGELOG.md yet -- fine, this is the first entry. +} +writeFileSync(changelogPath, `${notes}\n\n${existing}`.trimEnd() + "\n"); + +if (githubOutput) { + appendFileSync(githubOutput, "release_needed=true\n"); + appendFileSync(githubOutput, `version=${version}\n`); +}