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
48 changes: 48 additions & 0 deletions .github/scripts/release_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Compare package versions across the complete push, including squash merges."""
import json
import os
import re
import subprocess
from pathlib import Path


def release_outputs(previous, current):
match = re.fullmatch(
r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
r'(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?', current)
if not match or (match[4] and any(
part.isdigit() and len(part) > 1 and part.startswith('0')
for part in match[4].split('.'))):
raise ValueError(f'Invalid package.json SemVer: {current!r}')
return {
'changed': str(previous is not None and previous != current).lower(),
'version': current,
'tag': f'v{current}',
'prerelease': str(match[4] is not None).lower(),
}


def main():
event = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text(encoding='utf-8'))
before = event['before']
current = json.loads(Path('package.json').read_text(encoding='utf-8'))['version']
previous = None
if before != '0' * 40:
# checkout fetch-depth: 0 normally supplies this object; a force push may not.
available = subprocess.run(['git', 'cat-file', '-e', f'{before}^{{commit}}'],
capture_output=True)
if available.returncode:
subprocess.run(['git', 'fetch', 'origin', before], check=True)
content = subprocess.check_output(['git', 'show', f'{before}:package.json'],
encoding='utf-8')
previous = json.loads(content)['version']
outputs = release_outputs(previous, current)
with Path(os.environ['GITHUB_OUTPUT']).open('a', encoding='utf-8') as output:
for key, value in outputs.items():
output.write(f'{key}={value}\n')
print(f'Version: {previous!r} -> {current!r}; release: {outputs["changed"]}')


if __name__ == '__main__':
main()
145 changes: 145 additions & 0 deletions .github/scripts/test_publish_release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Native Node.js tests cannot use TypeScript return annotations.
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
import path from 'node:path'
const workflow = fs.readFileSync('.github/workflows/ci.yml', 'utf8')
const source = workflow.split(/ {10}script: \|\r?\n/)[1]
assert.ok(source, 'Publish script found')
const AsyncFunction = Object.getPrototypeOf(async () => null).constructor
const publish = new AsyncFunction('require', 'github', 'context', 'core', 'process', source)

async function simulate(options = {}) {
const calls = []
const missing = () => {
throw Object.assign(new Error('Not found'), { status: 404 })
}
const record = (name, data) => {
calls.push([name, data])
return { data }
}
const github = {
paginate: async () => options.assets || [],
rest: {
git: {
getRef: async () => {
if (options.permissionError) {
throw Object.assign(new Error('Forbidden'), { status: 403 })
}
return options.ref ? { data: options.ref } : missing()
},
getTag: async () => ({ data: { object: { type: 'commit', sha: 'trigger-sha' } } })
},
repos: {
getReleaseByTag: async () => (options.release ? { data: options.release } : missing()),
generateReleaseNotes: async () => ({ data: { body: 'generated notes' } }),
createRelease: async (data) => {
record('create', data)
return { data: { id: 42 } }
},
deleteReleaseAsset: async (data) => record('delete', data),
uploadReleaseAsset: async (data) => {
record('upload', data)
if (options.failUpload) throw new Error('upload failed')
},
updateRelease: async (data) => record('publish', data)
}
}
}
const fakeRequire = (name) =>
name === 'node:fs'
? {
readdirSync: () =>
options.files || ['auto-caption-v1.3.0.exe', 'auto-caption-v1.3.0.tar.gz'],
readFileSync: () => Buffer.from('installer')
}
: path
let error
try {
await publish(
fakeRequire,
github,
{ repo: { owner: 'owner', repo: 'repo' }, sha: 'trigger-sha' },
{ info: () => {} },
{ env: { RELEASE_TAG: 'v1.3.0', RELEASE_PRERELEASE: String(!!options.prerelease) } }
)
} catch (caught) {
error = caught
}
return { calls, error }
}

test('Creates draft at triggering SHA and publishes Windows/Linux without a macOS installer', async () => {
const { calls, error } = await simulate()
assert.equal(error, undefined)
assert.deepEqual(
calls.map((call) => call[0]),
['create', 'upload', 'upload', 'publish']
)
assert.deepEqual(
calls.filter((call) => call[0] === 'upload').map((call) => call[1].name),
['auto-caption-v1.3.0.exe', 'auto-caption-v1.3.0.tar.gz']
)
assert.equal(calls[0][1].target_commitish, 'trigger-sha')
assert.equal(calls[0][1].draft, true)
assert.equal(calls.at(-1)[1].draft, false)
})
test('Upload failure leaves draft unpublished', async () => {
const { calls, error } = await simulate({ failUpload: true })
assert.match(error.message, /upload failed/)
assert.ok(!calls.some((call) => call[0] === 'publish'))
})
test('Draft retry replaces partial asset and publishes', async () => {
const { calls, error } = await simulate({
release: { id: 42, draft: true, target_commitish: 'trigger-sha' },
assets: [{ id: 9, name: 'auto-caption-v1.3.0.exe' }]
})
assert.equal(error, undefined)
assert.ok(calls.some((call) => call[0] === 'delete' && call[1].asset_id === 9))
assert.ok(!calls.some((call) => call[0] === 'create'))
assert.equal(calls.at(-1)[0], 'publish')
})
test('Published release at same annotated tag is skipped', async () => {
const { calls, error } = await simulate({
release: { id: 42, draft: false },
ref: { object: { type: 'tag', sha: 'annotated-tag-sha' } }
})
assert.equal(error, undefined)
assert.equal(calls.length, 0)
})
test('Tag at another commit fails without mutation', async () => {
const { calls, error } = await simulate({ ref: { object: { type: 'commit', sha: 'other-sha' } } })
assert.match(error.message, /another commit/)
assert.equal(calls.length, 0)
})

test('Untagged draft at another commit fails without mutation', async () => {
const { calls, error } = await simulate({
release: { id: 42, draft: true, target_commitish: 'other-sha' }
})
assert.match(error.message, /another target/)
assert.equal(calls.length, 0)
})

test('API permission failure is not treated as a missing release', async () => {
const { calls, error } = await simulate({ permissionError: true })
assert.equal(error.status, 403)
assert.equal(calls.length, 0)
})
for (const [missing, files] of [
['.exe', ['auto-caption-v1.3.0.tar.gz']],
['.tar.gz', ['auto-caption-v1.3.0.exe']]
]) {
test(`Missing ${missing} installer fails before creating release`, async () => {
const { calls, error } = await simulate({ files })
assert.equal(error.message, `Missing installer: ${missing}`)
assert.equal(calls.length, 0)
})
}
test('Pre-release is published without marking latest', async () => {
const { calls, error } = await simulate({ prerelease: true })
assert.equal(error, undefined)
assert.equal(calls.at(-1)[1].prerelease, true)
assert.equal(calls.at(-1)[1].make_latest, 'false')
})
72 changes: 72 additions & 0 deletions .github/scripts/test_release_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

from release_version import release_outputs


class ReleaseVersionTests(unittest.TestCase):
def test_unchanged_and_new_branch_do_not_release(self):
for previous in ('1.2.0', None):
self.assertEqual(release_outputs(previous, '1.2.0')['changed'], 'false')

def test_version_change(self):
self.assertEqual(release_outputs('1.2.0', '1.3.0'), {
'changed': 'true', 'version': '1.3.0', 'tag': 'v1.3.0',
'prerelease': 'false',
})

def test_prerelease_and_build_metadata(self):
result = release_outputs('1.2.0', '1.3.0-rc.1+build.42')
self.assertEqual(result['prerelease'], 'true')
self.assertEqual(result['tag'], 'v1.3.0-rc.1+build.42')
self.assertEqual(release_outputs('1.2.0', '1.3.0+build.42')['prerelease'], 'false')

def test_reject_unsafe_or_invalid_versions(self):
for version in ('v1.3.0', '01.3.0', '1.3', '1.3.0-01', '1.3.0\ntag=oops', ''):
with self.subTest(version=version), self.assertRaises(ValueError):
release_outputs('1.2.0', version)

def test_detection_compares_entire_push_not_last_commit(self):
script = Path(__file__).with_name('release_version.py').resolve()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)

def git(*args):
return subprocess.check_output(['git', *args], cwd=root, encoding='utf-8',
stderr=subprocess.STDOUT).strip()

def commit(version):
(root / 'package.json').write_text(json.dumps({'version': version}),
encoding='utf-8')
git('add', 'package.json')
git('-c', 'user.name=CI Test', '-c', 'user.email=ci@example.com',
'commit', '--allow-empty', '-m', 'fixture')

git('init')
commit('1.2.0')
before = git('rev-parse', 'HEAD')
commit('1.3.0')
commit('1.3.0') # A later commit does not modify the version.
event = root / 'event.json'
output = root / 'output.txt'
event.write_text(json.dumps({'before': before}), encoding='utf-8')
environment = {**os.environ, 'GITHUB_EVENT_PATH': str(event),
'GITHUB_OUTPUT': str(output)}
subprocess.run([sys.executable, str(script)], cwd=root, env=environment,
check=True, capture_output=True)
self.assertIn('changed=true', output.read_text(encoding='utf-8'))
self.assertIn('tag=v1.3.0', output.read_text(encoding='utf-8'))
event.write_text(json.dumps({'before': '0' * 40}), encoding='utf-8')
output.write_text('', encoding='utf-8')
subprocess.run([sys.executable, str(script)], cwd=root, env=environment,
check=True, capture_output=True)
self.assertIn('changed=false', output.read_text(encoding='utf-8'))


if __name__ == '__main__':
unittest.main()
Loading
Loading