Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
518 changes: 464 additions & 54 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

104 changes: 88 additions & 16 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,95 @@ the laptop's OpenCode, the laptop ledger is the source of truth, and no check ru

## Releasing

Every plugin releases as `v0.0.0`, and it installs from that GitHub tag. The release is one commit on top of a `master`
head that CI passed, and the tag points at it.

1. Set `version` to `0.0.0` in `package.json`, date the `## [0.0.0]` entry in `CHANGELOG.md`, then run `npm run check`.
Each release is the GitHub tag `v<version>`, where `<version>` is the `version` in `package.json`, and users install from
that tag. The release is one commit on top of a `master` head that CI passed, and the tag points at it. The release
workflow makes both, in GitHub Actions: nobody builds, commits or tags a release on their own machine, and no personal
key signs it.

While changes land, their notes go under `## [Unreleased]` at the top of `CHANGELOG.md`. Each earlier GitHub release is
a `## [x.y.z] - YYYY-MM-DD` heading below it, newest first, and the npm history stays last, under
`## Earlier releases on npm`.

1. On `master`, set `version` in `package.json` to the new version, and change every install spec in the README,
`CONTRIBUTING.md` and `docs/` to `#v<version>` (`test/docs.test.ts` fails until they match). Then run
`node scripts/changelog.mjs release`: it turns `## [Unreleased]` into `## [<version>] - <today>`, above the earlier
releases, and refuses a CHANGELOG it cannot shape. Run `npm run check`.
2. Push that commit to `master`, and wait for CI to pass on it.
3. In a clean checkout of `master` at that commit, run `node scripts/release-commit.mjs --ci-sha <its full sha>`. It builds
`dist/`, removes the lifecycle scripts, and creates the signed commit and the signed annotated tag `v0.0.0`. It never
pushes; `--dry-run` shows the change first.
4. Push the tag only: `git push origin v0.0.0`.
5. Approve the run in the `release` environment. The release workflow checks the tag and its commit, installs the tag on
each stock OpenCode host (2.2.3 to 2.2.6), packs it, and creates the GitHub Release with the notes, the tarball and
`SHA256SUMS`.

A tag that already exists stops step 3. Delete the old tag and its Release first, then start again.


Each release has its own tag, and a Git install does not update by itself. To move to a newer release, remove the old entry with `opencode plugin remove github:joe-broadhead/opencode-boxd#v0.0.0`, then add the new tag with `opencode plugin add github:joe-broadhead/opencode-boxd#v<version>`.
3. Start the release with that commit's full sha:
`gh workflow run release.yml --ref master -f sha=<full sha>`, or from Actions → release →
Run workflow, on `master`.
4. Read the run's summary, then approve the `release` environment. The summary is on the run page, under the `stage`
job: the tag, the parent and its CI run, the release commit and its tree, the sha256 of `SHA256SUMS` and the staged
artifact's id, `SHA256SUMS` itself, and the notes. Open the release commit on `release/v<version>` and read them
before approving: the approval releases exactly those bytes. Only the owner approves a release. Agents never approve
one, never call the deployment-approval API, and never bypass a ruleset or the environment; the kit's scripts make
only their own GitHub calls and refuse every other.
5. Check the published Release: not a draft, its notes, and every asset against `SHA256SUMS`.

Before the approval, the workflow:
- **plans:** refuses a run that is not on `master`, a sha that is not `master`'s head, a sha without a successful `ci.yml`
push run, a version that is not `x.y.z`, a CHANGELOG whose newest release is not this version, a release that does not
move forward from the last published Release, a leftover `release/v<version>` branch, a native run that did not build
this sha on `master`, by hand, with success, and a missing `native_run` (or one given to a plugin that is not native);
- **builds** `dist/` with `npm ci --ignore-scripts` and `npm run build`, with a read-only token, and writes the release
files: `dist/`, `package.json` without its lifecycle scripts, and the vetted native manifest;
- **commits** them on a temporary branch `release/v<version>` at the parent, through GitHub's API with the workflow's token,
so GitHub signs the commit (author `github-actions[bot]`, committer GitHub). This job runs no npm. It refuses a commit
that GitHub does not verify, or whose parent or tree is not the one the files give;
- **verifies** on a fresh runner: the commit's identity, one parent on `master`, `package.json` exactly the parent's
without the lifecycle scripts, only regular files in the allowed paths, no `package.json` under `dist/`, and a second,
independent build of the parent (its own job) equal to the committed `dist/` byte for byte;
- **installs** the release commit with `plugin add` on each stock OpenCode host (2.2.3 to 2.2.6);
- **packs** the commit before installing anything and keeps that tarball, then runs the pack stage against a copy;
- **stages** the Release: verifies the attested native files, writes `SHA256SUMS` and the notes (the version's CHANGELOG
section), and shows them all in the run summary with the sha256 of `SHA256SUMS`.

