-
Notifications
You must be signed in to change notification settings - Fork 2
ci: replace direct-push release with a PR-based release flow #74
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
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 |
|---|---|---|
| @@ -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: 22.x | ||
| 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}" | ||
|
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. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Update the existing release PR body. If a release PR remains open and a later main-branch change changes 🤖 Prompt for AI Agents |
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
Repository: WYRE-AI/node-halopsa
Length of output: 332
🏁 Script executed:
Repository: WYRE-AI/node-halopsa
Length of output: 12736
Sensitive Data Exposure (CWE-522): Insufficiently Protected Credentials
Reachability: External · Exploitability: Difficult
Restore a clean trust boundary before credentialed release commands.
A compromised dependency can modify project files or installed modules during
npm ci. Later credentialed npm and Node commands can then execute the modified code or use modified npm configuration to exfiltrateGITHUB_TOKENandNODE_AUTH_TOKEN.Run credentialed release operations in a fresh job or clean checkout. Install release dependencies with lifecycle scripts disabled, and generate npm authentication configuration outside the mutable workspace.
🤖 Prompt for AI Agents