Skip to content
Merged
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
263 changes: 263 additions & 0 deletions .github/workflows/release-manual.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
name: Release (one-click)

on:
workflow_dispatch:
inputs:
version:
description: "Version to publish. Blank = auto next patch over npm latest."
required: false
default: ""

concurrency:
group: one-click-release
cancel-in-progress: false

jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
registry-url: https://registry.npmjs.org

- name: Resolve version
id: version
run: |
REQ="${{ inputs.version }}"
if [ -n "$REQ" ]; then
if ! echo "$REQ" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::version '$REQ' does not look like semver (x.y.z or x.y.z-prerelease)"
exit 1
fi
echo "version=$REQ" >> "$GITHUB_OUTPUT"
exit 0
fi
PKG="$(node -p "require('./package.json').name")"
CUR="$(npm view "$PKG" version)"
if [ -z "$CUR" ]; then
echo "::error::cannot read the npm latest version of $PKG"
exit 1
fi
V="$(node -p 'const p = process.argv[1].split("-")[0].split("."); p[2] = String(Number(p[2]) + 1); p.join(".")' "$CUR")"
echo "auto-bumped $CUR -> $V"
echo "version=$V" >> "$GITHUB_OUTPUT"

- name: Drift guard
run: |
PKG="$(node -p "require('./package.json').name")"
MASTER_V="$(node -p "require('./package.json').version")"
NPM_V="$(npm view "$PKG" version)"
if [ "$MASTER_V" != "$NPM_V" ]; then
echo "::error::$PKG: master package.json ($MASTER_V) != npm latest ($NPM_V). Sync master with the last published release before one-click releasing."
exit 1
fi
if [ "${{ steps.version.outputs.version }}" = "$NPM_V" ]; then
echo "::error::$NPM_V is already published; pick a newer version"
exit 1
fi

- name: Bump version
run: |
node -e '
const fs = require("fs");
const [oldV, newV] = process.argv.slice(1);
for (const f of ["package.json", "package-lock.json"]) {
const s = fs.readFileSync(f, "utf8");
const needle = `"version": "${oldV}"`;
const n = s.split(needle).length - 1;
if (!n) { console.error(`version field not found in ${f}`); process.exit(1); }
fs.writeFileSync(f, s.split(needle).join(`"version": "${newV}"`));
console.log(`${f}: replaced ${n} occurrence(s)`);
}
' "$(node -p "require('./package.json').version")" "${{ steps.version.outputs.version }}"

- name: Verify bump diff
run: |
git add package.json package-lock.json
CHANGED="$(git diff --cached --name-only)"
EXPECT="package-lock.json
package.json"
if [ "$CHANGED" != "$EXPECT" ]; then
echo "::error::bump touched unexpected files: $CHANGED"
exit 1
fi

- name: Pre-flight gate
run: |
npm ci
npm run check:package
npm test

- name: Commit and push to master
id: push
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
VER="${{ steps.version.outputs.version }}"
git commit -q -m "release v$VER"
if git push -q origin HEAD:master; then
echo "pushed release commit to master"
echo "direct=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::warning::direct push to master was blocked (branch protection). Fallback: release prepared on a branch; a PR is needed to publish."
BASE_BR="$(date -u +%Y-%m-%d)_release-v${VER}"
BR="$BASE_BR"
if git ls-remote --exit-code --heads origin "$BR" >/dev/null 2>&1; then
BR="${BASE_BR}-$(date -u +%H%M%S)"
echo "::warning::fallback branch ${BASE_BR} already exists on origin (stale earlier run of the same version+date); using ${BR} instead."
fi
if ! git push -q origin "HEAD:$BR"; then
echo "::error::fallback branch push to ${BR} failed — nothing published, aborting."
exit 1
fi
echo "fallback release branch pushed: ${BR}"
echo "needs_pr=true" >> "$GITHUB_OUTPUT"
echo "pr_branch=${BR}" >> "$GITHUB_OUTPUT"
exit 0

