diff --git a/.github/workflows/release-manual.yml b/.github/workflows/release-manual.yml new file mode 100644 index 00000000..ffb93ec7 --- /dev/null +++ b/.github/workflows/release-manual.yml @@ -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)", + "", + "", + ].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 "
" + echo "Release notes — paste into the PR description" + echo "" + echo '```markdown' + cat "$BODY_FILE" + echo '```' + echo "
" + } >> "$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" diff --git a/devlog/2026-09-20_one-click-release/REQ.md b/devlog/2026-09-20_one-click-release/REQ.md new file mode 100644 index 00000000..bae7bbf2 --- /dev/null +++ b/devlog/2026-09-20_one-click-release/REQ.md @@ -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 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": ""` 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. diff --git a/devlog/2026-09-20_one-click-release/WORKLOG.md b/devlog/2026-09-20_one-click-release/WORKLOG.md new file mode 100644 index 00000000..6862f657 --- /dev/null +++ b/devlog/2026-09-20_one-click-release/WORKLOG.md @@ -0,0 +1,81 @@ +# WORKLOG - One-click release workflow (workflow_dispatch) + +- Task ID: `2026-09-20_one-click-release` +- Home Repo: `opencode-acp` +- Status: InProgress +- Updated: 2026-09-20 13:15 + +## 1. Summary + +- **What was done**: Added `.github/workflows/release-manual.yml` — a `workflow_dispatch` workflow ("Release (one-click)") that automates release preparation: version resolution (auto next-patch over npm `latest`, or explicit semver), drift guard, minimal two-file bump, full pre-flight gate, then either a direct push to master (publishing in-run) or a prepared `YYYY-MM-DD_release-v{V}` branch with a best-effort auto-PR carrying a generated changelog. Ported from ranxianglei/billion-context#772 (#944 + #978 + #994 + #1017). +- **Why**: Manual release bookkeeping (editing package.json/lockfile, naming the branch correctly, writing the PR body) is error-prone and duplicated on every release; the existing `release.yml` already publishes from a correctly-named merge, so only the *preparation* needed automation. +- **Behavior / compatibility changes**: CI-only. No product code, config schema, state format, persistence, or public API changes. Existing `release.yml` untouched. +- **Risk level**: Low + +## 2. Change Log + +### Commits + +| Commit | Description | +|--------|-------------| +| `1510f154` | feat(ci): one-click release workflow (workflow_dispatch) | +| `(this commit)` | docs: add devlog entry (REQ.md + WORKLOG.md) required by AGENTS.md §5.1.2 / pr-validation | + +### Key Files + +- `.github/workflows/release-manual.yml` — NEW. Single job `release`, 13 steps, concurrency group `one-click-release` (no cancel-in-progress), permissions `contents: write` + `pull-requests: write`. +- `devlog/2026-09-20_one-click-release/REQ.md`, `WORKLOG.md` — this entry. + +## 3. Design & Implementation Notes + +- **Version resolution** (`Resolve version` step): explicit input validated against `^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$`; blank input reads the package name dynamically from `package.json` and auto-bumps the patch over `npm view version`. +- **Drift guard**: aborts if master `package.json` version != npm `latest` (master must equal the last published release before one-clicking), and if the target version is already published. +- **Bump**: string-replace `"version": ""` → `"version": ""` in exactly `package.json` + `package-lock.json`; the following step stages those two files and asserts `git diff --cached --name-only` equals exactly that pair. +- **Pre-flight gate**: `npm ci && npm run check:package && npm test` — mirrors `release.yml`'s gate exactly (verified line-by-line), with `actions/setup-node@v4` node 22 + `cache: npm` + registry npmjs like the existing workflows. +- **Landing strategy** (`Commit and push to master` step, id `push`): commits `release v{V}`; tries `git push origin HEAD:master` first. On success sets `direct=true` and the run continues to tag → publish → GitHub Release. On failure (branch protection — this repo's normal case) pushes to branch `YYYY-MM-DD_release-v{V}`, appending `-HHMMSS` when that name already exists on origin (stale same-day earlier run of the same version, ported from billion-context #1017), sets `needs_pr=true` + `pr_branch`, and the run continues green. +- **Fallback PR** (`Open fallback PR`, `continue-on-error`): builds a generated changelog body from `git log --no-merges --pretty='- %s' LAST_TAG..HEAD~1` (last `v*` tag → parent of the release commit, so the release commit itself is excluded), opens the PR via REST API with GITHUB_TOKEN, and writes the job summary in every outcome: PR URL when auto-opened, or a one-click `/pull/new/` link plus paste-ready notes when the account-level Actions restriction blocks auto-PR. +- **Direct-path publish**: prerelease detection (version contains `-` → npm tag `dev` + GitHub Release `prerelease: true`, mirroring `release.yml`); tag push is idempotent (`|| true`); publish uses the repo's `NPM_TOKEN` secret. +- **Coupling note**: the fallback branch name is chosen specifically to match `release.yml`'s Pattern-1 regex (`Merge pull request #[0-9]+ from .*[0-9]{4}-[0-9]{2}-[0-9]{2}_release-v`) — the generated release PR must be merged with a **standard merge commit**; squash/rebase drops the branch name from the merge title and `release.yml` skips publishing. Documented in the PR description Note. + +## 4. Testing & Verification + +### Build & Test Commands + +```sh +# Validate workflow YAML + inline scripts (done locally before PR) +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release-manual.yml'))" # 13 steps parse OK +bash -n # all pass +# Product suite (unchanged by this PR; runs in CI) +npm ci && npm run check:package && npm test +``` + +### Test Coverage + +- N/A for new unit tests — CI-only change, no product code touched. The existing suite runs unchanged in CI (test(22)/test(24) green on this PR). +- Key scenarios verified locally: + - YAML parses (pyyaml); every `run:` script passes `bash -n`. + - Zero hardcoded references to another repo's package name — npm target read dynamically from `package.json`. + - Pre-flight gate matches `release.yml` exactly (`npm ci` / `npm run check:package` / `npm test`; setup-node node 22 cache npm registry npmjs). + - Fallback branch-name format matches `release.yml`'s detection regex. + - master currently matches npm `latest`, so the drift guard will not trip on first use. + +### Results + +- **PASS/FAIL**: PASS — local static verification clean; CI `PR Checks` initially failed ONLY on the missing devlog entry (fixed by this commit); product test/build jobs green. + +## 5. Risk Assessment & Rollback + +- **Risk points**: + - Squash/rebase merge of the generated release PR silently skips publishing (regex coupling) — mitigated by the explicit Note in the PR description. + - If both direct push and fallback branch push fail, the run fails loudly with "nothing published, aborting" — no partial state. +- **Rollback method**: delete `.github/workflows/release-manual.yml` (single-file revert); no state/persistence/API impact. +- **Compatibility notes**: none — no data format or config schema changes. + +## 6. Lessons Learned + +- What went well: porting an already-proven workflow (billion-context has run it through #944→#1017 hardening) kept the adaptation surface small — only the gate commands and branch-name convention needed re-checking against this repo's `release.yml`. +- Reusable conclusion: "green-on-prepared" (succeed when the release is safely staged behind a human merge) keeps the common path out of the red while preserving human-in-the-loop publishing. + +## 7. Follow-ups + +- [ ] First real one-click run: confirm drift guard passes and the fallback path lands a correctly-named branch + PR end-to-end (will happen on the next actual release).