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 faee110..4132850 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,80 +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: 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 - 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" --generate-notes) - if [[ "$PRERELEASE" == "true" ]]; then - args+=(--prerelease --latest=false) - fi - gh release create "$RELEASE_TAG" "$PACKAGE_FILE" "$PACKAGE_FILE.sha256" "${args[@]}" diff --git a/AGENTS.md b/AGENTS.md index ab7b3af..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,6 +43,12 @@ For common investigations: - **Why is polling inactive or duplicated?** Read architecture ownership, workflow section 1, and installation registration details. +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 - `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..76827f8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +Release descriptions come from the exact version section committed with the tag. +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 + +### 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 + 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 + +### 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..ce4a08a 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) -2. Restart the service when its sessions are idle: +```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" +``` + - ```bash - opencode2 service restart - ``` +`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. + +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: +Wait for active bot work to finish, then: -1. Install the new file using the **same prefix** as before: +1. Run the versioned command in [Install from a .tgz package](#install-from-a-tgz-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. - ```bash - npm install --global --prefix "$HOME/.local" /absolute/path/to/opencode2-automation-NEW_VERSION.tgz - ``` - - 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: @@ -287,22 +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. - -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): +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.0 -git push --atomic origin HEAD v0.6.0 +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. +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 3eb3116..66c6c2a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,6 +4,14 @@ 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 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 `npm install --global` runs the bundled `postinstall` script. It writes two small 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/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/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 new file mode 100644 index 0000000..ff1bff3 --- /dev/null +++ b/scripts/update-release-readme.mjs @@ -0,0 +1,46 @@ +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); +} 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 new file mode 100644 index 0000000..b24c6b4 --- /dev/null +++ b/test/release-publication.test.mjs @@ -0,0 +1,78 @@ +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 { 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}`, + })), + }; +} +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/); + } +});