- name: Open fallback PR (best effort)
if: steps.push.outputs.needs_pr == 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_BRANCH: ${{ steps.push.outputs.pr_branch }}
run: |
VER="${{ steps.version.outputs.version }}"
LINK="https://github.com/${{ github.repository }}/pull/new/${PR_BRANCH}"
LAST_TAG="$(git describe --match 'v[0-9]*' --tags --abbrev=0 HEAD~1 2>/dev/null || true)"
if [ -n "$LAST_TAG" ]; then
NOTES="$(git log --no-merges --pretty='- %s' "${LAST_TAG}..HEAD~1")"
else
NOTES="(no prior release tag found)"
fi
PAYLOAD_FILE="$(mktemp)"
BODY_FILE="$(mktemp)"
VER="$VER" PR_BRANCH="$PR_BRANCH" LAST_TAG="$LAST_TAG" NOTES="$NOTES" node -e '
const fs = require("fs");
const body = [
"Auto-fallback from the one-click release workflow: direct push to master was blocked by branch protection. Merging this PR publishes via the standard release.yml flow.",
"",
"## Changes since " + (process.env.LAST_TAG || "n/a"),
"",
process.env.NOTES || "(no changes recorded)",
"",
"<!-- ework-agent-pr -->",
].join("\n");
fs.writeFileSync(process.argv[1], JSON.stringify({ title: "release v" + process.env.VER, head: process.env.PR_BRANCH, base: "master", body }));
fs.writeFileSync(process.argv[2], body);
' "$PAYLOAD_FILE" "$BODY_FILE"
RESP_FILE="$(mktemp)"
HTTP_CODE=$(curl -sS -o "$RESP_FILE" -w '%{http_code}' -X POST \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
"https://api.github.com/repos/${{ github.repository }}/pulls" \
-d @"$PAYLOAD_FILE")
PR_URL=$(node -e 'try{console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).html_url||"")}catch(e){console.log("")}' "$RESP_FILE")
echo "POST /pulls HTTP: $HTTP_CODE"
write_notes() {
{
echo "<details>"
echo "<summary>Release notes — paste into the PR description</summary>"
echo ""
echo '```markdown'
cat "$BODY_FILE"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
}
if [ -n "$PR_URL" ]; then
{
echo "### :rotating_light: Release v${VER} prepared — NOT PUBLISHED yet"
echo ""
echo "Direct push to master was blocked (branch protection); the release commit is on branch \`${PR_BRANCH}\`."
echo "Release PR opened automatically **with the generated changelog**: **${PR_URL}** — merge it to publish via CI."
} >> "$GITHUB_STEP_SUMMARY"
write_notes
echo "::notice::Release PR opened automatically: ${PR_URL} — merge it to publish v${VER}"
exit 0
fi
{
echo "### :rotating_light: Release v${VER} prepared — NOTHING PUBLISHED YET"
echo ""
echo "The release commit passed the full pre-flight gate and is on branch \`${PR_BRANCH}\`."
echo "Auto-opening the release PR failed (HTTP ${HTTP_CODE}): $(cat "$RESP_FILE")"
echo ""
echo "**Finish with one click:** [open the release PR](${LINK}), copy the release notes below into the description, then merge to publish."
} >> "$GITHUB_STEP_SUMMARY"
write_notes
echo "::warning::NOTHING WAS PUBLISHED YET — auto-PR failed (HTTP ${HTTP_CODE}): $(cat "$RESP_FILE")"
echo "::warning::Finish the release with one click: ${LINK} (then merge to publish v${VER})"
exit 0

- name: Check if prerelease
id: pre
if: steps.push.outputs.direct == 'true'
run: |
if echo "${{ steps.version.outputs.version }}" | grep -q '-'; then
echo "tag=dev" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "tag=latest" >> "$GITHUB_OUTPUT"
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi

- name: Create tag
if: steps.push.outputs.direct == 'true'
run: |
git tag "v${{ steps.version.outputs.version }}" || true
git push origin "v${{ steps.version.outputs.version }}" || true

- name: Publish
if: steps.push.outputs.direct == 'true'
run: npm publish --tag ${{ steps.pre.outputs.tag }} --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Create GitHub Release
if: steps.push.outputs.direct == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VER="${{ steps.version.outputs.version }}"
LAST="$(git describe --tags --abbrev=0 2>/dev/null || echo "")"
NOTES="$RUNNER_TEMP/release-notes.md"
if [ -n "$LAST" ] && [ "$LAST" != "v$VER" ]; then
{
echo "## Changes since $LAST"
echo ""
git log --no-merges --pretty='- %s' "$LAST..HEAD~1"
} > "$NOTES"
else
echo "- initial release" > "$NOTES"
fi
node -e '
const fs = require("fs");
const [notesFile, ver, prerelease, out] = process.argv.slice(1);
const body = {
tag_name: "v" + ver,
name: "v" + ver,
prerelease: prerelease === "true",
body: fs.readFileSync(notesFile, "utf8").trim()
};
fs.writeFileSync(out, JSON.stringify(body));
' "$NOTES" "$VER" "${{ steps.pre.outputs.prerelease }}" "$RUNNER_TEMP/release.json"
curl -sS -f -X POST \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-H "Content-Type: application/json" \
"https://api.github.com/repos/${{ github.repository }}/releases" \
-d @"$RUNNER_TEMP/release.json"
63 changes: 63 additions & 0 deletions devlog/2026-09-20_one-click-release/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# REQ - One-click release workflow (workflow_dispatch)

- Task ID: `2026-09-20_one-click-release`
- Home Repo: `opencode-acp`
- Created: 2026-09-20
- Status: InProgress
- Priority: P2
- Owner: ranxianglei
- References: https://github.com/ranxianglei/opencode-acp/pull/443 ; port of the one-click release workflow from ranxianglei/billion-context#772 (built there as #944 core + #978 green-on-prepared fallback + #994 generated changelog body + #1017 stale-branch collision fix)

## 1. Background & Problem Statement

- **Context**: Releases in this repo are already CI-automated end-to-end — merging a date-prefixed release branch (`YYYY-MM-DD_release-v{V}`) triggers `release.yml`, which tags, gates, publishes to npm, and creates the GitHub Release. What is still manual is *preparing* that release: bumping `package.json` + `package-lock.json`, pushing a branch, opening the PR, writing the changelog body. Every release therefore costs a round of manual bookkeeping before CI takes over.
- **Current behavior**: A human must locally edit two files, commit as `release v{V}`, push a correctly named branch, open a PR with a changelog body, then merge. Error-prone naming (release.yml only detects the branch name in the merge commit message) and duplicated bookkeeping are the recurring pain points.
- **Goal**: An Actions button ("Release (one-click)") that does all preparation automatically: resolve version → guard against drift → minimal bump → full pre-flight gate → land on master directly if allowed (publish in the same run) or prepare a correctly-named release branch + best-effort auto-PR otherwise (human merges; existing `release.yml` publishes).
- **Impact**: One click per release instead of manual file edits + branch + PR; version and branch naming can no longer be mistyped; the pre-flight gate runs in every path (direct or fallback), so nothing untested is ever published.

## 2. Usage / Reproduction

- Actions tab → **Release (one-click)** → Run workflow.
- `version` input blank = auto next-patch over npm `latest` (e.g. `1.18.2` → `1.18.3`). Type an explicit semver (`x.y.z` or `x.y.z-prerelease`) for minor/major/prerelease releases.
- Expected outcomes:
- Direct push allowed → run publishes to npm + tag + GitHub Release and stays green.
- Direct push blocked by branch protection (this repo's normal case) → run stays **green** ("prepared" state), release lives on branch `YYYY-MM-DD_release-v{V}` (+ `-HHMMSS` suffix on name collision), job summary carries the auto-opened release PR link (or a one-click `/pull/new` link if auto-PR fails). Human merges with a **standard merge commit** → `release.yml` detects the merge and publishes via CI.

## 3. Constraints & Non-Goals

- **Constraints**:
- New file only: `.github/workflows/release-manual.yml`. No product code touched.
- Pre-flight gate MUST mirror this repo's own `release.yml` gate exactly: `npm ci` + `npm run check:package` + `npm test` (no separate build step), `actions/setup-node@v4` with node 22, `cache: npm`, registry npmjs.
- No hardcoded references to another repo's package name — the npm target is read dynamically from `package.json` (`node -p "require('./package.json').name"`).
- Auto-bump starts from `npm latest` (the stable lane), matching this repo's dev/stable tag model.
- Drift guard: master's `package.json` version must equal npm `latest`, else abort loudly (prevents publishing over an unpublished manual change).
- The fallback branch name MUST match `release.yml`'s detection regex (`Merge pull request #[0-9]+ from .*[0-9]{4}-[0-9]{2}-[0-9]{2}_release-v`) — hence the generated release PR must be merged with a standard merge commit (squash/rebase drops the branch name from the commit message and `release.yml` skips publishing).
- Existing `release.yml` (including its `workflow_dispatch` force input) stays untouched; the new workflow is separately named "Release (one-click)" so both appear distinctly in the Actions dropdown.
- **Non-Goals**:
- No changes to product code, config schema, state format, or persistence.
- No changes to `pr-checks.yml` / `ci.yml` / `release.yml`.
- Not automating the final merge (human-only operation per AGENTS.md §5.1.1.2).

## 4. Acceptance Criteria

- **Correctness**:
- [x] Workflow YAML parses; every inline `run:` script passes `bash -n`.
- [x] Zero hardcoded references to another repo's package name (npm target read dynamically from `package.json`).
- [x] Version resolution: explicit semver validated by regex; blank input auto-bumps patch over `npm view <pkg> version`.
- [x] Drift guard aborts when master `package.json` != npm `latest`, and when the target version is already published.
- [x] Bump touches ONLY `package.json` + `package-lock.json`; verify step asserts the staged diff contains exactly those two files.
- [x] Pre-flight gate identical to `release.yml`'s: `npm ci` / `npm run check:package` / `npm test`.
- [x] Direct-push path: commit `release v{V}` pushed to master continues in-run to tag + `npm publish --tag {latest|dev}` + GitHub Release (prerelease when version contains `-`).
- [x] Fallback path: branch `YYYY-MM-DD_release-v{V}` (timestamp-suffixed on stale collision), best-effort auto-PR with generated changelog (`git log --no-merges LAST_TAG..HEAD~1`), run stays GREEN, PR link + release notes in job summary; one-click `/pull/new` link if auto-PR is blocked.
- [ ] First real use: drift guard passes (master currently matches npm `latest`, so it will not trip).
- **Performance / Stability**:
- [x] Single job, single concurrency group (`one-click-release`, no cancel-in-progress); no new dependencies, no runners other than `ubuntu-latest`.
- **Regression**: N/A — CI-only change; existing test suite untouched (green on the PR: test(22)/test(24)).

## 5. Proposed Approach

- **Affected files**: `.github/workflows/release-manual.yml` (NEW, ~263 lines, 13 steps) + this devlog entry.
- **Flow** (13 steps): checkout (fetch-depth 0) → setup-node (node 22, cache npm, registry npmjs) → Resolve version → Drift guard → Bump version (string-replace `"version": "<old>"` in package.json + package-lock.json) → Verify bump diff (staged diff == exactly those two files) → Pre-flight gate (`npm ci && npm run check:package && npm test`) → Commit and push to master (id `push`; sets `direct=true` or `needs_pr=true` + `pr_branch`) → Open fallback PR (best effort, `continue-on-error`; generated changelog body from last `v*` tag to `HEAD~1`, i.e. excluding the release commit itself; writes job summary in all outcomes) → Check if prerelease (`direct` only) → Create tag (`direct` only) → Publish (`direct` only, `NPM_TOKEN` secret) → Create GitHub Release (`direct` only, REST API, prerelease flag honored).
- **Why "green-on-prepared"**: in this repo branch protection blocks direct pushes, so the common outcome is "release prepared on a branch". Failing the run in that case would make the happy path look like an error; instead the run succeeds and surfaces the PR link (and a one-click `/pull/new` escape hatch) in the job summary. Nothing is published until a human merges, preserving the human-in-the-loop rule.
- **Risks**: the coupling to `release.yml`'s merge-title regex means squash/rebase merges of the generated PR silently skip publishing — documented in the PR description Note.
- **Rollback strategy**: delete the single new file; no state, persistence, or API impact.
Loading
Loading