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
6 changes: 5 additions & 1 deletion .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -104,6 +105,7 @@ jobs:
path: |
apps/desktop/release/*.dmg
apps/desktop/release/*.zip
if-no-files-found: error
retention-days: 7

linux:
Expand Down Expand Up @@ -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
Expand All @@ -149,4 +152,5 @@ jobs:
path: |
apps/desktop/release/*.AppImage
apps/desktop/release/*.deb
if-no-files-found: error
retention-days: 7
93 changes: 76 additions & 17 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ on:
permissions:
contents: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
meta:
runs-on: ubuntu-latest
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -254,31 +278,66 @@ 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" \
-f sha256_macos_arm64="$MACOS_ARM64" \
-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
2 changes: 1 addition & 1 deletion apps/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ deb:
packageName: upster
publish:
provider: github
releaseType: release
releaseType: draft
owner: kerddotdev
repo: upster
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand Down
24 changes: 24 additions & 0 deletions scripts/check-release.ts
Original file line number Diff line number Diff line change
@@ -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 <prepare|publish> <version> 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)
}
163 changes: 163 additions & 0 deletions scripts/release-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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/
)
})
})
Loading
Loading