4. Publish worker — create the GitHub Release

#5 · closed · 2 comments

View on GitHub ↗

jwulf

Part of #1. **Depends-on: #7** — the scaffold + lint/test conventions must be merged first. Do **not** scaffold the app or add/alter the lint, typecheck, or test setup here; build on the merged skeleton and add your own tests to the existing `tests/` layout. ## 4. Publish worker — create the GitHub Release Close the loop: on approval, ship the release. ### Deliverables - A worker handling the publish service task: create a GitHub Release for the target repo with the finalized `notes_md` as the body and the computed tag/name. - Idempotent: if the release/tag already exists, update rather than error (or no-op). - Persist the resulting release URL back onto the draft row; set status = `published`. - Uses `gh` or GitHub REST via `fetch`; host-agnostic. ### Acceptance - On approval, a real (draft or prerelease is fine for testing) GitHub Release is created and its URL stored. - Re-running the publish path does not create a duplicate. - Typecheck / `urban check` / `deno check` pass.

Comments

jwulf

## QA4 finding — S2: `publishRelease` silently drops the release URL when the persist matches 0 rows **Where:** `workers/publish.ts` `publishRelease` (the `await deps.store.markPublished(...)` at ~line 119). **Class:** data-loss / inconsistent-row on publish (QA4 target 3: "a crash/mismatch between *create release* and *persist URL* … leaves an inconsistent row"). **What's wrong:** `DraftStore.markPublished` returns `Promise<number>` — the count of `release_drafts` rows updated — *specifically so the caller can confirm the write landed*. `publishRelease` **discards that return value**. So if the update matches **zero rows** (stale/wrong `draftId`, or the row was deleted), a real GitHub Release has already been created, yet `publishRelease` still resolves `status: "published"` with the release URL persisted onto **no row**. The release URL is silently lost and the draft row is left inconsistent (never marked published, no URL), with no error surfaced for the engine to retry. **Repro (pre-fix):** ```ts const client = () => Promise.resolve({ id: 1, htmlUrl: "https://x/v1", tag: "v1", action: "created" }); const store = { markPublished: () => Promise.resolve(0) }; // update matched nothing const res = await publishRelease( { draftId: "does-not-exist", repo: "o/r", notesMd: "# n", tag: "v1" }, { client, store }, ); // actual: res = {status:"published", releaseUrl:"https://x/v1", releaseId:1, action:"created"} // → reports success though 0 rows were persisted ``` `node --experimental-strip-types --test` output: `RESULT: {"status":"published","releaseUrl":"https://x/v1",...}` — publish claimed success with a zero-row persist. **Fix (landed in the QA PR):** `publishRelease` captures the row count and throws when `rows < 1`, so the job fails loudly (and the engine retries) instead of reporting a phantom publish. **Guard (CI-enforced, `tests/unit/publish-idempotency.test.ts`):** "published implies a persisted release row" — the 0-row persist is now rejected; verified failing on the pre-fix code and passing on the fix. Severity **S2** (invariant/data-integrity): no duplicate/erroneous GitHub release is produced and the release URL is still returned in the process result, so it is not a full S1 loss — but the DB row↔release linkage can be silently skipped on a `draftId` mismatch. Fix + guard both in the QA PR against #6.

jwulf

## S1 (QA5 — Security): publish can be aimed at an ARBITRARY repo **Falsification target:** prove publish can be aimed at a repo the caller shouldn't touch. **Repro (`file:line` + reasoning):** the publish target `repo` originates in the untrusted `/hooks/release` webhook body (`correlationKey = body.repo`) and flows unmodified: `processes/release-notes.bpmn` `publish` ioMapping `=repo` → `workers/publish.ts` `coerceInput()` (line ~74, `repo = vars.repo`) → `upsertRelease()` in `workers/lib/github-release.ts:121`, which does a `POST /repos/{owner}/{name}/releases` **write** with the app's `GITHUB_TOKEN`. There was **no allow-list / authorization** on `repo`, so a release can be published to any repo that token can write to. **Guard + fix landed (PR #15):** - `workers/lib/repo-allowlist.ts` — `assertRepoAllowed(repo, allowed)` (exact `owner/name` or `owner/*`, case-insensitive, **fail-closed** when the list is empty/unset). - `workers/publish.ts` — the handler now calls `assertRepoAllowed(input.repo, parseAllowedRepos(app.env("RELEASE_ALLOWED_REPOS")))` **before** any GitHub side effect. - `tests/unit/security-publish-repo.test.ts` — proves an un-allow-listed repo (and an unset allow-list) is refused with `RepoNotAllowedError` and **no draft-row write**, so an unauthorized target never reaches the API.