diff --git a/.dagger/ci/scripts/release.sh b/.dagger/ci/scripts/release.sh deleted file mode 100644 index 5664922..0000000 --- a/.dagger/ci/scripts/release.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -# A failed view may mean an API outage, so a failed create must remain fatal. -if [[ "$1" == create ]]; then - if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - gh release edit "$RELEASE_TAG" --draft=false --prerelease=false - else - gh release create "$RELEASE_TAG" --verify-tag --generate-notes --draft=false --prerelease=false - fi -fi -shopt -s nullglob -assets=() -for asset in ./*.tar.gz ./*.zip ./*.deb ./*.rpm ./*.sha256; do [[ ! -f "$asset" ]] || assets+=("$asset"); done -(( ${#assets[@]} > 0 )) || { echo "No release assets supplied" >&2; exit 1; } -gh release upload "$RELEASE_TAG" "${assets[@]}" --clobber diff --git a/.dagger/ci/src/CI/Assets.purs b/.dagger/ci/src/CI/Assets.purs new file mode 100644 index 0000000..2c540de --- /dev/null +++ b/.dagger/ci/src/CI/Assets.purs @@ -0,0 +1,28 @@ +module CI.Assets (releaseFiles, packageFiles, isReleaseAsset) where + +import Prelude +import Dagger.Directory as Directory +import Dagger.Types (Directory, ExistsType(..)) +import Data.Array (filter, filterA) +import Data.Foldable (any) +import Data.Maybe (Maybe(..), isJust) +import Data.String (Pattern(..), stripSuffix) +import Effect.Aff (Aff) + +isReleaseAsset :: String -> Boolean +isReleaseAsset name = any (\suffix -> endsWith (Pattern suffix) name) + [ ".tar.gz", ".zip", ".deb", ".rpm", ".sha256" ] + +releaseFiles :: Directory -> Aff (Array String) +releaseFiles directory = do + entries <- Directory.entries directory + filterA (\name -> Directory.existsWith name + (Directory.defaultExistsOpts { expectedType = Just ExistsTypeRegularType }) directory) + (filter isReleaseAsset entries) + +packageFiles :: Directory -> Aff (Array String) +packageFiles directory = filter (\name -> endsWith (Pattern ".deb") name || endsWith (Pattern ".rpm") name) + <$> releaseFiles directory + +endsWith :: Pattern -> String -> Boolean +endsWith suffix value = isJust (stripSuffix suffix value) diff --git a/.dagger/ci/src/CI/Release.purs b/.dagger/ci/src/CI/Release.purs index d789956..7b7e667 100644 --- a/.dagger/ci/src/CI/Release.purs +++ b/.dagger/ci/src/CI/Release.purs @@ -3,12 +3,14 @@ module CI.Release (releaseArgs, publishRelease, uploadRelease) where import Prelude import Dagger (Client) import Dagger.Container as Container -import Dagger.CurrentModule as CurrentModule -import Dagger.Directory as Directory import Dagger.Modules as Module import Dagger.Query as Query -import Dagger.Types (Container, Directory, Secret) -import Effect.Aff (Aff) +import Dagger.Types (Container, Directory, Secret, ReturnType(..)) +import CI.Assets as Assets +import Data.Array (null) +import Data.Maybe (Maybe(..)) +import Effect.Aff (Aff, throwError) +import Effect.Exception (error) type ReleaseArgs = { assets :: Directory, tag :: String, token :: Secret, runId :: String } @@ -23,23 +25,33 @@ releaseArgs = ado publisher :: Client -> Container publisher client = Query.container client # Container.from "node:22-bookworm-slim" - # Container.withExec [ "sh", "-c", "apt-get update && apt-get install -y --no-install-recommends gh curl ca-certificates && rm -rf /var/lib/apt/lists/*" ] - # Container.withDirectory "/pipeline" (Query.currentModule client # CurrentModule.source # Directory.directory "scripts") + # Container.withExec [ "apt-get", "update" ] + # Container.withExec [ "apt-get", "install", "-y", "--no-install-recommends", "gh", "ca-certificates" ] -release :: String -> Client -> ReleaseArgs -> Aff String -release mode client args = do - void $ Container.sync $ publisher client - # Container.withDirectory "/assets" args.assets - # Container.withWorkdir "/assets" - # Container.withSecretVariable "GH_TOKEN" args.token - # Container.withEnvVariable "GH_REPO" "tsirysndr/snapcaster" - # Container.withEnvVariable "RELEASE_TAG" args.tag - # Container.withEnvVariable "WORKFLOW_RUN" args.runId - # Container.withExec [ "bash", "/pipeline/release.sh", mode ] +release :: Boolean -> Client -> ReleaseArgs -> Aff String +release create client args = do + files <- Assets.releaseFiles args.assets + when (null files) $ throwError (error "No release assets supplied") + let base = publisher client + # Container.withDirectory "/assets" args.assets + # Container.withWorkdir "/assets" + # Container.withSecretVariable "GH_TOKEN" args.token + # Container.withEnvVariable "GH_REPO" "tsirysndr/snapcaster" + # Container.withEnvVariable "WORKFLOW_RUN" args.runId + ready <- if create then do + status <- Container.exitCode $ base + # Container.withExecWith [ "gh", "release", "view", args.tag ] + (Container.defaultWithExecOpts { expect = Just ReturnTypeAny }) + pure $ base # Container.withExec + (if status == 0 then [ "gh", "release", "edit", args.tag, "--draft=false", "--prerelease=false" ] + else [ "gh", "release", "create", args.tag, "--verify-tag", "--generate-notes", "--draft=false", "--prerelease=false" ]) + else pure base + void $ Container.sync $ ready # Container.withExec + ([ "gh", "release", "upload", args.tag, "--clobber" ] <> map ("./" <> _) files) pure "Release assets published" publishRelease :: Client -> ReleaseArgs -> Aff String -publishRelease = release "create" +publishRelease = release true uploadRelease :: Client -> ReleaseArgs -> Aff String -uploadRelease = release "upload" +uploadRelease = release false diff --git a/.dagger/tests/pipeline.test.mjs b/.dagger/tests/pipeline.test.mjs new file mode 100644 index 0000000..e08ab9e --- /dev/null +++ b/.dagger/tests/pipeline.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; +import { readdir, readFile } from 'node:fs/promises'; + +// Build with `cd .dagger/ci && spago build --strict` first. +const output = process.env.CI_OUTPUT_DIR + ? pathToFileURL(resolve(process.env.CI_OUTPUT_DIR) + '/') + : new URL('../ci/output/', import.meta.url); +const prefix = process.env.CI_MODULE_PREFIX ? `${process.env.CI_MODULE_PREFIX}.` : ''; +const load = name => import(new URL(`${name}/index.js`, output)); +const [Dagger, Query, Aff, Either, Release, Assets] = await Promise.all([ + load('Dagger'), load('Dagger.Query'), load('Effect.Aff'), load('Data.Either'), + load(`${prefix}CI.Release`), load(`${prefix}CI.Assets`), +]); +const run = aff => new Promise((resolve, reject) => Aff.runAff_(result => () => { + if (result instanceof Either.Left) reject(result.value0); + else resolve(result.value0); +})(aff)()); + +async function engine(t, { files = [], exists = true, fail = false } = {}) { + const commands = []; + const server = createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) body += chunk; + const { query, variables } = JSON.parse(body); + const fields = query.slice(query.indexOf('{')).replace(/\([^)]*\)/g, '').match(/\b\w+\b/g); + const leaf = fields.at(-1); + const args = Object.values(variables); + const execs = args.filter(Array.isArray); + commands.push(...execs); + if (fail && leaf === 'sync') { + res.end(JSON.stringify({ errors: [{ message: 'publication failed', extensions: { _type: 'EXEC_ERROR', exitCode: 1 } }] })); + return; + } + let value = leaf === 'entries' ? files + : leaf === 'exists' ? !args.includes('directory.deb') + : leaf === 'exitCode' ? (exists ? 0 : 1) : 'fixture-id'; + for (const field of fields.reverse()) value = { [field]: value }; + res.end(JSON.stringify({ data: value })); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(() => { server.closeAllConnections(); server.close(); }); + const client = Dagger.connectWith({ port: server.address().port, token: 'fixture', timeoutMs: 1000 })(); + return { client, commands, args: { + assets: Query.directory(client), token: Query.setSecret('fixture')('fixture')(client), + tag: 'v1.2.3', runId: 'fixture', + } }; +} + +test('existing release uploads only regular release assets as literal arguments', async t => { + const f = await engine(t, { files: ['app.tar.gz', 'app.tar.gz.sha256', 'driver.zip', 'directory.deb', 'launch-agent.plist'] }); + await run(Release.publishRelease(f.client)(f.args)); + const gh = f.commands.filter(args => args[0] === 'gh'); + assert.equal(gh.some(args => args[2] === 'create'), false); + assert.deepEqual(gh.at(-1), ['gh', 'release', 'upload', 'v1.2.3', '--clobber', './app.tar.gz', './app.tar.gz.sha256', './driver.zip']); +}); +test('new release creation precedes upload and engine failures propagate', async t => { + const f = await engine(t, { files: ['app.tar.gz'], exists: false, fail: true }); + await assert.rejects(run(Release.publishRelease(f.client)(f.args)), /publication failed/); + const gh = f.commands.filter(args => args[0] === 'gh'); + assert.equal(gh.findIndex(args => args[2] === 'create') < gh.findIndex(args => args[2] === 'upload'), true); +}); +test('empty uploads fail before invoking a publishing command', async t => { + const f = await engine(t); + await assert.rejects(run(Release.uploadRelease(f.client)(f.args)), /No release assets/); + assert.equal(f.commands.length, 0); +}); +test('asset selection excludes checkout metadata', () => { + assert.equal(Assets.isReleaseAsset('service.plist'), false); + assert.equal(Assets.isReleaseAsset('README.md'), false); + assert.equal(Assets.isReleaseAsset('driver.zip.sha256'), true); +}); +test('handwritten pipeline code never launches a shell', async () => { + const directory = new URL('../ci/src/CI/', import.meta.url); + for (const name of await readdir(directory)) { + if (!name.endsWith('.purs')) continue; + const source = await readFile(new URL(name, directory), 'utf8'); + assert.doesNotMatch(source, /withExec(?:With)?\s*\[\s*"(?:sh|bash|\/bin\/sh|\/bin\/bash)"/, name); + } +}); diff --git a/.dagger/tests/release.test.mjs b/.dagger/tests/release.test.mjs deleted file mode 100644 index b74622b..0000000 --- a/.dagger/tests/release.test.mjs +++ /dev/null @@ -1,55 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { spawnSync } from 'node:child_process'; - -const script = fileURLToPath(new URL('../ci/scripts/release.sh', import.meta.url)); -async function fixture(t, { exists = true, createFails = false } = {}) { - const root = await mkdtemp(join(tmpdir(), 'dagger-release-')); - t.after(() => rm(root, { recursive: true, force: true })); - await mkdir(join(root, 'bin')); - await mkdir(join(root, 'assets')); - const log = join(root, 'calls.jsonl'); - await writeFile(log, ''); - await writeFile(join(root, 'bin', 'gh'), `#!/usr/bin/env node -import { appendFileSync } from 'node:fs'; -const args = process.argv.slice(2); -appendFileSync(process.env.GH_MOCK_LOG, JSON.stringify(args) + '\\n'); -if (args[1] === 'view') process.exit(${exists ? 0 : 1}); -if (args[1] === 'create') process.exit(${createFails ? 1 : 0}); -`, { mode: 0o755 }); - // Make the stub's module format independent of the host Node default. - await writeFile(join(root, 'bin', 'package.json'), '{"type":"module"}'); - const env = { ...process.env, PATH: `${join(root, 'bin')}:${process.env.PATH}`, GH_MOCK_LOG: log, RELEASE_TAG: 'v1.2.3' }; - return { - assets: join(root, 'assets'), - run: mode => spawnSync('bash', [script, mode], { cwd: join(root, 'assets'), env, encoding: 'utf8' }), - calls: async () => (await readFile(log, 'utf8')).trim().split('\n').filter(Boolean).map(JSON.parse), - }; -} -test('existing release uploads assets only, excluding checkout metadata and directories', async t => { - const f = await fixture(t); - for (const name of ['app.tar.gz', 'app.tar.gz.sha256', 'driver.zip', 'app.deb', 'app.rpm', 'launch-agent.plist']) { - await writeFile(join(f.assets, name), 'fixture'); - } - await mkdir(join(f.assets, 'debian')); - const result = f.run('create'); - assert.equal(result.status, 0, result.stderr); - const calls = await f.calls(); - assert.equal(calls.some(args => args[1] === 'create'), false); - assert.deepEqual(calls.at(-1), ['release', 'upload', 'v1.2.3', './app.tar.gz', './driver.zip', './app.deb', './app.rpm', './app.tar.gz.sha256', '--clobber']); -}); -test('failed release creation never proceeds to upload', async t => { - const f = await fixture(t, { exists: false, createFails: true }); - await writeFile(join(f.assets, 'app.tar.gz'), 'fixture'); - assert.notEqual(f.run('create').status, 0); - assert.equal((await f.calls()).some(args => args[1] === 'upload'), false); -}); -test('upload-only mode rejects an empty asset directory without creating a release', async t => { - const f = await fixture(t); - assert.notEqual(f.run('upload').status, 0); - assert.deepEqual(await f.calls(), []); -});