A job either runs repository or dependency code (`npm ci`, the build, the pack stage, a host install) and has no write
token, or uses the API and runs only the kit's scripts on a fresh checkout, after checking that the release commit's
`scripts/` and `.github/` are the parent's. The first kind is not token-free: `actions/checkout` fetches with the job's
read-only (`contents: read`) token, and its post step and the artifact uploads run after repository code on the same
runner, so that code could reach a read-only token for this public repository and the runner's artifact token. Jobs
pass files as artifacts downloaded by the id the uploading job gave, and each is checked again on arrival.

After the approval, it downloads the staged artifact by the id the summary names, and refuses unless `SHA256SUMS` has
the sha256 the summary shows and every staged file matches it, and every native file is the one the release commit's
`manifest/native.json` pins, size and sha256, with each gzip holding the pinned executable. Then it checks everything again, creates the annotated tag
`v<version>` on the release commit (tagger `github-actions[bot]`, unsigned), checks the tag, creates the Release with the
notes, the tarball, the native files and `SHA256SUMS`, and deletes `release/v<version>`. The tag keeps the commit.

GitHub's signature and the bot identity show only that GitHub made the commit through its API for the workflow; anyone
with write access can make such a commit. What ties the release to `master` is its content: one parent CI passed, the
exact `package.json`, the allowed paths and the byte-for-byte rebuild. Never relax those because the commit is signed.

**A rejected, expired or failed run** leaves nothing behind: the `cleanup` job deletes `release/v<version>` when it points
at the parent or at that run's commit, and never touches another branch. No tag exists before the approval, so the
version is not burned: fix `master` and start again. If `cleanup` itself failed, the next run refuses until the branch
is deleted: `gh api -X DELETE repos/joe-broadhead/opencode-boxd/git/refs/heads/release/v<version>`.

**Resume.** If the run fails after creating the tag and before the Release is complete, start it again with the same `sha`. The plan finds the kit's tag on a release commit on that sha with no published Release, and resumes:
it makes no new commit, checks and installs the tagged commit, stages the files, and after a new approval reuses the tag
and adds what the Release lacks. Delete a draft Release a failed upload left first: the publish step refuses a draft.
Any other existing tag is refused: release the next version.

The repository's tag ruleset keeps a `v*` tag from being moved or deleted. A stray `v*` tag, one that never became a
Release, does not block later releases: only published Releases count, and drafts do not. Its version cannot be
released, so release the next one. To remove the tag anyway, an admin disables the `release tags` ruleset, deletes the
tag, and restores the ruleset exactly as it was.

**Known limit: a `v*` tag pushed by hand.** No ruleset limits who creates a `v*` tag, so anyone with write access can
push one by hand, pointing at any commit. Such a tag has no published Release, the forward-only check ignores it, and
Jelly installs only a published Release whose `SHA256SUMS` it verifies. But a user who installs
`github:joe-broadhead/opencode-boxd#v<x>` directly gets whatever that tag points at. Only the release workflow makes
release tags; never push one by hand.

The release kit is these files, the same in every plugin: `scripts/release-rules.mjs`, `scripts/release-commit.mjs`,
`scripts/release-github.mjs`, `scripts/check-release-shape.mjs`, `scripts/changelog.mjs`, `scripts/release-version.mjs`,
`scripts/release-pack.mjs`, `scripts/publish-release.mjs`, `scripts/attach-native.mjs`, `scripts/native-manifest.mjs`,
`scripts/tested-hosts.mjs`, `scripts/check-release-plugin-add.sh`, `scripts/release-kit.test.mjs` and
`.github/workflows/release.yml`. Do not re-run the older `apply-release-kit.mjs`: it writes back the kit that could
release only `v0.0.0`.

Each release has its own tag, and a Git install does not update by itself. To move to a newer release, remove the old entry with `opencode plugin remove github:joe-broadhead/opencode-boxd#v<old version>`, then add the new tag with `opencode plugin add github:joe-broadhead/opencode-boxd#v<version>`.

Or add the plugin to `opencode.json` instead, with the same spec:

Expand Down
56 changes: 31 additions & 25 deletions scripts/attach-native.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@
// Per file: size and sha256 equal the manifest; the attestation verifies with `gh attestation verify`. Per module, the
// gzip decompresses to the executable the manifest pins (size and sha256). Any mismatch stops the release. A repository
// with no manifest has nothing to attach: the script says so and exits 0.
import { execFileSync } from "node:child_process"
import { createHash } from "node:crypto"
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync } from "node:fs"
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"
import { gunzipSync } from "node:zlib"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { fileURLToPath } from "node:url"
import { assetUrl, manifestProblems, MANIFEST_PATH } from "./native-manifest.mjs"
import { ghOutput } from "./release-rules.mjs"

