fix(ci): deploy version tags and install instance controls
Development CI / check-and-package (push) Successful in 4m21s
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / image (push) Successful in 22s
Development CI / deploy-development (push) Successful in 16s

This commit is contained in:
eezd committed 2026-11-02 15:37:54 +08:00
1 parent 6cbd604a28
commit 403824eccb
11 files changed
+335 -16

No files matched your search

+27 -2
View File
@@ -4,6 +4,7 @@
import argparse
import contextlib
import fcntl
import hashlib
import json
import os
from pathlib import Path
@@ -46,12 +47,35 @@ def save(path, data):
temporary.unlink(missing_ok=True)
def install_controls(directory):
# Recognize the original templates; subsequent updates verify the installed receipt.
legacy = {'start.sh': 'b4709f36a66b428671a09f373f640a99259845924779dcb122f5cd9c17e85ab6',
'stop.sh': '83348a7781b7d738a715bde51387951041a5cfcd31796805422ef83139a21571'}
receipt = directory / '.control-scripts.json'
previous = json.loads(receipt.read_text()) if receipt.exists() else {}
sources = Path(__file__).resolve().parents[1] / 'deploy'
scripts = {name: (sources / name).read_bytes() for name in legacy}
hashes = {name: hashlib.sha256(data).hexdigest() for name, data in scripts.items()}
# Check both entries before replacing either; preserve custom or locally edited scripts.
for name in scripts:
target = directory / name
require(not target.is_symlink(), f'Refusing to replace symlink: {target}')
if target.exists():
current = hashlib.sha256(target.read_bytes()).hexdigest()
require(current in (legacy[name], hashes[name], previous.get(name)),
f'Locally modified control script requires review: {target}')
for name, data in scripts.items():
save(directory / name, data)
(directory / name).chmod(0o755)
save(receipt, (json.dumps(hashes, indent=2) + '\n').encode())
def deployment(directory, channel, image, commit, version, client_container=None):
directory = Path(directory).resolve(strict=True)
require(channel == 'development', 'Only the persistent DEV instance supports deployment')
cfg = settings(directory, channel)
registry = GITEA_DEV_IMAGE
require(re.fullmatch(re.escape(registry) + r'@sha256:[0-9a-f]{64}', image), 'Expected an immutable registry digest')
require(image == registry + ':' + version, 'Expected the selected development version tag')
require(re.fullmatch(r'[0-9a-f]{40}', commit), 'Expected a full commit SHA')
# Serializes actual deployments, including manually started jobs and CLI use.
with (directory / '.deploy.lock').open('a') as lock:
@@ -85,6 +109,7 @@ def deployment(directory, channel, image, commit, version, client_container=None
compose = ['docker', 'compose', '--project-name', cfg['RAWMARK_PROJECT'], '--env-file', str(directory / '.env'),
'-f', str(directory / 'compose.yml')]
run(*compose, 'config', '--quiet', env=env)
install_controls(directory)
backup = None
if previous:
run('docker', 'stop', '--time', '15', previous['Id'])
@@ -148,7 +173,7 @@ def main():
parser.add_argument('--version', required=True)
parser.add_argument('--client-container')
args = parser.parse_args()
require(args.image and args.commit, 'Development requires an image digest and commit')
require(args.image and args.commit, 'Development requires an image version tag and commit')
deployment(args.directory, args.channel, args.image, args.commit, args.version, args.client_container)
+2 -2
View File
@@ -532,7 +532,7 @@ def promote_development(local_image):
input=token, text=True, check=True)
existing = registry_digest(GITEA_DEV_IMAGE, build['version'])
if existing:
reference = GITEA_DEV_IMAGE + '@' + existing
reference = GITEA_DEV_IMAGE + ':' + build['version']
run('docker', 'pull', reference)
info = json.loads(run('docker', 'image', 'inspect', reference))[0]
labels = info['Config'].get('Labels') or {}
@@ -561,7 +561,7 @@ def promote_development(local_image):
run('docker', 'buildx', 'imagetools', 'create', '--prefer-index=false', '--tag',
GITEA_DEV_IMAGE + ':' + tag, GITEA_DEV_IMAGE + '@' + value)
require(registry_digest(GITEA_DEV_IMAGE, tag) == value, 'Development tag digest changed')
output('image', GITEA_DEV_IMAGE + '@' + value)
output('image', GITEA_DEV_IMAGE + ':' + build['version'])
output('version', build['version'])
finally:
run('docker', 'image', 'rm', candidate)
+29 -1
View File
@@ -204,7 +204,7 @@ class DeploymentTests(unittest.TestCase):
(self.directory / '.env').write_text(
f'RAWMARK_CHANNEL=development\nRAWMARK_PROJECT=rawmark-ci-deploy\n'
f'RAWMARK_BIND=127.0.0.1\nRAWMARK_PORT=21899\nRAWMARK_DATA_DIR={self.directory}/data\n')
self.image = release.GITEA_DEV_IMAGE + '@' + DIGEST
self.image = release.GITEA_DEV_IMAGE + ':0.1.1'
self.previous = {'Id': 'old-id', 'Image': 'old-image', 'State': {'Running': True},
'Mounts': [{'Destination': '/data', 'Type': 'bind', 'Source': str(self.directory / 'data')}]}
self.image_info = {'Id': 'new-image', 'Config': {'User': '65532:65532', 'Labels': {
@@ -236,6 +236,24 @@ class DeploymentTests(unittest.TestCase):
'test': {'NetworkID': 'network', 'IPAddress': '127.0.0.1'}}}}])
return ''
def test_existing_verified_build_outputs_a_tag(self):
build={'commit':SHA,'version':'0.1.1'}
info=json.dumps([{'Config':{'Labels':{'org.opencontainers.image.revision':SHA,
'org.opencontainers.image.version':'0.1.1'}}}])
with patch.object(release,'verify_bundle',return_value=build), \
patch.object(release.subprocess,'run'), patch.object(release,'registry_digest',return_value=DIGEST), \
patch.object(release,'run',return_value=info) as run, patch.object(release,'output') as output:
release.promote_development('synthetic')
output.assert_any_call('image',release.GITEA_DEV_IMAGE+':0.1.1')
self.assertEqual(run.call_args_list[0].args,('docker','pull',release.GITEA_DEV_IMAGE+':0.1.1'))
def test_digest_and_other_version_tags_cannot_deploy(self):
for image in (release.GITEA_DEV_IMAGE + '@' + DIGEST, release.GITEA_DEV_IMAGE + ':other'):
with self.subTest(image=image), patch.object(deploy, 'run') as docker:
with self.assertRaisesRegex(ValueError, 'version tag'):
deploy.deployment(self.directory, 'development', image, SHA, '0.1.1')
docker.assert_not_called()
def test_channel_mismatch_cannot_touch_docker(self):
with patch.object(deploy, 'run') as docker:
with self.assertRaisesRegex(ValueError, 'channel'):
@@ -277,6 +295,16 @@ class DeploymentTests(unittest.TestCase):
start = next(i for i,c in enumerate(self.commands) if c[:2] == ('docker', 'compose') and 'up' in c)
self.assertLess(stop, start)
self.assertIn('RAWMARK_IMAGE=' + self.image, (self.directory / '.env').read_text())
self.assertTrue((self.directory / 'start.sh').stat().st_mode & 0o111)
self.assertTrue((self.directory / 'stop.sh').stat().st_mode & 0o111)
def test_custom_control_script_is_preserved_before_stopping(self):
(self.directory / 'stop.sh').write_text('custom behavior\n')
with patch.object(deploy, 'run', side_effect=self.command):
with self.assertRaisesRegex(ValueError, 'Locally modified'):
deploy.deployment(self.directory, 'development', self.image, SHA, '0.1.1')
self.assertFalse(any(c[:2] == ('docker', 'stop') for c in self.commands))
self.assertEqual((self.directory / 'stop.sh').read_text(), 'custom behavior\n')
def test_existing_data_is_backed_up_without_a_container(self):
def without_container(*args, **kwargs):
+164
View File
@@ -0,0 +1,164 @@
"""Verify instance controls with synthetic files and no container mutations."""
import fcntl
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import deploy
SOURCES = Path(__file__).resolve().parents[2] / 'deploy'
class InstanceControlTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='rawmark controls ')
self.addCleanup(self.tmp.cleanup)
self.directory = Path(self.tmp.name)
self.bin = self.directory / 'bin'
self.bin.mkdir()
for name in ('start.sh', 'stop.sh'):
shutil.copy2(SOURCES / name, self.directory / name)
(self.directory / 'compose.yml').write_text(
'name: ${RAWMARK_PROJECT:?missing project}\nservices:\n app:\n'
' image: ${RAWMARK_IMAGE:?missing image}\n environment:\n'
' APP_SETTING: ${APP_SETTING:?missing setting}\n'
' volumes:\n - ${RAWMARK_DATA_DIR:?missing data}:/data\n')
(self.directory / '.env').write_text(
'RAWMARK_PROJECT=rawmark-controls\nRAWMARK_IMAGE=example.invalid/verified:old\n'
f'APP_SETTING=from-dotenv\nRAWMARK_DATA_DIR={self.directory}/data\n')
(self.directory / 'data').mkdir()
(self.directory / 'data/keep').write_text('persistent')
(self.bin / 'docker').write_text(f'#!{sys.executable}\n' + '''
import json
import os
from pathlib import Path
import subprocess
import sys
directory = Path(__file__).resolve().parents[1]
args = sys.argv[1:]
with (directory / 'calls.jsonl').open('a') as log:
log.write(json.dumps({'args': args, 'env': dict(os.environ)}) + '\\n')
if 'config' in args and (directory / 'real-docker').exists():
real = (directory / 'real-docker').read_text()
result = subprocess.run([real, *args[:-1], '--format', 'json'], capture_output=True, text=True)
(directory / 'resolved.json').write_text(result.stdout)
sys.stderr.write(result.stderr)
sys.exit(result.returncode)
status = directory / 'failure-code'
if 'config' not in args and (directory / 'action-failure-code').exists():
status = directory / 'action-failure-code'
sys.exit(int(status.read_text()) if status.exists() else 0)
''')
(self.bin / 'docker').chmod(0o755)
def invoke(self, name, **environment):
return subprocess.run(['bash', str(self.directory / name)], cwd='/',
env={**os.environ, 'PATH': str(self.bin) + ':' + os.environ['PATH'], **environment},
capture_output=True, text=True)
def calls(self):
path = self.directory / 'calls.jsonl'
return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []
def test_start_and_stop_use_instance_files_and_preserve_data(self):
(self.directory / 'images.env').write_text('RAWMARK_IMAGE=example.invalid/verified:new\n')
for name, action in (('start.sh', ['up', '-d', '--no-build', '--pull', 'never']),
('stop.sh', ['stop'])):
with self.subTest(name=name):
result = self.invoke(name, RAWMARK_PROJECT='other-project', RAWMARK_IMAGE='unverified',
RAWMARK_DATA_DIR='/other-data', APP_SETTING='caller',
COMPOSE_FILE='/wrong.yml', COMPOSE_PROJECT_NAME='other',
COMPOSE_PROFILES='extra', COMPOSE_DISABLE_ENV_FILE='true')
self.assertEqual(result.returncode, 0, result.stderr)
call = self.calls()[-1]
self.assertEqual(call['args'][-len(action):], action)
files = [call['args'][i + 1] for i, arg in enumerate(call['args']) if arg == '--env-file']
self.assertEqual(files, [str(self.directory / '.env'), str(self.directory / 'images.env')])
self.assertEqual(call['args'][call['args'].index('--project-directory') + 1], str(self.directory))
self.assertFalse(any(key.startswith(('RAWMARK_', 'COMPOSE_')) or key == 'APP_SETTING'
for key in call['env']))
self.assertEqual((self.directory / 'data/keep').read_text(), 'persistent')
def test_docker_client_configuration_is_preserved(self):
result = self.invoke('stop.sh', DOCKER_CONFIG='/private/docker-config')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.calls()[-1]['env']['DOCKER_CONFIG'], '/private/docker-config')
def test_pending_blocks_start_but_allows_stop(self):
(self.directory / 'deployment.pending.json').write_text('{}')
self.assertNotEqual(self.invoke('start.sh').returncode, 0)
self.assertEqual(self.calls(), [])
self.assertEqual(self.invoke('stop.sh').returncode, 0)
self.assertEqual(self.calls()[-1]['args'][-1], 'stop')
def test_lock_blocks_both_controls(self):
with (self.directory / '.deploy.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
for name in ('start.sh', 'stop.sh'):
self.assertNotEqual(self.invoke(name).returncode, 0)
self.assertEqual(self.calls(), [])
def test_invalid_compose_count_rejects_before_docker(self):
(self.directory / 'compose.yaml').write_text('services: {}')
for name in ('start.sh', 'stop.sh'):
self.assertNotEqual(self.invoke(name).returncode, 0)
self.assertEqual(self.calls(), [])
def test_config_failure_prevents_action_and_propagates_status(self):
(self.directory / 'failure-code').write_text('17')
for name in ('start.sh', 'stop.sh'):
result = self.invoke(name)
self.assertEqual(result.returncode, 17)
self.assertEqual(self.calls()[-1]['args'][-2:], ['config', '--quiet'])
def test_action_failure_propagates_without_success_message(self):
(self.directory / 'action-failure-code').write_text('19')
for name in ('start.sh', 'stop.sh'):
result = self.invoke(name)
self.assertEqual(result.returncode, 19)
self.assertEqual(result.stdout, '')
def test_real_compose_loads_dotenv_and_image_override_without_caller_overrides(self):
docker = shutil.which('docker')
if not docker or subprocess.run([docker, 'compose', 'version'], capture_output=True).returncode:
self.skipTest('Docker Compose is not installed')
(self.directory / 'real-docker').write_text(docker)
(self.directory / 'images.env').write_text('RAWMARK_IMAGE=example.invalid/verified:new\n')
for name in ('start.sh', 'stop.sh'):
result = self.invoke(name, RAWMARK_PROJECT='wrong', RAWMARK_IMAGE='wrong',
RAWMARK_DATA_DIR='/wrong', APP_SETTING='wrong')
self.assertEqual(result.returncode, 0, result.stderr)
resolved = json.loads((self.directory / 'resolved.json').read_text())
self.assertEqual(resolved['name'], 'rawmark-controls')
self.assertEqual(resolved['services']['app']['image'], 'example.invalid/verified:new')
self.assertEqual(resolved['services']['app']['environment']['APP_SETTING'], 'from-dotenv')
self.assertEqual(resolved['services']['app']['volumes'][0]['source'], str(self.directory / 'data'))
class ControlInstallationTests(unittest.TestCase):
def test_fresh_install_and_update_preserve_local_edits(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
deploy.install_controls(directory)
for name in ('start.sh', 'stop.sh'):
self.assertEqual((directory / name).stat().st_mode & 0o777, 0o755)
# A previously managed version can be updated using its recorded digest.
(directory / 'start.sh').write_text('previous managed version')
receipt = json.loads((directory / '.control-scripts.json').read_text())
receipt['start.sh'] = hashlib.sha256((directory / 'start.sh').read_bytes()).hexdigest()
(directory / '.control-scripts.json').write_text(json.dumps(receipt))
deploy.install_controls(directory)
self.assertEqual((directory / 'start.sh').read_bytes(), (SOURCES / 'start.sh').read_bytes())
(directory / 'stop.sh').write_text('custom user behavior')
with self.assertRaisesRegex(ValueError, 'Locally modified'):
deploy.install_controls(directory)
self.assertEqual((directory / 'stop.sh').read_text(), 'custom user behavior')