diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 53e561a..8be914b 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -8,7 +8,7 @@ on: type: string required: true publish: - description: Upload the artifacts to the GitHub release + description: Upload the artifacts to the draft GitHub release type: boolean default: false workflow_dispatch: @@ -87,6 +87,7 @@ jobs: ulimit -n 65536 export APPLE_API_KEY="$RUNNER_TEMP/AuthKey.p8" if [ "${{ inputs.publish }}" = "true" ]; then + bun run scripts/check-release.ts prepare "$UPSTER_VERSION" bun run package:desktop --publish else bun run package:desktop @@ -104,6 +105,7 @@ jobs: path: | apps/desktop/release/*.dmg apps/desktop/release/*.zip + if-no-files-found: error retention-days: 7 linux: @@ -138,6 +140,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | if [ "${{ inputs.publish }}" = "true" ]; then + bun run scripts/check-release.ts prepare "$UPSTER_VERSION" bun run package:desktop --publish else bun run package:desktop @@ -149,4 +152,5 @@ jobs: path: | apps/desktop/release/*.AppImage apps/desktop/release/*.deb + if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3cfb81f..da63a80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,10 @@ on: permissions: contents: read +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: meta: runs-on: ubuntu-latest @@ -31,6 +35,10 @@ jobs: gh workflow list --repo kerddotdev/homebrew-tap | grep -q "Update Upster Formula" \ || { echo "TAP_GITHUB_TOKEN cannot see the tap workflow" >&2; exit 1; } + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + - id: meta run: | if [ "${GITHUB_REF_TYPE}" = "tag" ] && [ "${{ inputs.dry_run }}" != "true" ]; then @@ -47,6 +55,13 @@ jobs: fi echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Refuse to modify an already published release + if: steps.meta.outputs.publish == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.meta.outputs.version }} + run: bun run scripts/check-release.ts prepare "$VERSION" + server-bundles: # GitHub-hosted: Vectis job admission occasionally stalls and a stalled # job would block the whole release. @@ -153,6 +168,13 @@ jobs: bun-version: 1.3.13 no-cache: true + - name: Refuse to modify an already published release + if: needs.meta.outputs.publish == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.meta.outputs.version }} + run: bun run scripts/check-release.ts prepare "$VERSION" + - run: bun install --frozen-lockfile - run: bun run test @@ -239,10 +261,12 @@ jobs: release-assets/* retention-days: 3 - - name: Create GitHub Release + - name: Create draft GitHub Release if: needs.meta.outputs.publish == 'true' uses: softprops/action-gh-release@v2 with: + draft: true + fail_on_unmatched_files: true generate_release_notes: true files: | apps/cli/dist/upster-macos-arm64.tar.gz @@ -254,17 +278,62 @@ jobs: release-assets/docker-compose.yaml release-assets/env.example + desktop: + needs: [meta, release] + permissions: + contents: write + uses: ./.github/workflows/desktop.yml + with: + version: ${{ needs.meta.outputs.version }} + publish: ${{ needs.meta.outputs.publish == 'true' }} + secrets: inherit + + publish: + runs-on: ubuntu-latest + needs: [meta, release, desktop] + if: needs.meta.outputs.publish == 'true' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Verify every required asset and publish the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.meta.outputs.version }} + run: | + bun run scripts/check-release.ts publish "$VERSION" + gh release edit "v$VERSION" --draft=false + + homebrew: + runs-on: ubuntu-latest + needs: [meta, publish] + if: needs.meta.outputs.publish == 'true' + steps: + - name: Download published checksums + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.meta.outputs.version }} + run: gh release download "v$VERSION" --repo "$GITHUB_REPOSITORY" --pattern checksums.sha256 + - name: Trigger Homebrew tap update - if: success() && needs.meta.outputs.publish == 'true' env: GH_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} VERSION: ${{ needs.meta.outputs.version }} run: | - cd apps/cli/dist - MACOS_ARM64=$(sha256sum upster-macos-arm64.tar.gz | cut -d' ' -f1) - MACOS_X64=$(sha256sum upster-macos-x64.tar.gz | cut -d' ' -f1) - LINUX_ARM64=$(sha256sum upster-linux-arm64.tar.gz | cut -d' ' -f1) - LINUX_X64=$(sha256sum upster-linux-x64.tar.gz | cut -d' ' -f1) + MACOS_ARM64=$(awk '$2 == "upster-macos-arm64.tar.gz" { print $1 }' checksums.sha256) + MACOS_X64=$(awk '$2 == "upster-macos-x64.tar.gz" { print $1 }' checksums.sha256) + LINUX_ARM64=$(awk '$2 == "upster-linux-arm64.tar.gz" { print $1 }' checksums.sha256) + LINUX_X64=$(awk '$2 == "upster-linux-x64.tar.gz" { print $1 }' checksums.sha256) + for checksum in "$MACOS_ARM64" "$MACOS_X64" "$LINUX_ARM64" "$LINUX_X64"; do + [[ "$checksum" =~ ^[0-9a-f]{64}$ ]] || { echo "Invalid release checksum" >&2; exit 1; } + done gh workflow run update-upster-formula.yml \ --repo kerddotdev/homebrew-tap \ -f version="$VERSION" \ @@ -272,13 +341,3 @@ jobs: -f sha256_macos_x64="$MACOS_X64" \ -f sha256_linux_arm64="$LINUX_ARM64" \ -f sha256_linux_x64="$LINUX_X64" - - desktop: - needs: [meta, release] - permissions: - contents: write - uses: ./.github/workflows/desktop.yml - with: - version: ${{ needs.meta.outputs.version }} - publish: ${{ needs.meta.outputs.publish == 'true' }} - secrets: inherit diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 4c7839c..74443db 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -41,6 +41,6 @@ deb: packageName: upster publish: provider: github - releaseType: release + releaseType: draft owner: kerddotdev repo: upster diff --git a/package.json b/package.json index 3d6ff16..18a29bd 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "cli:build": "bun run --cwd apps/cli build", "cli:test": "bun run --cwd apps/cli test", "preview": "UPSTER_DATA_DIR=${UPSTER_DATA_DIR:-$PWD/.upster} UPSTER_WORKSPACE_ROOTS=${UPSTER_WORKSPACE_ROOTS:-$PWD} bun run --cwd apps/web preview", - "test": "bun run --cwd packages/core test && bun run --cwd packages/service test && bun run --cwd packages/migration test && bun run --cwd apps/desktop test && bun run --cwd apps/web test && bun run --cwd apps/cli test", + "test": "bun test scripts && bun run --cwd packages/core test && bun run --cwd packages/service test && bun run --cwd packages/migration test && bun run --cwd apps/desktop test && bun run --cwd apps/web test && bun run --cwd apps/cli test", "lint": "bun run --cwd apps/web lint", "format": "prettier --write \"apps/**/*.{ts,tsx,js,jsx}\" \"packages/**/*.{ts,tsx,js,jsx}\" \"scripts/**/*.ts\"", "check": "prettier --check \"apps/**/*.{ts,tsx,js,jsx}\" \"packages/**/*.{ts,tsx,js,jsx}\" \"scripts/**/*.ts\"", diff --git a/scripts/check-release.ts b/scripts/check-release.ts new file mode 100644 index 0000000..29e9404 --- /dev/null +++ b/scripts/check-release.ts @@ -0,0 +1,24 @@ +import { assertComplete, assertDraft, getRelease } from "./release-gate" + +const [mode, version] = process.argv.slice(2) +const repository = process.env.GITHUB_REPOSITORY +const token = process.env.GH_TOKEN + +if ( + !["prepare", "publish"].includes(mode) || + !version || + !repository || + !token +) { + throw new Error( + "Usage: check-release.ts with GITHUB_REPOSITORY and GH_TOKEN" + ) +} + +const release = await getRelease(repository, version, token) +if (mode === "prepare") { + if (release) assertDraft(release, version) +} else { + if (!release) throw new Error(`Draft release v${version} does not exist`) + assertComplete(release, version) +} diff --git a/scripts/release-gate.test.ts b/scripts/release-gate.test.ts new file mode 100644 index 0000000..ec3458b --- /dev/null +++ b/scripts/release-gate.test.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { + assertComplete, + assertDraft, + getRelease, + requiredAssets, +} from "./release-gate" +import type { Release } from "./release-gate" + +const version = "1.2.3" +const complete = (): Release => ({ + tag_name: `v${version}`, + draft: true, + assets: requiredAssets(version).map((name) => ({ + name, + size: 123, + state: "uploaded", + })), +}) + +describe("release publication gate", () => { + it("accepts a draft with every required asset uploaded", () => { + assert.doesNotThrow(() => assertComplete(complete(), version)) + assert.equal(requiredAssets(version).length, 26) + assert.equal(new Set(requiredAssets(version)).size, 26) + }) + + for (const name of requiredAssets(version)) { + it(`blocks publication without ${name}`, () => { + const release = complete() + release.assets = release.assets.filter((asset) => asset.name !== name) + assert.throws( + () => assertComplete(release, version), + /missing or incomplete/ + ) + }) + } + + it("blocks zero-byte and unfinished uploads", () => { + for (const incomplete of [{ size: 0 }, { state: "starter" }]) { + const release = complete() + Object.assign(release.assets[0], incomplete) + assert.throws(() => assertComplete(release, version), /checksums.sha256/) + } + }) + + it("does not accept a differently versioned installer", () => { + const release = complete() + release.assets.find((asset) => asset.name.endsWith(".dmg"))!.name = + "Upster-1.0.0.dmg" + assert.throws(() => assertComplete(release, version), /Upster-1.2.3.dmg/) + }) + + it("refuses an existing published release before any upload", () => { + assert.throws( + () => assertDraft({ ...complete(), draft: false }, version), + /already public/ + ) + }) + + it("refuses to publish a different tag", () => { + assert.throws( + () => assertComplete({ ...complete(), tag_name: "v0.1.0" }, version), + /Expected release/ + ) + }) +}) + +describe("release lookup", () => { + const request = (status: number, body = "{}"): typeof fetch => + (async () => new Response(body, { status })) as typeof fetch + + it("allows a new release only after exhausting the release list", async () => { + assert.equal( + await getRelease("owner/repo", version, "test", request(200, "[]")), + null + ) + }) + + it("fails closed on missing repository, authentication, rate-limit, and server errors", async () => { + for (const status of [401, 403, 404, 429, 500]) { + await assert.rejects( + getRelease("owner/repo", version, "test", request(status)), + new RegExp(`HTTP ${status}`) + ) + } + }) + + it("uses the authenticated releases list to find an exact draft tag", async () => { + const mock = (async (input, init) => { + assert.equal( + input, + "https://api.github.com/repos/owner/repo/releases?per_page=100&page=1" + ) + assert.equal( + new Headers(init?.headers).get("Authorization"), + "Bearer test" + ) + return new Response( + JSON.stringify([{ ...complete(), tag_name: "v1.2.30" }, complete()]) + ) + }) as typeof fetch + assert.deepEqual( + await getRelease("owner/repo", version, "test", mock), + complete() + ) + }) + + it("finds a public release beyond the first page so reruns are rejected", async () => { + let calls = 0 + const published = { ...complete(), draft: false } + const mock = (async (input) => { + calls++ + assert.equal( + input, + `https://api.github.com/repos/owner/repo/releases?per_page=100&page=${calls}` + ) + return new Response( + JSON.stringify( + calls === 1 + ? Array.from({ length: 100 }, (_, i) => ({ + ...complete(), + tag_name: `v0.0.${i}`, + })) + : [published] + ) + ) + }) as typeof fetch + const release = await getRelease("owner/repo", version, "test", mock) + assert.equal(calls, 2) + assert.deepEqual(release, published) + assert.throws(() => assertDraft(release!, version), /already public/) + }) + + it("fails closed when a later page cannot be read", async () => { + let calls = 0 + const mock = (async () => { + calls++ + return calls === 1 + ? new Response( + JSON.stringify( + Array.from({ length: 100 }, () => ({ + ...complete(), + tag_name: "v0.1.0", + })) + ) + ) + : new Response("{}", { status: 403 }) + }) as typeof fetch + await assert.rejects( + getRelease("owner/repo", version, "test", mock), + /HTTP 403/ + ) + }) + + it("rejects a malformed release list", async () => { + await assert.rejects( + getRelease("owner/repo", version, "test", request(200)), + /invalid release list/ + ) + }) +}) diff --git a/scripts/release-gate.ts b/scripts/release-gate.ts new file mode 100644 index 0000000..3c38a07 --- /dev/null +++ b/scripts/release-gate.ts @@ -0,0 +1,97 @@ +export type ReleaseAsset = { + name: string + size: number + state: string +} + +export type Release = { + tag_name: string + draft: boolean + assets: Array +} + +export function requiredAssets(version: string): Array { + return [ + "checksums.sha256", + "docker-compose.yaml", + "env.example", + ...["macos", "linux"].flatMap((platform) => + ["arm64", "x64"].map((arch) => `upster-${platform}-${arch}.tar.gz`) + ), + ...["darwin", "linux"].flatMap((platform) => + ["arm64", "x64"].map((arch) => `upster-server-${platform}-${arch}.tar.gz`) + ), + ...["", "-arm64"].flatMap((arch) => [ + `Upster-${version}${arch}.dmg`, + `Upster-${version}${arch}.dmg.blockmap`, + `Upster-${version}${arch}-mac.zip`, + `Upster-${version}${arch}-mac.zip.blockmap`, + ]), + ...["x86_64", "arm64"].map((arch) => `Upster-${version}-${arch}.AppImage`), + ...["amd64", "arm64"].map((arch) => `Upster-${version}-${arch}.deb`), + "latest-mac.yml", + "latest-linux.yml", + "latest-linux-arm64.yml", + ] +} + +export function assertDraft(release: Release, version: string): void { + if (release.tag_name !== `v${version}`) { + throw new Error(`Expected release v${version}, got ${release.tag_name}`) + } + if (release.draft !== true) { + throw new Error( + `Release v${version} is already public; refusing to modify it` + ) + } +} + +export function assertComplete(release: Release, version: string): void { + assertDraft(release, version) + const missing = requiredAssets(version).filter( + (name) => + !release.assets.some( + (asset) => + asset.name === name && asset.state === "uploaded" && asset.size > 0 + ) + ) + if (missing.length > 0) { + throw new Error( + `Release v${version} has missing or incomplete assets: ${missing.join(", ")}` + ) + } +} + +export async function getRelease( + repository: string, + version: string, + token: string, + request: typeof fetch = fetch +): Promise { + for (let page = 1; ; page++) { + const response = await request( + `https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + } + ) + if (!response.ok) { + throw new Error( + `Could not read release v${version}: HTTP ${response.status}` + ) + } + const releases = (await response.json()) as Array + if (!Array.isArray(releases)) { + throw new Error( + `Could not read release v${version}: invalid release list` + ) + } + const release = releases.find((item) => item.tag_name === `v${version}`) + if (release) return release + if (releases.length < 100) return null + } +}