function parseArgs(argv) {
const options = { out: undefined, repo: process.env.GITHUB_REPOSITORY, manifest: MANIFEST_PATH }
Expand Down Expand Up @@ -54,33 +54,39 @@ function main() {
if (!options.repo) throw new Error("the repository is not known: set GITHUB_REPOSITORY or pass --repo")

const downloads = mkdtempSync(join(tmpdir(), "native-artifacts-"))
execFileSync("gh", ["run", "download", String(manifest.built.run), "--repo", options.repo, "--dir", downloads], { stdio: "inherit" })
const found = filesByName(downloads)
mkdirSync(options.out, { recursive: true })
let staged
try {
ghOutput(["run", "download", String(manifest.built.run), "--repo", options.repo, "--dir", downloads], { inherit: true })
const found = filesByName(downloads)
mkdirSync(options.out, { recursive: true })

const entries = []
for (const [module, platforms] of Object.entries(manifest.modules)) {
for (const [platform, entry] of Object.entries(platforms)) entries.push({ label: `${module} ${platform}`, entry, gzipped: true })
}
for (const licence of manifest.licences ?? []) entries.push({ label: `licence ${licence.name}`, entry: licence, gzipped: false })
const entries = []
for (const [module, platforms] of Object.entries(manifest.modules)) {
for (const [platform, entry] of Object.entries(platforms)) entries.push({ label: `${module} ${platform}`, entry, gzipped: true })
}
for (const licence of manifest.licences ?? []) entries.push({ label: `licence ${licence.name}`, entry: licence, gzipped: false })

for (const { label, entry, gzipped } of entries) {
const candidates = found.get(entry.name) ?? []
if (candidates.length !== 1) throw new Error(`${label}: the run's artifacts hold ${candidates.length} files named ${entry.name}; one is needed`)
const [path] = candidates
const bytes = readFileSync(path)
if (bytes.length !== entry.bytes) throw new Error(`${label}: ${entry.name} is ${bytes.length} bytes; the manifest pins ${entry.bytes}`)
if (sha256(bytes) !== entry.sha256) throw new Error(`${label}: ${entry.name} does not match the manifest's sha256`)
if (gzipped) {
const executable = gunzipSync(bytes)
if (executable.length !== entry.executable.bytes) throw new Error(`${label}: the executable is ${executable.length} bytes; the manifest pins ${entry.executable.bytes}`)
if (sha256(executable) !== entry.executable.sha256) throw new Error(`${label}: the executable does not match the manifest's sha256`)
for (const { label, entry, gzipped } of entries) {
const candidates = found.get(entry.name) ?? []
if (candidates.length !== 1) throw new Error(`${label}: the run's artifacts hold ${candidates.length} files named ${entry.name}; one is needed`)
const [path] = candidates
const bytes = readFileSync(path)
if (bytes.length !== entry.bytes) throw new Error(`${label}: ${entry.name} is ${bytes.length} bytes; the manifest pins ${entry.bytes}`)
if (sha256(bytes) !== entry.sha256) throw new Error(`${label}: ${entry.name} does not match the manifest's sha256`)
if (gzipped) {
const executable = gunzipSync(bytes)
if (executable.length !== entry.executable.bytes) throw new Error(`${label}: the executable is ${executable.length} bytes; the manifest pins ${entry.executable.bytes}`)
if (sha256(executable) !== entry.executable.sha256) throw new Error(`${label}: the executable does not match the manifest's sha256`)
}
ghOutput(["attestation", "verify", path, "--repo", options.repo], { inherit: true })
copyFileSync(path, join(options.out, entry.name))
console.log(`ok ${label}: ${entry.name} (${bytes.length} bytes, attested) from ${assetUrl(manifest, entry)}`)
}
execFileSync("gh", ["attestation", "verify", path, "--repo", options.repo], { stdio: "inherit" })
copyFileSync(path, join(options.out, entry.name))
console.log(`ok ${label}: ${entry.name} (${bytes.length} bytes, attested) from ${assetUrl(manifest, entry)}`)
staged = entries.length
} finally {
rmSync(downloads, { recursive: true, force: true })
}
console.log(`attach-native: ${entries.length} files verified and staged in ${options.out}`)
console.log(`attach-native: ${staged} files verified and staged in ${options.out}`)
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
Expand Down
Loading
Loading