From 261e53967c8776a802e5fed905695a2d92453672 Mon Sep 17 00:00:00 2001 From: d3cker Date: Sun, 13 Sep 2026 13:30:00 +0200 Subject: [PATCH 1/4] Fix release notes and maintain versioned README downloads --- .github/workflows/release.yml | 15 +- .github/workflows/update-release-readme.yml | 34 +++++ AGENTS.md | 6 + CHANGELOG.md | 40 ++++++ README.md | 68 ++++++--- docs/installation.md | 7 + package.json | 3 +- scripts/package-check.mjs | 2 +- scripts/release-notes.mjs | 41 ++++++ scripts/update-release-readme.mjs | 107 ++++++++++++++ test/release-publication.test.mjs | 149 ++++++++++++++++++++ 11 files changed, 446 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/update-release-readme.yml create mode 100644 CHANGELOG.md create mode 100644 scripts/release-notes.mjs create mode 100644 scripts/update-release-readme.mjs create mode 100644 test/release-publication.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index faee110..e3f8c86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,10 @@ jobs: env: RELEASE_TAG: ${{ github.ref_name }} run: node scripts/release-version.mjs "$RELEASE_TAG" + - name: Prepare notes for this version + env: + RELEASE_TAG: ${{ github.ref_name }} + run: node scripts/release-notes.mjs "$RELEASE_TAG" "$RUNNER_TEMP/release/release-notes.md" - name: Lint, typecheck, test, and build run: npm run check - name: Pack and verify installation @@ -48,6 +52,7 @@ jobs: path: | ${{ runner.temp }}/release/*.tgz ${{ runner.temp }}/release/*.sha256 + ${{ runner.temp }}/release/release-notes.md if-no-files-found: error retention-days: 7 @@ -73,8 +78,16 @@ jobs: PACKAGE_FILE: ${{ needs.package.outputs.filename }} run: | sha256sum --check "$PACKAGE_FILE.sha256" - args=(--repo "$GITHUB_REPOSITORY" --verify-tag --title "$RELEASE_TAG" --generate-notes) + args=(--repo "$GITHUB_REPOSITORY" --verify-tag --title "$RELEASE_TAG" --notes-file release-notes.md) if [[ "$PRERELEASE" == "true" ]]; then args+=(--prerelease --latest=false) fi gh release create "$RELEASE_TAG" "$PACKAGE_FILE" "$PACKAGE_FILE.sha256" "${args[@]}" + + update-readme: + name: Update the stable package download + needs: [package, publish] + if: needs.package.outputs.prerelease == 'false' + permissions: + contents: write + uses: ./.github/workflows/update-release-readme.yml diff --git a/.github/workflows/update-release-readme.yml b/.github/workflows/update-release-readme.yml new file mode 100644 index 0000000..9b4ce06 --- /dev/null +++ b/.github/workflows/update-release-readme.yml @@ -0,0 +1,34 @@ +name: Update release download in README + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release-readme + cancel-in-progress: false + +jobs: + update: + name: Link the latest published stable package + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out updater + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - name: Install script dependencies + run: npm ci --ignore-scripts + - name: Update README on the default branch + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/update-release-readme.mjs "$GITHUB_REPOSITORY" diff --git a/AGENTS.md b/AGENTS.md index ab7b3af..da85da8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,12 @@ For common investigations: - **Why is polling inactive or duplicated?** Read architecture ownership, workflow section 1, and installation registration details. +For release changes, read the README's **GitHub Actions and releases** section, +`CHANGELOG.md`, and `.github/workflows/release.yml`. Every release tag must have +an exact version section in the changelog. `scripts/release-notes.mjs` extracts +that section; `scripts/update-release-readme.mjs` maintains the marked installation +block on the default branch after stable publication. Keep its markers intact. + ## From documentation to source - `src/index.ts` loads the combined plugin; `src/plugins/` contains the scheduler diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..895f4b1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +Release descriptions come from the exact version section committed with the tag. +Keep unreleased changes here, then move them into a `## MAJOR.MINOR.PATCH` section +before creating the release tag. Prerelease headings include the full version, +for example `## 0.7.0-beta.1`. + +## Unreleased + +### Fixed + +- Generate release descriptions from the matching changelog section, so publishing + a tag before its PR merges no longer produces notes about an earlier release. +- Show the latest published stable package's versioned download URL in the README + and update it automatically after successful stable releases. + +## 0.6.2 + +### Fixed + +- Keep the automation owner service active with a heartbeat during long-running + worker sessions, preserving dispatcher access for task completion and PR publication. +- Verify the owner process before sending heartbeat requests and prevent overlapping + requests with a bounded timeout. +- Attempt every cleanup step during shutdown, releasing scheduler and dispatcher + locks even when another cleanup fails, so the plugin can start again. + +### Validation and documentation + +- Add regression coverage for owner heartbeats and shutdown cleanup failures. +- Document owner lifetime, lock cleanup, and recovery behavior. + +## 0.6.1 + +### Documentation + +- Add detailed bot workflow diagrams and a documentation map in `AGENTS.md`. +- Expand bundled bot instructions for planning, delegation, verification, and + handing publication back to the dispatcher. +- Add the README banner showing an OpenCode2 agent executing a task. diff --git a/README.md b/README.md index 9ba351d..ace6a28 100644 --- a/README.md +++ b/README.md @@ -23,22 +23,28 @@ Nothing needs to be published to npm. `$HOME` expands to your home directory. ## Install from a .tgz package -1. Download/copy the archive to the machine running OpenCode 2 and install it - (replace `VERSION` with the downloaded version): +Run this command on the machine running OpenCode 2: - ```bash - npm install --global --prefix "$HOME/.local" "$HOME/Downloads/opencode2-automation-VERSION.tgz" - ``` + +Latest stable release: **[v0.6.2](https://github.com/d3cker/opencode2-github-automation/releases/tag/v0.6.2)**. - `postinstall` registers both the plugin and TUI automatically. No `sudo`, - source checkout, or manual config editing is needed. Do not add - `--ignore-scripts`; npm needs network access to install dependencies. +[Download the .tgz package](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.2/opencode2-automation-0.6.2.tgz) · [SHA-256 checksum](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.2/opencode2-automation-0.6.2.tgz.sha256) + +```bash +npm install --global --prefix "$HOME/.local" "https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.2/opencode2-automation-0.6.2.tgz" +``` + -2. Restart the service when its sessions are idle: +`postinstall` registers both the plugin and TUI automatically. No `sudo`, +source checkout, or manual config editing is needed. Do not add +`--ignore-scripts`; npm needs network access to install dependencies. +You can also download the archive and pass its local path to the same command. - ```bash - opencode2 service restart - ``` +Restart the service when its sessions are idle: + +```bash +opencode2 service restart +``` The CLI is now at `$HOME/.local/bin/opencode2-automation`. If `$HOME/.local/bin` is on your PATH, you can use the shorter `opencode2-automation` command. @@ -122,15 +128,13 @@ A reboot-only task does not handle later `opencode2 service restart` calls. ## Update from a .tgz package -Wait for active bot work to finish. Download the new archive, then: - -1. Install the new file using the **same prefix** as before: +Wait for active bot work to finish, then: - ```bash - npm install --global --prefix "$HOME/.local" /absolute/path/to/opencode2-automation-NEW_VERSION.tgz - ``` +1. Run the versioned command in [Install from a .tgz package](#install-from-a-tgz-package) + using the **same prefix** as before. The README on the default branch links to + the latest published stable package. - Replace the example path with your archive. `postinstall` refreshes registration; + `postinstall` refreshes registration; project settings and queues are preserved. Do not run `init` again. 2. Reload the service: @@ -292,13 +296,18 @@ copy it to another machine and follow the `.tgz` instructions above. - **Releases:** push a SemVer tag to build and publish a GitHub Release with the `.tgz` and SHA-256 checksum. CI verifies that the tag matches the committed version in `package.json` and `package-lock.json`. Tests and package installation must pass. + Release notes come from the exact version section in [CHANGELOG.md](CHANGELOG.md) + at that tag, independently of PR merge timing. Missing, duplicate, or empty + version sections stop the release before publication. -For a stable release, merge the PR first, then update your local `main` branch. -With a clean working tree, run (replace `0.6.0` with your next unused version): +For a stable release, prepare a changelog section such as `## 0.6.3` with the +changes for that version, merge the PR, then update your local `main` branch. +With a clean working tree, run (replace `0.6.3` with your next unused version, +matching the changelog heading): ```bash -npm version 0.6.0 -git push --atomic origin HEAD v0.6.0 +npm version 0.6.3 +git push --atomic origin HEAD v0.6.3 ``` `npm version` updates both manifests, creates a commit, and tags it automatically. @@ -306,3 +315,16 @@ The push sends the current branch and tag together. For testing, use a version such as `0.7.0-beta.1` on a feature branch; CI marks it as a prerelease. CI does not rewrite versions or publish to npm. Releases use the built-in `GITHUB_TOKEN`; no npm token or extra secret is needed. + +After successful stable publication, the **Update release download in README** +workflow reads GitHub's latest stable release and checks that its archive and +checksum are uploaded. It commits the versioned installation block to the current +default branch. Prereleases do not trigger this update. A tag's README and the +README inside an existing archive remain snapshots from their build. + +The updater needs `contents: write` and branch rules that permit its README commit. +It preserves content outside the marked block and retries conflicting edits using +the current file. If this step fails, the release remains published; resolve the +reported permission or content problem, then run **Update release download in README** +manually from Actions. The rerun selects the latest stable release again and does +nothing if the README is already current. Do not rerun publication to repair a link. diff --git a/docs/installation.md b/docs/installation.md index 3eb3116..1f4fbd7 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,6 +4,13 @@ Start with the [README](../README.md) for `.tgz` or source installation, updates project configuration, headless startup, and removal. This page covers details and troubleshooting. +The README's package installation command contains the versioned GitHub asset URL +for the latest published stable release. Its default-branch copy is updated after +successful stable publication; prereleases do not replace that link. For upgrades, +use the current default-branch README rather than a copy from an older archive or +tag, and keep the same installation prefix. Maintainer setup and retry instructions +are in [GitHub Actions and releases](../README.md#github-actions-and-releases). + ## Package registration `npm install --global` runs the bundled `postinstall` script. It writes two small diff --git a/package.json b/package.json index 0d91fc5..261874c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "files": [ "dist", "README.md", + "CHANGELOG.md", "img", "docs", "prompts", @@ -31,7 +32,7 @@ "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.test.json", "lint": "eslint src test scripts eslint.config.mjs --max-warnings 0", - "test": "node --import tsx --test test/*.test.ts", + "test": "node --import tsx --test test/*.test.ts test/*.test.mjs", "check": "npm run lint && npm run typecheck && npm test && npm run build", "package:check": "node scripts/package-check.mjs", "install-local": "bash scripts/install-local.sh" diff --git a/scripts/package-check.mjs b/scripts/package-check.mjs index a603712..8780a95 100644 --- a/scripts/package-check.mjs +++ b/scripts/package-check.mjs @@ -18,7 +18,7 @@ try { const [archive] = JSON.parse(packed.stdout); assert.equal(archive.version, pkg.version, "Packed version must match package.json"); assert.equal(archive.filename, basename(archive.filename), "Archive name must not contain a directory"); - for (const required of ["dist/index.js", "dist/tui.js", "dist/setup.js", "dist/install.js", "scripts/postinstall.mjs", "prompts/bot.md"]) { + for (const required of ["dist/index.js", "dist/tui.js", "dist/setup.js", "dist/install.js", "scripts/postinstall.mjs", "prompts/bot.md", "CHANGELOG.md"]) { assert.ok(archive.files.some(file => file.path === required), `Missing packaged file: ${required}`); } const file = join(output, archive.filename), prefix = join(temporary, "prefix"), config = join(temporary, "config"); diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 0000000..b74a038 --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,41 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { pathToFileURL } from "node:url"; +import semver from "semver"; + +export function releaseVersion(tag) { + const version = tag.startsWith("v") ? tag.slice(1) : tag; + if (!/^[0-9]/.test(version) || version.trim() !== version || !semver.valid(version)) { + throw new Error("Expected a SemVer release tag."); + } + return version; +} + +export function releaseNotes(changelog, tag) { + const version = releaseVersion(tag); + const sections = [...changelog.matchAll(/^## (.+)\r?$/gm)]; + const matches = sections.filter(section => section[1].trim() === version); + if (matches.length !== 1) { + throw new Error(`CHANGELOG.md must contain exactly one "## ${version}" section.`); + } + const section = matches[0]; + const next = sections[sections.indexOf(section) + 1]; + const notes = changelog.slice(section.index + section[0].length, next?.index).trim(); + if (!notes || !/^[-*] \S/m.test(notes)) { + throw new Error(`CHANGELOG.md section ${version} must contain release notes as bullet points.`); + } + return `${notes}\n`; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + if (process.argv.length !== 4) throw new Error("Usage: node scripts/release-notes.mjs TAG OUTPUT_FILE"); + const notes = releaseNotes(await readFile("CHANGELOG.md", "utf8"), process.argv[2]); + await mkdir(dirname(process.argv[3]), { recursive: true }); + await writeFile(process.argv[3], notes); + console.log(`Prepared release notes for ${process.argv[2]}.`); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/update-release-readme.mjs b/scripts/update-release-readme.mjs new file mode 100644 index 0000000..6394188 --- /dev/null +++ b/scripts/update-release-readme.mjs @@ -0,0 +1,107 @@ +import { pathToFileURL } from "node:url"; +import semver from "semver"; +import { releaseVersion } from "./release-notes.mjs"; + +const start = ""; +const end = ""; + +function repositoryPath(repository) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + throw new Error("Expected a GitHub repository in OWNER/REPO format."); + } + return `/repos/${repository}`; +} + +export function updateReadme(readme, release, repository) { + repositoryPath(repository); + const version = releaseVersion(release.tag_name); + if (release.draft !== false || release.prerelease !== false || semver.prerelease(version)) { + throw new Error("The README download must point to a published stable release."); + } + const base = `https://github.com/${repository}/releases`; + const assetURL = name => { + const matches = release.assets.filter(asset => asset.name === name && asset.state === "uploaded"); + const expected = `${base}/download/${encodeURIComponent(release.tag_name)}/${encodeURIComponent(name)}`; + if (matches.length !== 1 || matches[0].browser_download_url !== expected) { + throw new Error(`Latest release must contain the uploaded asset ${name} at ${expected}.`); + } + return expected; + }; + const archive = assetURL(`opencode2-automation-${version}.tgz`); + const checksum = assetURL(`opencode2-automation-${version}.tgz.sha256`); + if (readme.split(start).length !== 2 || readme.split(end).length !== 2 || readme.indexOf(end) < readme.indexOf(start)) { + throw new Error("README.md must contain exactly one ordered latest-release marker pair."); + } + const block = [ + start, + `Latest stable release: **[${release.tag_name}](${base}/tag/${encodeURIComponent(release.tag_name)})**.`, + "", + `[Download the .tgz package](${archive}) · [SHA-256 checksum](${checksum})`, + "", + "```bash", + `npm install --global --prefix "$HOME/.local" "${archive}"`, + "```", + end, + ].join("\n"); + return readme.slice(0, readme.indexOf(start)) + block + readme.slice(readme.indexOf(end) + end.length); +} + +// Read the current default-branch file and its SHA; never replace it with the tag's README. +export async function syncReadme(repository, request) { + const base = repositoryPath(repository); + for (let attempt = 0; attempt < 3; attempt++) { + const { default_branch: branch } = await request("GET", base); + if (!branch) throw new Error("GitHub did not return the repository's default branch."); + const file = await request("GET", `${base}/contents/README.md?ref=${encodeURIComponent(branch)}`); + if (file.encoding !== "base64" || !file.sha || typeof file.content !== "string") { + throw new Error("GitHub did not return README.md content and its blob SHA."); + } + // Fetch latest at execution time, including retries, so an older queued run cannot restore its own tag. + const release = await request("GET", `${base}/releases/latest`); + const previous = Buffer.from(file.content, "base64").toString("utf8"); + const updated = updateReadme(previous, release, repository); + if (updated === previous) return `README already points to ${release.tag_name}.`; + try { + await request("PUT", `${base}/contents/README.md`, { + branch, + sha: file.sha, + message: `docs: update README download to ${release.tag_name}`, + content: Buffer.from(updated).toString("base64"), + }); + return `Updated README on ${branch} to ${release.tag_name}.`; + } catch (error) { + if (error.status !== 409 || attempt === 2) throw error; + } + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const token = process.env.GH_TOKEN; + if (!token) throw new Error("GH_TOKEN is required to update the README."); + if (process.argv.length !== 3) throw new Error("Usage: node scripts/update-release-readme.mjs OWNER/REPO"); + const request = async (method, path, body) => { + const response = await fetch(`https://api.github.com${path}`, { + method, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + const error = new Error(`GitHub ${method} ${path} failed (HTTP ${response.status}). Check Actions contents:write permission and default-branch rules before retrying.`); + error.status = response.status; + throw error; + } + return response.json(); + }; + console.log(await syncReadme(process.argv[2], request)); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/test/release-publication.test.mjs b/test/release-publication.test.mjs new file mode 100644 index 0000000..6f0831a --- /dev/null +++ b/test/release-publication.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { releaseNotes } from "../scripts/release-notes.mjs"; +import { syncReadme, updateReadme } from "../scripts/update-release-readme.mjs"; + +const repository = "example/automation"; +const readme = "# Product\n\n\nold install\n\n\nOther instructions.\n"; +function release(version = "0.6.2", tag = `v${version}`) { + return { + tag_name: tag, + draft: false, + prerelease: false, + assets: ["tgz", "tgz.sha256"].map(extension => ({ + name: `opencode2-automation-${version}.${extension}`, + state: "uploaded", + browser_download_url: `https://github.com/${repository}/releases/download/${encodeURIComponent(tag)}/opencode2-automation-${encodeURIComponent(version)}.${extension}`, + })), + }; +} +function file(content, sha = "original-sha") { + return { encoding: "base64", sha, content: Buffer.from(content).toString("base64") }; +} + +test("release notes select only the exact tagged version regardless of surrounding releases", () => { + const changelog = "# Changelog\n\n## Unreleased\n- Future\n\n## 0.6.2\n### Fixed\n- Keep owner active\n\n## 0.6.1\n- Banner\n"; + assert.equal(releaseNotes(changelog, "v0.6.2"), "### Fixed\n- Keep owner active\n"); + assert.equal(releaseNotes(changelog.replaceAll("\n", "\r\n"), "0.6.1"), "- Banner\n"); + assert.equal(releaseNotes("## 0.7.0-beta.1\n- Preview\n", "v0.7.0-beta.1"), "- Preview\n"); +}); + +test("missing, empty, duplicate, and invalid release sections fail instead of guessing notes", () => { + for (const changelog of ["## Unreleased\n- Pending", "## 0.6.2\n\n## 0.6.1\n- Old", "## 0.6.2\n- One\n## 0.6.2\n- Two", "## 0.6.2\n### Fixed"]) { + assert.throws(() => releaseNotes(changelog, "v0.6.2"), /CHANGELOG.md/); + } + for (const tag of ["v0.6", "v0.6.2\n", "Unreleased"]) { + assert.throws(() => releaseNotes("## 0.6.2\n- Fix", tag), /SemVer/); + } +}); + +test("the recorded 0.6.2 notes describe lifecycle fixes rather than the previous release", async () => { + const notes = releaseNotes(await readFile(new URL("../CHANGELOG.md", import.meta.url), "utf8"), "v0.6.2"); + assert.match(notes, /heartbeat/); + assert.match(notes, /cleanup/); + assert.doesNotMatch(notes, /0\.6\.1|banner/i); +}); + +test("README links uploaded versioned assets and preserves all surrounding content", () => { + const updated = updateReadme(readme, release(), repository); + assert.match(updated, /npm install --global --prefix "\$HOME\/\.local" "https:\/\/github\.com\/example\/automation\/releases\/download\/v0\.6\.2\/opencode2-automation-0\.6\.2\.tgz"/); + assert.match(updated, /opencode2-automation-0\.6\.2\.tgz\.sha256/); + assert.ok(updated.startsWith("# Product\n\n")); + assert.ok(updated.endsWith("\n\nOther instructions.\n")); + assert.equal(updateReadme(updated, release(), repository), updated); + assert.match(updateReadme(readme, release("0.6.3", "0.6.3"), repository), /download\/0\.6\.3\//); +}); + +test("README refuses drafts, prereleases, absent assets, and untrusted download URLs", () => { + const invalid = [ + { ...release(), draft: true }, + { ...release(), prerelease: true }, + release("0.7.0-beta.1"), + { ...release(), assets: [] }, + { ...release(), assets: release().assets.slice(0, 1) }, + ]; + for (const change of [ + { state: "starter" }, + { browser_download_url: "https://example.com/package.tgz" }, + { browser_download_url: release().assets[0].browser_download_url + '?x=$(env)' }, + ]) { + const candidate = release(); + Object.assign(candidate.assets[0], change); + invalid.push(candidate); + } + for (const candidate of invalid) assert.throws(() => updateReadme(readme, candidate, repository)); +}); + +test("README refuses missing, duplicate, or reversed markers", () => { + for (const content of ["# README", readme + readme, ""]) { + assert.throws(() => updateReadme(content, release(), repository), /marker/); + } +}); + +test("a delayed updater queries latest and writes only the default-branch README with its SHA", async () => { + const calls = []; + const request = async (method, path, body) => { + calls.push({ method, path, body }); + if (path === `/repos/${repository}`) return { default_branch: "main" }; + if (path.endsWith("?ref=main")) return file(readme); + if (path.endsWith("/latest")) return release("0.6.4"); + assert.equal(method, "PUT"); + return {}; + }; + assert.equal(await syncReadme(repository, request), "Updated README on main to v0.6.4."); + const put = calls.at(-1); + assert.equal(put.path, `/repos/${repository}/contents/README.md`); + assert.equal(put.body.sha, "original-sha"); + assert.equal(put.body.branch, "main"); + assert.equal(Buffer.from(put.body.content, "base64").toString(), updateReadme(readme, release("0.6.4"), repository)); +}); + +test("an up-to-date README produces no commit", async () => { + const updated = updateReadme(readme, release(), repository); + const request = async (method, path) => { + assert.equal(method, "GET"); + if (path.endsWith("/latest")) return release(); + if (path.includes("/contents/")) return file(updated); + return { default_branch: "master" }; + }; + assert.match(await syncReadme(repository, request), /already points to v0.6.2/); +}); + +test("a conflicting edit retries with fresh content, SHA, and latest release", async () => { + let writes = 0; + const concurrent = readme.replace("Other instructions.", "Someone else's new instructions."); + const request = async (method, path, body) => { + if (method === "PUT") { + writes++; + if (writes === 1) throw Object.assign(new Error("Conflict"), { status: 409 }); + assert.equal(body.sha, "new-sha"); + const content = Buffer.from(body.content, "base64").toString(); + assert.match(content, /Someone else's new instructions/); + assert.match(content, /v0\.6\.3/); + return {}; + } + if (path.endsWith("/latest")) return release(writes ? "0.6.3" : "0.6.2"); + if (path.includes("/contents/")) return file(writes ? concurrent : readme, writes ? "new-sha" : "original-sha"); + return { default_branch: "main" }; + }; + assert.match(await syncReadme(repository, request), /v0.6.3/); + assert.equal(writes, 2); +}); + +test("write failures are surfaced and conflict retries are bounded", async () => { + for (const status of [403, 409, 422]) { + let writes = 0; + const request = async (method, path) => { + if (method === "PUT") { + writes++; + throw Object.assign(new Error("Write failed"), { status }); + } + if (path.endsWith("/latest")) return release(); + if (path.includes("/contents/")) return file(readme); + return { default_branch: "main" }; + }; + await assert.rejects(syncReadme(repository, request), /Write failed/); + assert.equal(writes, status === 409 ? 3 : 1); + } +}); From 9f75ca507cc11cfb8148a1f5fe9ae2e09bc07166 Mon Sep 17 00:00:00 2001 From: d3cker Date: Sun, 13 Sep 2026 14:29:45 +0200 Subject: [PATCH 2/4] Publish through release branch and promote to protected main by PR --- .github/workflows/ci.yml | 7 +- .github/workflows/promotion.yml | 37 +++ .github/workflows/release.yml | 90 ++----- .github/workflows/update-release-readme.yml | 34 --- AGENTS.md | 15 +- CHANGELOG.md | 20 +- README.md | 71 ++--- docs/installation.md | 11 +- docs/releases.md | 139 ++++++++++ scripts/release-pipeline.mjs | 254 ++++++++++++++++++ scripts/release-version.mjs | 2 +- scripts/update-release-readme.mjs | 61 ----- test/release-pipeline.test.mjs | 273 ++++++++++++++++++++ test/release-publication.test.mjs | 73 +----- 14 files changed, 801 insertions(+), 286 deletions(-) create mode 100644 .github/workflows/promotion.yml delete mode 100644 .github/workflows/update-release-readme.yml create mode 100644 docs/releases.md create mode 100644 scripts/release-pipeline.mjs create mode 100644 test/release-pipeline.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6140b88..c6cd85d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,8 @@ name: CI on: pull_request: - push: - branches: [main, master] - workflow_dispatch: + branches: [release] + types: [opened, synchronize, reopened, ready_for_review, edited] permissions: contents: read @@ -34,6 +33,8 @@ jobs: cache: npm - name: Install dependencies run: npm ci + - name: Verify release notes and manifests + run: node scripts/release-pipeline.mjs verify-feature - name: Lint, typecheck, test, and build run: npm run check - name: Verify package installation diff --git a/.github/workflows/promotion.yml b/.github/workflows/promotion.yml new file mode 100644 index 0000000..f0cce5c --- /dev/null +++ b/.github/workflows/promotion.yml @@ -0,0 +1,37 @@ +name: Release promotion + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, ready_for_review, edited] + +permissions: + contents: read + +concurrency: + group: promotion-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + ready: + name: Release ready + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out the proposed release head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - name: Install validation dependencies + run: npm ci --ignore-scripts + - name: Verify published version and README without building a package + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/release-pipeline.mjs verify-promotion diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3f8c86..4132850 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,93 +1,51 @@ name: Release on: + pull_request: + branches: [release] + types: [closed] push: tags: ['v[0-9]*', '[0-9]*'] permissions: contents: read +# Automatic and manual releases share one publisher. Never cancel an active publication. concurrency: - group: release-${{ github.ref }} + group: release-publication cancel-in-progress: false jobs: - package: - name: Build and verify release package + release: + name: Publish from release and open promotion PR + if: github.event_name == 'push' || github.event.pull_request.merged == true runs-on: ubuntu-latest - timeout-minutes: 15 - outputs: - prerelease: ${{ steps.version.outputs.prerelease }} - filename: ${{ steps.package.outputs.filename }} + timeout-minutes: 25 + permissions: + contents: write + pull-requests: write steps: - - name: Check out the tag + - name: Check out release uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - persist-credentials: false + ref: release + fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm - - name: Install dependencies - run: npm ci - - name: Verify package version matches tag - id: version + - name: Install pipeline dependencies + run: npm ci --ignore-scripts + - name: Prepare version, publish package, and promote through a PR env: - RELEASE_TAG: ${{ github.ref_name }} - run: node scripts/release-version.mjs "$RELEASE_TAG" - - name: Prepare notes for this version - env: - RELEASE_TAG: ${{ github.ref_name }} - run: node scripts/release-notes.mjs "$RELEASE_TAG" "$RUNNER_TEMP/release/release-notes.md" - - name: Lint, typecheck, test, and build - run: npm run check - - name: Pack and verify installation - id: package - run: npm run package:check -- "$RUNNER_TEMP/release" - - name: Upload verified release files + GH_TOKEN: ${{ github.token }} + run: node scripts/release-pipeline.mjs + - name: Preserve built release files for diagnostics + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-package - path: | - ${{ runner.temp }}/release/*.tgz - ${{ runner.temp }}/release/*.sha256 - ${{ runner.temp }}/release/release-notes.md - if-no-files-found: error + path: ${{ runner.temp }}/release/* + if-no-files-found: ignore retention-days: 7 - - publish: - name: Publish GitHub Release - needs: package - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - steps: - - name: Download verified release files - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: release-package - path: release - - name: Create release with package and checksum - working-directory: release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.ref_name }} - PRERELEASE: ${{ needs.package.outputs.prerelease }} - PACKAGE_FILE: ${{ needs.package.outputs.filename }} - run: | - sha256sum --check "$PACKAGE_FILE.sha256" - args=(--repo "$GITHUB_REPOSITORY" --verify-tag --title "$RELEASE_TAG" --notes-file release-notes.md) - if [[ "$PRERELEASE" == "true" ]]; then - args+=(--prerelease --latest=false) - fi - gh release create "$RELEASE_TAG" "$PACKAGE_FILE" "$PACKAGE_FILE.sha256" "${args[@]}" - - update-readme: - name: Update the stable package download - needs: [package, publish] - if: needs.package.outputs.prerelease == 'false' - permissions: - contents: write - uses: ./.github/workflows/update-release-readme.yml diff --git a/.github/workflows/update-release-readme.yml b/.github/workflows/update-release-readme.yml deleted file mode 100644 index 9b4ce06..0000000 --- a/.github/workflows/update-release-readme.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Update release download in README - -on: - workflow_call: - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: release-readme - cancel-in-progress: false - -jobs: - update: - name: Link the latest published stable package - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Check out updater - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22 - cache: npm - - name: Install script dependencies - run: npm ci --ignore-scripts - - name: Update README on the default branch - env: - GH_TOKEN: ${{ github.token }} - run: node scripts/update-release-readme.mjs "$GITHUB_REPOSITORY" diff --git a/AGENTS.md b/AGENTS.md index da85da8..f6916d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,10 @@ Use English for all user-facing prompts, UI labels, errors, generated bot messages, examples, and documentation. Do not hard-code a personal account in defaults. +Never push commits directly to `main` or modify its files through GitHub APIs. +All changes reach `main` by merging a PR from `release`. Feature PRs target +`release`; version and post-publication README commits belong on `release`. + ## Project context This repository implements issue-to-PR automation for OpenCode **2**: a scheduler, @@ -24,6 +28,7 @@ steps, project setup, headless operation, and removal. | [docs/runtime.md](docs/runtime.md) | User-visible behavior while the bot runs: GitHub questions and permission replies, branch selection, media inputs, prompt loading, follow-up comments, session tabs, and routine management commands. | Use when changing issue conversations, session continuation, runtime tools, or TUI behavior. | | [docs/advanced.md](docs/advanced.md) | Separate scheduler/dispatcher setup, multiple repositories, custom RPC jobs, full options, timeouts, management and retry commands, persistence, reconciliation, locks, and known limits. | Use for low-level configuration, operational troubleshooting, recovery, or ownership/concurrency changes. | | [docs/installation.md](docs/installation.md) | Loader registration, config-directory precedence, prerequisites, source installation, project-local installation, upgrade conflicts, testing on another machine, and migration limits. | Use when working on packaging, installers, registration, upgrades, or deployment troubleshooting. | +| [docs/releases.md](docs/releases.md) | Feature-to-release PR checks, automatic patch versions, manual npm version/tag releases, exact changelog notes, publication recovery, README commits on release, and promotion PRs into protected main. | Use for CI triggers, versioning, packaging, GitHub Release publication, branch permissions, or recovery after a failed release. | For common investigations: @@ -38,11 +43,11 @@ For common investigations: - **Why is polling inactive or duplicated?** Read architecture ownership, workflow section 1, and installation registration details. -For release changes, read the README's **GitHub Actions and releases** section, -`CHANGELOG.md`, and `.github/workflows/release.yml`. Every release tag must have -an exact version section in the changelog. `scripts/release-notes.mjs` extracts -that section; `scripts/update-release-readme.mjs` maintains the marked installation -block on the default branch after stable publication. Keep its markers intact. +For release changes, read `docs/releases.md`, `CHANGELOG.md`, and the workflows +under `.github/workflows/`. `scripts/release-pipeline.mjs` handles automatic and +manual releases, retry state, and promotion. `scripts/release-notes.mjs` extracts +the exact tagged changelog section; `scripts/update-release-readme.mjs` renders +the installation block without making remote writes. Keep its markers intact. ## From documentation to source diff --git a/CHANGELOG.md b/CHANGELOG.md index 895f4b1..76827f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ # Changelog Release descriptions come from the exact version section committed with the tag. -Keep unreleased changes here, then move them into a `## MAJOR.MINOR.PATCH` section -before creating the release tag. Prerelease headings include the full version, -for example `## 0.7.0-beta.1`. +Add feature changes under `Unreleased`; after a PR merges into `release`, automation +moves them into the new patch version's section. For a manual release, prepare +and commit the exact version section before creating its tag. Prerelease headings +include the full version, for example `## 0.7.0-beta.1`. ## Unreleased @@ -12,7 +13,18 @@ for example `## 0.7.0-beta.1`. - Generate release descriptions from the matching changelog section, so publishing a tag before its PR merges no longer produces notes about an earlier release. - Show the latest published stable package's versioned download URL in the README - and update it automatically after successful stable releases. + on `release` after successful publication, then promote it to `main` through a PR. + +### Release process + +- Run full CI when feature PRs target `release`, including new commits to open PRs. + Ordinary feature pushes no longer run CI or build packages. +- Publish an automatic patch after a PR merges into `release`, and support manual + version tags on that branch without a second version bump. +- Recover interrupted publication without moving tags or republishing completed + packages. Commit README on `release` before opening or updating its PR to `main`. +- Keep `main` changes behind PR merges and verify that promotion contains the + published package's code and updated download links. ## 0.6.2 diff --git a/README.md b/README.md index ace6a28..ce4a08a 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,8 @@ A reboot-only task does not handle later `opencode2 service restart` calls. Wait for active bot work to finish, then: 1. Run the versioned command in [Install from a .tgz package](#install-from-a-tgz-package) - using the **same prefix** as before. The README on the default branch links to - the latest published stable package. + using the **same prefix** as before. After publication, the README on `release` + links to the new stable package; `main` receives that link through the promotion PR. `postinstall` refreshes registration; project settings and queues are preserved. Do not run `init` again. @@ -291,40 +291,41 @@ copy it to another machine and follow the `.tgz` instructions above. ## GitHub Actions and releases -- **Pull requests:** CI runs ESLint, type checking, unit tests, a build, and a - package installation check on Node 22 and 24. Pushes to `main`/`master` also run CI. -- **Releases:** push a SemVer tag to build and publish a GitHub Release with the - `.tgz` and SHA-256 checksum. CI verifies that the tag matches the committed - version in `package.json` and `package-lock.json`. Tests and package installation must pass. - Release notes come from the exact version section in [CHANGELOG.md](CHANGELOG.md) - at that tag, independently of PR merge timing. Missing, duplicate, or empty - version sections stop the release before publication. - -For a stable release, prepare a changelog section such as `## 0.6.3` with the -changes for that version, merge the PR, then update your local `main` branch. -With a clean working tree, run (replace `0.6.3` with your next unused version, -matching the changelog heading): +1. Work on a feature branch and add release notes under `Unreleased` in + [CHANGELOG.md](CHANGELOG.md). Ordinary branch pushes do not run CI or publish packages. +2. Open a PR into the long-lived `release` branch. CI runs lint, type checking, + tests, a build, and an installation check on Node 22 and 24. New commits to + the open PR rerun these checks. Review and merge after they pass. +3. The merge starts **Release**. It increments the patch version on `release`, + moves the unreleased notes into that version's changelog section, and pushes + the version commit and tag atomically. It builds and verifies the tagged + package, then publishes the GitHub Release with `.tgz`, SHA-256, and exact + version notes. No package is published to npm. +4. Only after publication succeeds, automation commits the versioned README link + on `release` and opens or updates a PR from `release` into `main`. +5. Review and merge that PR with a **merge commit**. All code, version metadata, + release notes, and README changes reach protected `main` through this PR. + The automation never pushes to `main` or writes its files through the API. + +To choose a version manually, prepare and commit its exact changelog section on +`release`, then use `npm version`, for example: ```bash -npm version 0.6.3 -git push --atomic origin HEAD v0.6.3 +git switch release +git pull --ff-only origin release +# Prepare and commit the CHANGELOG.md section for 1.0.0 first. +npm version 1.0.0 +git push --atomic origin release v1.0.0 ``` -`npm version` updates both manifests, creates a commit, and tags it automatically. -The push sends the current branch and tag together. For testing, use a version -such as `0.7.0-beta.1` on a feature branch; CI marks it as a prerelease. -CI does not rewrite versions or publish to npm. Releases use the built-in -`GITHUB_TOKEN`; no npm token or extra secret is needed. - -After successful stable publication, the **Update release download in README** -workflow reads GitHub's latest stable release and checks that its archive and -checksum are uploaded. It commits the versioned installation block to the current -default branch. Prereleases do not trigger this update. A tag's README and the -README inside an existing archive remain snapshots from their build. - -The updater needs `contents: write` and branch rules that permit its README commit. -It preserves content outside the marked block and retries conflicting edits using -the current file. If this step fails, the release remains published; resolve the -reported permission or content problem, then run **Update release download in README** -manually from Actions. The rerun selects the latest stable release again and does -nothing if the README is already current. Do not rerun publication to repair a link. +The pushed tag publishes exactly `1.0.0`, without another version bump. Both +`v1.0.0` and `1.0.0` tag names are accepted. The next automatic patch is `1.0.1`. +Version tags must point to code on `release`; ordinary pushes to that branch +never start publication. Finish the active release before merging another feature. + +The README on `release` is updated after publication; the README on `main` changes +when the promotion PR is merged. The tag and packaged README remain snapshots +from before the later README commit. + +See [Release process](docs/releases.md) for required repository permissions, +branch protection, CI approval for bot PRs, concurrency, and safe retry procedures. diff --git a/docs/installation.md b/docs/installation.md index 1f4fbd7..66c6c2a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,11 +5,12 @@ project configuration, headless startup, and removal. This page covers details and troubleshooting. The README's package installation command contains the versioned GitHub asset URL -for the latest published stable release. Its default-branch copy is updated after -successful stable publication; prereleases do not replace that link. For upgrades, -use the current default-branch README rather than a copy from an older archive or -tag, and keep the same installation prefix. Maintainer setup and retry instructions -are in [GitHub Actions and releases](../README.md#github-actions-and-releases). +for the stable release promoted into that branch. After publication, automation +updates README on `release`; its PR carries the update into `main` when merged. +While that PR awaits review, `release` contains the newer download link. For +upgrades, use that branch's current README rather than a copy from an old archive +or tag, and keep the same installation prefix. Prereleases do not replace the +stable link. Maintainer setup and retries are in [Release process](releases.md). ## Package registration diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..0406733 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,139 @@ +# Release process + +`release` is the persistent integration and publication branch. `main` receives +completed releases through PR merges only. Never push a commit directly to `main`, +modify its files with the Contents API, or bypass its protection rules. + +## Events and responsibilities + +| Event | Result | +| --- | --- | +| Commit/push on a feature branch without an open PR | No CI run and no package build. | +| Open, reopen, or update a PR targeting `release` | Full CI on Node 22 and 24, including build and isolated package installation. | +| Merge that PR into `release` | Automatic patch version, tag, package publication, README commit on `release`, and promotion PR. | +| Close that PR without merging | No publication. | +| Push a version tag pointing to code on `release` | Publish that exact version, without an automatic bump. | +| Push a version/README commit without a tag | No publication; automation cannot trigger itself in a loop. | +| Open/update the PR from `release` into `main` | `Release ready` validates publication and README without rebuilding the package. | +| Merge the promotion PR into `main` | Update `main` only; no new release or package build. | + +## Automatic patch release + +1. Create the feature branch from current `release`. Add accurate bullet points + under `## Unreleased` in `CHANGELOG.md`; do not pre-bump the package version. +2. Open a PR into `release`. Its current revision must pass `Checks (Node 22)` + and `Checks (Node 24)` before the maintainer accepts and merges it. CI also + verifies that manifests agree and `Unreleased` has notes for the next patch. +3. The merged-PR workflow increments the current package's patch version. It + updates both manifests using npm, moves `Unreleased` into the exact new version + section, and records the source PR, merge SHA, and version in + `.github/release-state.json` for retries. +4. The workflow commits these files on `release`, creates an annotated `vVERSION` + tag, and pushes the branch and tag atomically. An existing tag is never moved. +5. In the same workflow run, it checks out the tag, builds the package, verifies + its installation and checksum, and creates a draft GitHub Release. It uploads + both assets before publishing the draft with the exact changelog notes. + Publication does not depend on a second workflow being triggered by the bot's tag. +6. After GitHub confirms publication and uploaded assets, it returns to `release`, + commits the versioned README block there, and opens or updates the single + `release` → `main` PR. Publication failure never advances README or creates a PR. +7. Approve the promotion's checks and review, then use **Create a merge commit**. + Preserve the long-lived `release` branch; do not delete it after merging. + +## Manual version release + +Use this path for a specific version such as `1.0.0`, including minor/major bumps. +Finish any running publication first, then update local `release`: + +```bash +git switch release +git pull --ff-only origin release +``` + +Move the relevant unreleased notes into an exact `## 1.0.0` section and commit them. +Then create and push the version commit and tag: + +```bash +git add CHANGELOG.md +git commit -m "Document release 1.0.0" +npm version 1.0.0 +git push --atomic origin release v1.0.0 +``` + +The tag workflow publishes `1.0.0` without bumping it again. Unprefixed tags such +as `1.0.0` are also accepted if created manually. The tag version must exactly +match `package.json` and both root versions in `package-lock.json`. A new release +must be newer than GitHub's latest stable release. The next automatic patch after +manual `1.0.0` is `1.0.1`. + +A manual prerelease such as `1.1.0-beta.1` is published as a prerelease; it does +not replace the stable README link or open a promotion PR. It must also originate +on `release` and have its own exact changelog section. + +## README and protected main + +The version tag identifies the package source. The later README commit is on +`release` and enters `main` with the same promotion PR as all released code. +Consequently, the README inside the tag/archive remains a build-time snapshot. +While the PR awaits review, `main` can still show the previous stable download; +the README on `release` contains the newly published one. + +The `Release ready` check requires a same-repository `release` → `main` PR. It +verifies that the PR version is published with the expected assets, that its code +matches the tag (only README may differ), and that README contains the new links. +This also prevents a pending promotion from silently accepting new, unpublished +feature commits added to `release`. It does not build another package or check +whether a tag belongs to `main`. + +## Repository setup + +- Create `release` once from the current `main`, then target feature PRs there. + Bootstrap this workflow through the first feature PR into `release`. +- Protect `main`: require a PR, review of the current revision, and the + `Release ready` status check. Apply protection to administrators as well; + disable force pushes and deletion. Automation needs no bypass permission. +- The publisher needs `contents: write` for commits/tags on `release` and release + assets, and `pull-requests: write` to create/update its promotion PR. If `release` + has additional protection, it must permit the publisher's version and README + commits. Feature changes still enter through reviewed, passing PRs. +- In **Settings → Actions → General → Workflow permissions**, enable + **Allow GitHub Actions to create and approve pull requests**. The workflow only + creates/updates PRs; it never approves or merges them. Keep default token + permissions read-only; the publisher grants only its required permissions. +- GitHub may require **Approve workflows to run** on a PR created/updated with + `GITHUB_TOKEN`. A maintainer approves those runs before review/merge. Do not + disable the required promotion check to avoid that approval. +- Keep merge commits enabled and preserve `release` after promotion. Avoid squash + or rebase merging the long-lived release branch into `main`. + +## Concurrency and recovery + +Merge one feature PR at a time and wait for publication/README/PR preparation to +finish before the next merge or manual version bump. Automatic and manual runs +share a concurrency group and never cancel a running publication. GitHub retains +only one pending run per group; several overlapping triggers can replace pending +runs. Do not use the concurrency queue as a release backlog. + +The script requires the merged PR to still be the release tip when starting a new +patch. On retry it recognizes the committed PR/version record. Manual tags and +resumed runs must still match the code on `release`, allowing only a subsequent +README change. A concurrent branch change stops the run rather than overwriting +commits, including untagged code in a package, or moving an existing tag. + +Use **Re-run all jobs** on the original failed Release run: + +- Before the version/tag push: no remote version was created; the retry prepares it. +- After the atomic push: reuse the recorded version/tag; never bump a second time. +- During packaging/upload: rebuild from the same tag and repair assets only while + the GitHub Release is still a draft. +- After publication: verify and reuse the published assets without overwriting or + rebuilding them, then finish README and PR preparation. +- After the README commit or a lost PR response: reuse that commit and discover the + existing open promotion PR before creating another one. + +If a newer feature merge or manual version has already advanced `release`, an old +run may refuse to resume. Inspect the current branch and latest release before +continuing; do not reset `release` or force-move tags to make an old run succeed. +Any unresolved changes remain in Git. Draft releases are not complete publications. +If abandoning a failed, tagged version in favor of a later one, include its still +unpublished changes in the later version's changelog notes as part of the repair PR. diff --git a/scripts/release-pipeline.mjs b/scripts/release-pipeline.mjs new file mode 100644 index 0000000..3fec311 --- /dev/null +++ b/scripts/release-pipeline.mjs @@ -0,0 +1,254 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import semver from "semver"; +import { releaseNotes, releaseVersion } from "./release-notes.mjs"; +import { updateReadme } from "./update-release-readme.mjs"; + +const execute = promisify(execFile); +const statePath = ".github/release-state.json"; +const releaseRef = "refs/remotes/origin/release"; +const identity = ["-c", "user.name=github-actions[bot]", "-c", "user.email=41898282+github-actions[bot]@users.noreply.github.com"]; +const gitAt = cwd => async (...args) => (await execute("git", args, { cwd })).stdout.trim(); + +export function releaseRequest(event, repository) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new Error("Expected a GitHub OWNER/REPO."); + if (event.repository?.full_name !== repository) throw new Error("Release event belongs to another repository."); + if (event.action === "closed" && event.pull_request?.merged === true && event.pull_request.base?.ref === "release") { + const pr = event.pull_request; + if (!Number.isSafeInteger(pr.number) || pr.number <= 0 || !/^[a-f0-9]{40}$/.test(pr.merge_commit_sha)) { + throw new Error("Merged PR must provide its number and merge commit."); + } + return { pullRequest: pr.number, sourceSHA: pr.merge_commit_sha }; + } + if (event.ref?.startsWith("refs/tags/") && !event.deleted) { + const tag = event.ref.slice("refs/tags/".length); + releaseVersion(tag); + if (!/^[a-f0-9]{40}$/.test(event.after)) throw new Error("Tag event must provide its object SHA."); + return { tag, tagObject: event.after }; + } + throw new Error("Release requires a merged PR into release or a pushed version tag."); +} + +export function prepareChangelog(changelog, version) { + if ([...changelog.matchAll(/^## Unreleased\r?$/gm)].length !== 1) throw new Error("CHANGELOG.md needs exactly one Unreleased section."); + if ([...changelog.matchAll(/^## (.+)\r?$/gm)].some(match => match[1].trim() === version)) { + throw new Error(`CHANGELOG.md already contains ${version}.`); + } + const updated = changelog.replace(/^## Unreleased\r?$/m, `## Unreleased\n\n## ${version}`); + releaseNotes(updated, version); + return updated; +} + +async function manifest(git, ref) { + const pkg = JSON.parse(await git("show", `${ref}:package.json`)); + const lock = JSON.parse(await git("show", `${ref}:package-lock.json`)); + if (pkg.name !== "opencode2-automation" || lock.name !== pkg.name || lock.packages?.[""]?.name !== pkg.name || + pkg.version !== lock.version || pkg.version !== lock.packages[""].version) { + throw new Error("Package and lockfile names and root versions must match."); + } + releaseVersion(pkg.version); + return pkg; +} + +async function clean(git) { + if (await git("status", "--porcelain")) throw new Error("Release automation requires a clean checkout."); +} + +async function fetchRelease(git) { + await git("fetch", "--tags", "origin", "refs/heads/release:refs/remotes/origin/release"); + return git("rev-parse", releaseRef); +} + +// main is never a Git write target. Post-tag changes may only update README. +export async function assertPublishedTree(git, tag, head) { + await git("merge-base", "--is-ancestor", `refs/tags/${tag}`, head); + if (await git("diff", "--name-only", `refs/tags/${tag}`, head, "--", ".", ":(exclude)README.md")) { + throw new Error("release contains changes beyond the tagged package. Finish one release before merging the next PR."); + } +} + +async function prepareAutomatic(request, git, cwd) { + const head = await fetchRelease(git); + if (head !== request.sourceSHA) { + // A retry resumes the version already committed by this merged PR. + let saved; + try { saved = JSON.parse(await git("show", `${releaseRef}:${statePath}`)); } catch {} + if (saved?.pullRequest !== request.pullRequest || saved.sourceSHA !== request.sourceSHA) { + throw new Error("release advanced past this PR. Finish/retry the current release before merging another PR."); + } + const tag = `v${releaseVersion(saved.version)}`; + if (await git("rev-parse", `refs/tags/${tag}^`) !== request.sourceSHA) throw new Error("Saved release tag does not belong to this PR."); + const taggedState = JSON.parse(await git("show", `refs/tags/${tag}:${statePath}`)); + if (JSON.stringify(taggedState) !== JSON.stringify(saved)) throw new Error("Saved release state differs from the tag."); + await assertPublishedTree(git, tag, head); + return tag; + } + const pkg = await manifest(git, releaseRef); + const version = semver.inc(pkg.version, "patch"); + const tag = `v${version}`; + for (const candidate of [tag, version]) { + if (await git("tag", "--list", candidate)) throw new Error(`Version tag ${candidate} already exists.`); + } + const changelog = prepareChangelog(await git("show", `${releaseRef}:CHANGELOG.md`), version); + await git("switch", "-C", "release", releaseRef); + await execute("npm", ["version", version, "--no-git-tag-version", "--ignore-scripts"], { cwd }); + await writeFile(join(cwd, "CHANGELOG.md"), changelog); + await mkdir(join(cwd, ".github"), { recursive: true }); + await writeFile(join(cwd, statePath), JSON.stringify({ ...request, version }, null, 2) + "\n"); + await git("add", "--", "package.json", "package-lock.json", "CHANGELOG.md", statePath); + await git(...identity, "commit", "-m", `Release ${tag} from PR #${request.pullRequest}`); + await git(...identity, "tag", "-a", tag, "-m", `Release ${tag} from PR #${request.pullRequest}`); + // Both refs land together, or neither does. Never force-push a branch or move a tag. + await git("push", "--atomic", "origin", "HEAD:refs/heads/release", `refs/tags/${tag}`); + return tag; +} + +export async function buildPackage({ cwd, directory, version }) { + await execute("npm", ["ci", "--ignore-scripts"], { cwd, timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }); + await execute("npm", ["run", "build"], { cwd, timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }); + await execute("npm", ["run", "package:check", "--", directory], { cwd, timeout: 180_000, maxBuffer: 8 * 1024 * 1024 }); + const archive = join(directory, `opencode2-automation-${version}.tgz`); + const checksum = `${archive}.sha256`; + const digest = createHash("sha256").update(await readFile(archive)).digest("hex"); + if ((await readFile(checksum, "utf8")).trim() !== `${digest} opencode2-automation-${version}.tgz`) { + throw new Error("Built package checksum does not match."); + } + return { archive, checksum }; +} + +export async function runRelease({ cwd, repository, event, github, directory, build = buildPackage }) { + const request = releaseRequest(event, repository); + const git = gitAt(cwd); + await clean(git); + const tag = request.tag ?? await prepareAutomatic(request, git, cwd); + const head = await fetchRelease(git); + if (request.tagObject && await git("rev-parse", `${request.tagObject}^{commit}`) !== await git("rev-parse", `refs/tags/${tag}^{commit}`)) { + throw new Error("The pushed tag has moved since this workflow started."); + } + await assertPublishedTree(git, tag, head); + const pkg = await manifest(git, `refs/tags/${tag}`); + if (pkg.version !== releaseVersion(tag)) throw new Error("Tag must match the committed package version."); + const notes = releaseNotes(await git("show", `refs/tags/${tag}:CHANGELOG.md`), tag); + const prerelease = semver.prerelease(pkg.version) !== null; + let published = await github.release(tag); + if (!published || published.draft) { + const latest = await github.latest(); + if (latest && !semver.gt(pkg.version, releaseVersion(latest.tag_name))) { + throw new Error("A new release must be newer than the latest published stable version."); + } + await git("switch", "--detach", `refs/tags/${tag}`); + await mkdir(directory, { recursive: true }); + const files = await build({ cwd, directory, version: pkg.version }); + await writeFile(join(directory, "release-notes.md"), notes); + await github.publish({ tag, ...files, notesFile: join(directory, "release-notes.md"), prerelease, draft: published }); + published = await github.release(tag); + } + if (!published || published.draft || published.tag_name !== tag || published.prerelease !== prerelease) { + throw new Error("GitHub has not confirmed the expected published release."); + } + if (prerelease) return { tag, prerelease: true }; + // Validate the actual uploaded assets even on retries that skip building. + updateReadme(await git("show", `refs/tags/${tag}:README.md`), published, repository); + const latest = await github.latest(); + if (latest?.tag_name !== tag) throw new Error("A newer release is already published. Refusing to restore an older README link."); + const current = await fetchRelease(git); + await assertPublishedTree(git, tag, current); + await clean(git); + await git("switch", "-C", "release", releaseRef); + const readme = await readFile(join(cwd, "README.md"), "utf8"); + const updated = updateReadme(readme, published, repository); + if (updated !== readme) { + await writeFile(join(cwd, "README.md"), updated); + await git("add", "--", "README.md"); + await git(...identity, "commit", "-m", `docs: update README download to ${tag}`); + await git("push", "origin", "HEAD:refs/heads/release"); + } + // A concurrent feature merge must not be silently included in the promotion. + if (await fetchRelease(git) !== await git("rev-parse", "HEAD")) throw new Error("release changed before PR creation. Retry after publication catches up."); + const pull = await github.promote({ + title: `Release ${tag}`, + body: `Publish ${tag} to main with all released changes and the updated package download.\n\nRelease: ${published.html_url}\n\n${notes}\nMerge this PR with a merge commit to preserve the long-lived release branch.`, + }); + return { tag, pullRequest: pull.html_url }; +} + +export async function verifyPromotion({ cwd, repository, event, github }) { + const pr = event.pull_request; + if (pr?.base?.ref !== "main" || pr.head?.ref !== "release" || pr.head.repo?.full_name !== repository) { + throw new Error("Only a PR from this repository's release branch can promote a package to main."); + } + const git = gitAt(cwd); + if (await git("rev-parse", "HEAD") !== pr.head.sha) throw new Error("Checkout must match the proposed PR head."); + const pkg = await manifest(git, "HEAD"); + const published = await github.latest(); + if (!published || releaseVersion(published.tag_name) !== pkg.version) throw new Error("PR version is not the latest published stable release."); + await assertPublishedTree(git, published.tag_name, "HEAD"); + const readme = await readFile(join(cwd, "README.md"), "utf8"); + if (updateReadme(readme, published, repository) !== readme) throw new Error("PR README does not point to the published assets."); + return `Release ${published.tag_name} and README are ready for review.`; +} + +export async function verifyFeature(cwd) { + const git = gitAt(cwd); + const pkg = await manifest(git, "HEAD"); + const version = semver.inc(pkg.version, "patch"); + prepareChangelog(await git("show", "HEAD:CHANGELOG.md"), version); + return `Manifests and unreleased notes are ready for patch ${version}.`; +} + +export function githubClient(repository, token) { + const base = `/repos/${repository}`; + const api = async (method, path, body) => { + const response = await fetch(`https://api.github.com${base}${path}`, { + method, + headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "Content-Type": "application/json", "X-GitHub-Api-Version": "2022-11-28" }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(30_000), + }); + if (response.status === 404 && method === "GET" && path.startsWith("/releases/")) return null; + if (!response.ok) throw new Error(`GitHub ${method} ${path} failed (HTTP ${response.status}). For PR creation, check Actions permissions and the repository's allow-create-PR setting.`); + return response.json(); + }; + const gh = args => execute("gh", [...args, "--repo", repository], { env: { ...process.env, GH_TOKEN: token }, timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }); + return { + release: tag => api("GET", `/releases/tags/${encodeURIComponent(tag)}`), + latest: () => api("GET", "/releases/latest"), + publish: async ({ tag, archive, checksum, notesFile, prerelease, draft }) => { + if (!draft) await gh(["release", "create", tag, "--verify-tag", "--draft", "--title", tag, "--notes-file", notesFile]); + // A retry can replace incomplete draft assets, but never overwrite a published package. + await gh(["release", "upload", tag, archive, checksum, "--clobber"]); + await gh(["release", "edit", tag, "--draft=false", `--prerelease=${prerelease}`, `--latest=${!prerelease}`, "--notes-file", notesFile]); + }, + promote: async ({ title, body }) => { + const pulls = await api("GET", `/pulls?state=open&base=main&head=${encodeURIComponent(repository.split("/")[0] + ":release")}`); + if (pulls.length > 1) throw new Error("Multiple release promotion PRs exist."); + if (pulls.length) return api("PATCH", `/pulls/${pulls[0].number}`, { title, body }); + return api("POST", "/pulls", { base: "main", head: "release", title, body }); + }, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + if (process.argv[2] === "verify-feature") { + console.log(await verifyFeature(process.cwd())); + } else { + const repository = process.env.GITHUB_REPOSITORY; + if (!repository || !process.env.GH_TOKEN || !process.env.GITHUB_EVENT_PATH) throw new Error("Run this script through GitHub Actions with its repository, token, and event file."); + const options = { + cwd: process.cwd(), repository, + event: JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, "utf8")), + github: githubClient(repository, process.env.GH_TOKEN), + directory: join(process.env.RUNNER_TEMP ?? "/tmp", "release"), + }; + console.log(process.argv[2] === "verify-promotion" ? await verifyPromotion(options) : await runRelease(options)); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index d19208d..8c4a13e 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -13,7 +13,7 @@ try { throw new Error("package.json and package-lock.json must describe the same root package."); } if (pkg.version !== version || lock.version !== version || lock.packages[""].version !== version) { - throw new Error(`Release tag ${tag} must match the version in package.json and both root versions in package-lock.json. Use npm version to commit the version and create its tag before pushing.`); + throw new Error(`Release tag ${tag} must match the version in package.json and both root versions in package-lock.json. Prepare the version and tag on release, never on main.`); } const prerelease = semver.prerelease(version) !== null; if (process.env.GITHUB_OUTPUT) { diff --git a/scripts/update-release-readme.mjs b/scripts/update-release-readme.mjs index 6394188..ff1bff3 100644 --- a/scripts/update-release-readme.mjs +++ b/scripts/update-release-readme.mjs @@ -1,4 +1,3 @@ -import { pathToFileURL } from "node:url"; import semver from "semver"; import { releaseVersion } from "./release-notes.mjs"; @@ -45,63 +44,3 @@ export function updateReadme(readme, release, repository) { ].join("\n"); return readme.slice(0, readme.indexOf(start)) + block + readme.slice(readme.indexOf(end) + end.length); } - -// Read the current default-branch file and its SHA; never replace it with the tag's README. -export async function syncReadme(repository, request) { - const base = repositoryPath(repository); - for (let attempt = 0; attempt < 3; attempt++) { - const { default_branch: branch } = await request("GET", base); - if (!branch) throw new Error("GitHub did not return the repository's default branch."); - const file = await request("GET", `${base}/contents/README.md?ref=${encodeURIComponent(branch)}`); - if (file.encoding !== "base64" || !file.sha || typeof file.content !== "string") { - throw new Error("GitHub did not return README.md content and its blob SHA."); - } - // Fetch latest at execution time, including retries, so an older queued run cannot restore its own tag. - const release = await request("GET", `${base}/releases/latest`); - const previous = Buffer.from(file.content, "base64").toString("utf8"); - const updated = updateReadme(previous, release, repository); - if (updated === previous) return `README already points to ${release.tag_name}.`; - try { - await request("PUT", `${base}/contents/README.md`, { - branch, - sha: file.sha, - message: `docs: update README download to ${release.tag_name}`, - content: Buffer.from(updated).toString("base64"), - }); - return `Updated README on ${branch} to ${release.tag_name}.`; - } catch (error) { - if (error.status !== 409 || attempt === 2) throw error; - } - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { - const token = process.env.GH_TOKEN; - if (!token) throw new Error("GH_TOKEN is required to update the README."); - if (process.argv.length !== 3) throw new Error("Usage: node scripts/update-release-readme.mjs OWNER/REPO"); - const request = async (method, path, body) => { - const response = await fetch(`https://api.github.com${path}`, { - method, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "X-GitHub-Api-Version": "2022-11-28", - }, - ...(body ? { body: JSON.stringify(body) } : {}), - signal: AbortSignal.timeout(30_000), - }); - if (!response.ok) { - const error = new Error(`GitHub ${method} ${path} failed (HTTP ${response.status}). Check Actions contents:write permission and default-branch rules before retrying.`); - error.status = response.status; - throw error; - } - return response.json(); - }; - console.log(await syncReadme(process.argv[2], request)); - } catch (error) { - console.error(error.message); - process.exitCode = 1; - } -} diff --git a/test/release-pipeline.test.mjs b/test/release-pipeline.test.mjs new file mode 100644 index 0000000..523a0c0 --- /dev/null +++ b/test/release-pipeline.test.mjs @@ -0,0 +1,273 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import test from "node:test"; +import { githubClient, prepareChangelog, releaseRequest, runRelease, verifyFeature, verifyPromotion } from "../scripts/release-pipeline.mjs"; +import { updateReadme } from "../scripts/update-release-readme.mjs"; + +const exec = promisify(execFile); +const repository = "example/automation"; +const template = "# Product\n\n\nold\n\n\nInstructions.\n"; +function assetRelease(tag) { + const version = tag.replace(/^v/, ""); + return { tag_name: tag, html_url: `https://github.com/${repository}/releases/tag/${tag}`, draft: false, prerelease: false, + assets: ["tgz", "tgz.sha256"].map(ext => ({ name: `opencode2-automation-${version}.${ext}`, state: "uploaded", + browser_download_url: `https://github.com/${repository}/releases/download/${tag}/opencode2-automation-${version}.${ext}` })), + }; +} + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "oc2-release-pipeline-")); + t.after(() => rm(root, { recursive: true, force: true })); + const cwd = join(root, "checkout"), remote = join(root, "remote.git"); + await mkdir(cwd); + const git = async (...args) => (await exec("git", args, { cwd })).stdout.trim(); + const bare = async (...args) => (await exec("git", ["--git-dir", remote, ...args])).stdout.trim(); + await git("init", "-b", "main"); + await git("config", "user.name", "Test"); + await git("config", "user.email", "test@example.com"); + await git("config", "commit.gpgsign", "false"); + const pkg = { name: "opencode2-automation", version: "0.6.2", private: true }; + await writeFile(join(cwd, "package.json"), JSON.stringify(pkg, null, 2) + "\n"); + await writeFile(join(cwd, "package-lock.json"), JSON.stringify({ name: pkg.name, version: pkg.version, lockfileVersion: 3, packages: { "": { ...pkg } } }, null, 2) + "\n"); + await writeFile(join(cwd, "README.md"), updateReadme(template, assetRelease("v0.6.2"), repository)); + await writeFile(join(cwd, "CHANGELOG.md"), "# Changelog\n\n## Unreleased\n\n## 0.6.2\n- Previous fix\n"); + await writeFile(join(cwd, "code.txt"), "original\n"); + await git("add", "."); + await git("commit", "-m", "Initial published version"); + const main = await git("rev-parse", "HEAD"); + await git("init", "--bare", remote); + await git("remote", "add", "origin", remote); + await git("branch", "release"); + await git("push", "origin", "main", "release"); + // Model a protected main at the Git transport boundary, not just with a mock. + await writeFile(join(remote, "hooks/pre-receive"), '#!/bin/sh\nwhile read old new ref; do\n if [ "$ref" = "refs/heads/main" ]; then exit 1; fi\ndone\n', { mode: 0o755 }); + await git("switch", "release"); + const eventFor = sha => ({ action: "closed", repository: { full_name: repository }, pull_request: { + number: 7, merged: true, merge_commit_sha: sha, base: { ref: "release" }, + } }); + async function mergeFeature(number = 7) { + await git("switch", "-c", `feature/${number}`, "release"); + await writeFile(join(cwd, "code.txt"), `feature ${number}\n`); + const changelog = await readFile(join(cwd, "CHANGELOG.md"), "utf8"); + await writeFile(join(cwd, "CHANGELOG.md"), changelog.replace("## Unreleased", `## Unreleased\n- Implement feature ${number}`)); + await git("add", "."); + await git("commit", "-m", `Feature ${number}`); + await git("switch", "release"); + await git("merge", "--no-ff", `feature/${number}`, "-m", `Merge PR #${number}`); + await git("push", "origin", "release"); + const event = eventFor(await git("rev-parse", "HEAD")); + event.pull_request.number = number; + return event; + } + const event = await mergeFeature(); + const releases = new Map([["v0.6.2", assetRelease("v0.6.2")]]); + const calls = []; + let pull; + const github = { + release: async tag => releases.get(tag) ?? null, + latest: async () => [...releases.values()].filter(r => !r.draft && !r.prerelease).at(-1), + publish: async ({ tag }) => { calls.push(`publish:${tag}`); releases.set(tag, assetRelease(tag)); }, + promote: async data => { + const latest = await github.latest(); + const readme = await bare("show", "refs/heads/release:README.md"); + assert.match(readme, new RegExp(latest.tag_name.replaceAll(".", "\\."))); + calls.push("promote"); + pull = { ...data, html_url: "https://github.com/example/automation/pull/8" }; + return pull; + }, + }; + const build = async ({ version }) => { + calls.push(`build:${version}`); + assert.equal(JSON.parse(await readFile(join(cwd, "package.json"), "utf8")).version, version); + return { archive: "test.tgz", checksum: "test.tgz.sha256" }; + }; + const options = { cwd, repository, event, github, directory: join(root, "artifacts"), build }; + return { ...options, git, bare, main, calls, releases, mergeFeature, + run: overrides => runRelease({ ...options, ...overrides }), + pull: () => pull, + promotionEvent: async () => ({ pull_request: { base: { ref: "main" }, head: { ref: "release", sha: await git("rev-parse", "HEAD"), repo: { full_name: repository } } } }), + }; +} + +test("automatic patch publishes before README and PR, leaves protected main untouched, and retries without bumping", async t => { + const f = await fixture(t); + const result = await f.run(); + assert.equal(result.tag, "v0.6.3"); + assert.deepEqual(f.calls, ["build:0.6.3", "publish:v0.6.3", "promote"]); + assert.equal(await f.bare("rev-parse", "main"), f.main); + assert.equal(await f.bare("rev-parse", "v0.6.3^"), f.event.pull_request.merge_commit_sha); + const tagged = await f.bare("rev-parse", "v0.6.3^{commit}"); + const tip = await f.bare("rev-parse", "release"); + assert.notEqual(tip, tagged); + assert.equal(await f.bare("diff", "--name-only", tagged, tip), "README.md"); + assert.match(await f.bare("show", "release:CHANGELOG.md"), /## 0\.6\.3\n- Implement feature 7/); + assert.match(f.pull().body, /Implement feature 7/); + await f.run(); + assert.equal(await f.bare("rev-parse", "release"), tip); + assert.equal(await f.bare("rev-parse", "v0.6.3^{commit}"), tagged); + assert.equal(f.calls.filter(c => c.startsWith("build")).length, 1); + assert.match(await verifyPromotion({ ...f, event: await f.promotionEvent() }), /ready for review/); +}); + +test("a manual unprefixed 1.0.0 tag is published unchanged and the next merged PR produces 1.0.1", async t => { + const f = await fixture(t); + await exec("npm", ["version", "1.0.0", "--no-git-tag-version", "--ignore-scripts"], { cwd: f.cwd }); + const changelog = prepareChangelog(await readFile(join(f.cwd, "CHANGELOG.md"), "utf8"), "1.0.0"); + await writeFile(join(f.cwd, "CHANGELOG.md"), changelog); + await f.git("add", "."); + await f.git("commit", "-m", "Release 1.0.0"); + await f.git("tag", "-a", "1.0.0", "-m", "Release 1.0.0"); + await f.git("push", "--atomic", "origin", "release", "refs/tags/1.0.0"); + const event = { repository: { full_name: repository }, ref: "refs/tags/1.0.0", after: await f.git("rev-parse", "refs/tags/1.0.0") }; + assert.equal((await f.run({ event })).tag, "1.0.0"); + assert.deepEqual(f.calls, ["build:1.0.0", "publish:1.0.0", "promote"]); + assert.equal(await f.git("tag", "--list", "v1.0.1"), ""); + const next = await f.mergeFeature(9); + assert.equal((await f.run({ event: next })).tag, "v1.0.1"); + assert.equal(await f.bare("rev-parse", "main"), f.main); +}); + +test("package failure leaves README and PR unchanged; retry resumes the same version", async t => { + const f = await fixture(t); + const original = await f.bare("show", "release:README.md"); + await assert.rejects(f.run({ build: async () => { throw new Error("Package failed"); } }), /Package failed/); + assert.equal(await f.bare("show", "release:README.md"), original); + assert.equal(f.releases.has("v0.6.3"), false); + assert.equal(f.pull(), undefined); + assert.equal((await f.run()).tag, "v0.6.3"); + assert.equal(await f.git("tag", "--list", "v0.6.4"), ""); +}); + +test("a lost publication response is recovered without repacking or republishing", async t => { + const f = await fixture(t); + const publish = f.github.publish; + f.github.publish = async data => { await publish(data); throw new Error("Connection lost"); }; + await assert.rejects(f.run(), /Connection lost/); + assert.equal(f.pull(), undefined); + f.github.publish = publish; + await f.run(); + assert.equal(f.calls.filter(c => c.startsWith("publish")).length, 1); + assert.equal(f.calls.filter(c => c.startsWith("build")).length, 1); + assert.ok(f.pull()); +}); + +test("a failed PR request can be retried without creating another README or version commit", async t => { + const f = await fixture(t); + const promote = f.github.promote; + f.github.promote = async () => { throw new Error("PR permission denied"); }; + await assert.rejects(f.run(), /PR permission denied/); + const tip = await f.bare("rev-parse", "release"); + f.github.promote = promote; + await f.run(); + assert.equal(await f.bare("rev-parse", "release"), tip); + assert.equal(f.calls.filter(c => c.startsWith("publish")).length, 1); +}); + +test("unmerged PRs, main merges, ordinary pushes, and foreign repositories cannot release", () => { + for (const event of [ + { ref: "refs/heads/release" }, { ref: "refs/heads/feature/test" }, + { action: "closed", pull_request: { merged: false, base: { ref: "release" } } }, + { action: "closed", pull_request: { merged: true, base: { ref: "main" } } }, + { ref: "refs/tags/v1.0.0", after: "bad" }, + ]) assert.throws(() => releaseRequest({ repository: { full_name: repository }, ...event }, repository)); + assert.throws(() => releaseRequest({ repository: { full_name: "another/repo" } }, repository)); +}); + +test("a manual tag on an unmerged feature cannot publish", async t => { + const f = await fixture(t); + await f.git("switch", "-c", "feature/unmerged"); + await writeFile(join(f.cwd, "code.txt"), "unmerged"); + await f.git("commit", "-am", "Unmerged feature"); + await f.git("tag", "v1.0.0"); + await f.git("push", "origin", "refs/tags/v1.0.0"); + const event = { repository: { full_name: repository }, ref: "refs/tags/v1.0.0", after: await f.git("rev-parse", "HEAD") }; + await assert.rejects(f.run({ event })); + assert.deepEqual(f.calls, []); + assert.equal(await f.bare("rev-parse", "main"), f.main); +}); + +test("an empty changelog fails before the version changes", async t => { + const f = await fixture(t); + await writeFile(join(f.cwd, "CHANGELOG.md"), "## Unreleased\n\n## 0.6.2\n- Old\n"); + await f.git("commit", "-am", "Empty notes"); + await f.git("push", "origin", "release"); + const event = structuredClone(f.event); + event.pull_request.merge_commit_sha = await f.git("rev-parse", "HEAD"); + await assert.rejects(verifyFeature(f.cwd), /must contain release notes/); + await assert.rejects(f.run({ event }), /must contain release notes/); + assert.equal(JSON.parse(await f.bare("show", "release:package.json")).version, "0.6.2"); + assert.equal(await f.git("status", "--porcelain"), ""); +}); + +test("a partially uploaded draft is not promoted and can be repaired using the same tag", async t => { + const f = await fixture(t); + const publish = f.github.publish; + f.github.publish = async ({ tag }) => { + f.releases.set(tag, { ...assetRelease(tag), draft: true, assets: [] }); + throw new Error("Upload failed"); + }; + await assert.rejects(f.run(), /Upload failed/); + assert.equal(f.pull(), undefined); + assert.doesNotMatch(await f.bare("show", "release:README.md"), /v0\.6\.3/); + f.github.publish = publish; + await f.run(); + assert.equal(f.releases.get("v0.6.3").draft, false); + assert.equal(await f.git("tag", "--list", "v0.6.4"), ""); +}); + +test("published metadata without both uploaded assets cannot advance README or open a PR", async t => { + const f = await fixture(t); + const publish = f.github.publish; + f.github.publish = async data => { + await publish(data); + f.releases.get(data.tag).assets.pop(); + }; + await assert.rejects(f.run(), /uploaded asset/); + assert.equal(f.pull(), undefined); + assert.doesNotMatch(await f.bare("show", "release:README.md"), /v0\.6\.3/); +}); + +test("new untagged code on release blocks both old publication retries and promotion", async t => { + const f = await fixture(t); + await f.run(); + await f.mergeFeature(10); + await assert.rejects(f.run(), /changes beyond the tagged package/); + await assert.rejects(verifyPromotion({ ...f, event: await f.promotionEvent() }), /changes beyond the tagged package/); +}); + +test("promotion rejects wrong source branches and unpublished or stale README contents", async t => { + const f = await fixture(t); + await f.run(); + const event = await f.promotionEvent(); + event.pull_request.head.ref = "feature/wrong"; + await assert.rejects(verifyPromotion({ ...f, event }), /Only a PR/); + await writeFile(join(f.cwd, "README.md"), template); + await f.git("commit", "-am", "Stale README"); + await assert.rejects(verifyPromotion({ ...f, event: await f.promotionEvent() }), /README does not point/); +}); + +test("GitHub promotion creates or updates only a release-to-main PR, with no content or ref writes", async t => { + const calls = []; + let existing = []; + t.mock.method(globalThis, "fetch", async (url, options) => { + const body = options.body && JSON.parse(options.body); + calls.push({ url, method: options.method, body }); + if (options.method === "GET") return Response.json(existing); + assert.match(url, /\/pulls(?:\/8)?$/); + if (options.method === "POST") { + assert.equal(body.head, "release"); + assert.equal(body.base, "main"); + existing = [{ number: 8, html_url: "https://github.com/example/automation/pull/8" }]; + } + return Response.json(existing[0]); + }); + const client = githubClient(repository, "test-token"); + const first = await client.promote({ title: "Release v0.6.3", body: "Notes" }); + const retry = await client.promote({ title: "Release v0.6.4", body: "New notes" }); + assert.equal(first.html_url, retry.html_url); + assert.deepEqual(calls.map(c => c.method), ["GET", "POST", "GET", "PATCH"]); +}); diff --git a/test/release-publication.test.mjs b/test/release-publication.test.mjs index 6f0831a..b24c6b4 100644 --- a/test/release-publication.test.mjs +++ b/test/release-publication.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import { releaseNotes } from "../scripts/release-notes.mjs"; -import { syncReadme, updateReadme } from "../scripts/update-release-readme.mjs"; +import { updateReadme } from "../scripts/update-release-readme.mjs"; const repository = "example/automation"; const readme = "# Product\n\n\nold install\n\n\nOther instructions.\n"; @@ -18,10 +18,6 @@ function release(version = "0.6.2", tag = `v${version}`) { })), }; } -function file(content, sha = "original-sha") { - return { encoding: "base64", sha, content: Buffer.from(content).toString("base64") }; -} - test("release notes select only the exact tagged version regardless of surrounding releases", () => { const changelog = "# Changelog\n\n## Unreleased\n- Future\n\n## 0.6.2\n### Fixed\n- Keep owner active\n\n## 0.6.1\n- Banner\n"; assert.equal(releaseNotes(changelog, "v0.6.2"), "### Fixed\n- Keep owner active\n"); @@ -80,70 +76,3 @@ test("README refuses missing, duplicate, or reversed markers", () => { assert.throws(() => updateReadme(content, release(), repository), /marker/); } }); - -test("a delayed updater queries latest and writes only the default-branch README with its SHA", async () => { - const calls = []; - const request = async (method, path, body) => { - calls.push({ method, path, body }); - if (path === `/repos/${repository}`) return { default_branch: "main" }; - if (path.endsWith("?ref=main")) return file(readme); - if (path.endsWith("/latest")) return release("0.6.4"); - assert.equal(method, "PUT"); - return {}; - }; - assert.equal(await syncReadme(repository, request), "Updated README on main to v0.6.4."); - const put = calls.at(-1); - assert.equal(put.path, `/repos/${repository}/contents/README.md`); - assert.equal(put.body.sha, "original-sha"); - assert.equal(put.body.branch, "main"); - assert.equal(Buffer.from(put.body.content, "base64").toString(), updateReadme(readme, release("0.6.4"), repository)); -}); - -test("an up-to-date README produces no commit", async () => { - const updated = updateReadme(readme, release(), repository); - const request = async (method, path) => { - assert.equal(method, "GET"); - if (path.endsWith("/latest")) return release(); - if (path.includes("/contents/")) return file(updated); - return { default_branch: "master" }; - }; - assert.match(await syncReadme(repository, request), /already points to v0.6.2/); -}); - -test("a conflicting edit retries with fresh content, SHA, and latest release", async () => { - let writes = 0; - const concurrent = readme.replace("Other instructions.", "Someone else's new instructions."); - const request = async (method, path, body) => { - if (method === "PUT") { - writes++; - if (writes === 1) throw Object.assign(new Error("Conflict"), { status: 409 }); - assert.equal(body.sha, "new-sha"); - const content = Buffer.from(body.content, "base64").toString(); - assert.match(content, /Someone else's new instructions/); - assert.match(content, /v0\.6\.3/); - return {}; - } - if (path.endsWith("/latest")) return release(writes ? "0.6.3" : "0.6.2"); - if (path.includes("/contents/")) return file(writes ? concurrent : readme, writes ? "new-sha" : "original-sha"); - return { default_branch: "main" }; - }; - assert.match(await syncReadme(repository, request), /v0.6.3/); - assert.equal(writes, 2); -}); - -test("write failures are surfaced and conflict retries are bounded", async () => { - for (const status of [403, 409, 422]) { - let writes = 0; - const request = async (method, path) => { - if (method === "PUT") { - writes++; - throw Object.assign(new Error("Write failed"), { status }); - } - if (path.endsWith("/latest")) return release(); - if (path.includes("/contents/")) return file(readme); - return { default_branch: "main" }; - }; - await assert.rejects(syncReadme(repository, request), /Write failed/); - assert.equal(writes, status === 409 ? 3 : 1); - } -}); From 8ad03d860df85fc1bc20d6ba9dd79dd7078893a0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:18:15 +0000 Subject: [PATCH 3/4] Release v0.6.3 from PR #7 --- .github/release-state.json | 5 +++++ CHANGELOG.md | 4 +++- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 .github/release-state.json diff --git a/.github/release-state.json b/.github/release-state.json new file mode 100644 index 0000000..02ba434 --- /dev/null +++ b/.github/release-state.json @@ -0,0 +1,5 @@ +{ + "pullRequest": 7, + "sourceSHA": "0607dc761f2bff2bafca5c820f1427109cc13fde", + "version": "0.6.3" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 76827f8..ef7de51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ include the full version, for example `## 0.7.0-beta.1`. ## Unreleased +## 0.6.3 + ### Fixed - Generate release descriptions from the matching changelog section, so publishing @@ -49,4 +51,4 @@ include the full version, for example `## 0.7.0-beta.1`. - Add detailed bot workflow diagrams and a documentation map in `AGENTS.md`. - Expand bundled bot instructions for planning, delegation, verification, and handing publication back to the dispatcher. -- Add the README banner showing an OpenCode2 agent executing a task. +- Add the README banner showing an OpenCode2 agent executing a task. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0b7d6c1..7e8d435 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode2-automation", - "version": "0.6.2", + "version": "0.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode2-automation", - "version": "0.6.2", + "version": "0.6.3", "hasInstallScript": true, "dependencies": { "@opencode/client": "0.0.0-beta-19398", diff --git a/package.json b/package.json index 261874c..addaa96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode2-automation", - "version": "0.6.2", + "version": "0.6.3", "description": "Issue-to-PR automation for OpenCode 2 with a scheduler and GitHub dispatcher", "main": "./dist/index.js", "files": [ From 0f84630377fec661a3b7cfea02abe9f798737297 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:18:39 +0000 Subject: [PATCH 4/4] docs: update README download to v0.6.3 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ce4a08a..5d9c8cf 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,12 @@ Nothing needs to be published to npm. `$HOME` expands to your home directory. Run this command on the machine running OpenCode 2: -Latest stable release: **[v0.6.2](https://github.com/d3cker/opencode2-github-automation/releases/tag/v0.6.2)**. +Latest stable release: **[v0.6.3](https://github.com/d3cker/opencode2-github-automation/releases/tag/v0.6.3)**. -[Download the .tgz package](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.2/opencode2-automation-0.6.2.tgz) · [SHA-256 checksum](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.2/opencode2-automation-0.6.2.tgz.sha256) +[Download the .tgz package](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.3/opencode2-automation-0.6.3.tgz) · [SHA-256 checksum](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.3/opencode2-automation-0.6.3.tgz.sha256) ```bash -npm install --global --prefix "$HOME/.local" "https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.2/opencode2-automation-0.6.2.tgz" +npm install --global --prefix "$HOME/.local" "https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.3/opencode2-automation-0.6.3.tgz" ```