ci: add per-image jobs to homelab CI
ci / Compose (push) Skipped
ci / Workflows (push) Skipped
ci / Shell (push) Skipped
ci / Formatting (push) Skipped
ci / Python and tests (push) Skipped
ci / YAML (push) Skipped
ci / Dockerfiles (push) Skipped
ci / Kubernetes (push) Skipped
ci / Compose (pull_request) Successful in 11s
ci / Workflows (pull_request) Successful in 7s
ci / Shell (pull_request) Successful in 15s
ci / Formatting (pull_request) Successful in 15s
ci / Python and tests (pull_request) Successful in 5s
ci / YAML (pull_request) Successful in 7s
ci / Dockerfiles (pull_request) Successful in 5s
ci / Kubernetes (pull_request) Successful in 6s
ci / image-plan (pull_request) Skipped
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped
renovate-ci / validate-renovate (pull_request) Successful in 11s
ci / Compose (push) Skipped
ci / Workflows (push) Skipped
ci / Shell (push) Skipped
ci / Formatting (push) Skipped
ci / Python and tests (push) Skipped
ci / YAML (push) Skipped
ci / Dockerfiles (push) Skipped
ci / Kubernetes (push) Skipped
ci / Compose (pull_request) Successful in 11s
ci / Workflows (pull_request) Successful in 7s
ci / Shell (pull_request) Successful in 15s
ci / Formatting (pull_request) Successful in 15s
ci / Python and tests (pull_request) Successful in 5s
ci / YAML (pull_request) Successful in 7s
ci / Dockerfiles (pull_request) Successful in 5s
ci / Kubernetes (pull_request) Successful in 6s
ci / image-plan (pull_request) Skipped
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped
renovate-ci / validate-renovate (pull_request) Successful in 11s
This commit is contained in:
1 parent
1d93588e06
commit
95e4d8f875
4 files changed
+390
-61
No files matched your search
+118
-24
@@ -375,48 +375,142 @@ jobs:
|
|||||||
elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||||
printf '## %s\n\n- Result: **%s**\n- Failed step: %s\n' "$SUMMARY_CHECK" "$SUMMARY_RESULT" "$SUMMARY_FAILED_STEP" >>"$GITHUB_STEP_SUMMARY" || true
|
printf '## %s\n\n- Result: **%s**\n- Failed step: %s\n' "$SUMMARY_CHECK" "$SUMMARY_RESULT" "$SUMMARY_FAILED_STEP" >>"$GITHUB_STEP_SUMMARY" || true
|
||||||
fi
|
fi
|
||||||
build:
|
image-plan:
|
||||||
needs:
|
needs: [compose, workflows, shell, formatting, python, yaml, dockerfiles, kubernetes]
|
||||||
- compose
|
|
||||||
- workflows
|
|
||||||
- shell
|
|
||||||
- formatting
|
|
||||||
- python
|
|
||||||
- yaml
|
|
||||||
- dockerfiles
|
|
||||||
- kubernetes
|
|
||||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||||
runs-on: homelab
|
runs-on: homelab
|
||||||
timeout-minutes: 60
|
timeout-minutes: 10
|
||||||
|
outputs:
|
||||||
|
matrix: ${{ steps.plan.outputs.matrix }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
id: source
|
||||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
id: source
|
- name: Detect build inputs against successful CI
|
||||||
- name: Build changed images and write release
|
id: plan
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ github.token }}
|
GITEA_TOKEN: ${{ github.token }}
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
run: python3 .gitea/workflows/release.py prepare --output build-plan.json
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
- name: Store the image plan
|
||||||
run: python3 .gitea/workflows/release.py build
|
id: artifact
|
||||||
id: check
|
|
||||||
- name: Store commit release
|
|
||||||
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf
|
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf
|
||||||
with:
|
with:
|
||||||
name: release-${{ github.sha }}
|
name: build-plan
|
||||||
path: release.json
|
path: build-plan.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Write the plan result
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
SUMMARY_CHECK: Image plan
|
||||||
|
SUMMARY_RESULT: ${{ job.status }}
|
||||||
|
SUMMARY_FAILED_STEP: >-
|
||||||
|
${{ steps.plan.conclusion == 'failure' && 'Build input detection' ||
|
||||||
|
steps.artifact.conclusion == 'failure' && 'Plan upload' ||
|
||||||
|
steps.source.conclusion == 'failure' && 'Source checkout' || '' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ -f .gitea/workflows/release.py ]; then
|
||||||
|
python3 .gitea/workflows/release.py check-summary
|
||||||
|
elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||||
|
printf '## Image plan\n\nResult: %s\n' "$SUMMARY_RESULT" >>"$GITHUB_STEP_SUMMARY" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
images:
|
||||||
|
name: Image (${{ matrix.name }})
|
||||||
|
needs: [image-plan]
|
||||||
|
if: needs.image-plan.result == 'success'
|
||||||
|
runs-on: homelab
|
||||||
|
timeout-minutes: 60
|
||||||
|
strategy:
|
||||||
|
max-parallel: 1
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJSON(needs.image-plan.outputs.matrix || '{"include":[{"name":"inactive"}]}') }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
id: source
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||||
|
- name: Download the checked image plan
|
||||||
|
id: inputs
|
||||||
|
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
|
||||||
|
with:
|
||||||
|
name: build-plan
|
||||||
|
- name: Build or reuse this image
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ${{ matrix.name }}
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: python3 .gitea/workflows/release.py image --image "$IMAGE_NAME" --output image.json
|
||||||
|
- name: Store the image result
|
||||||
id: artifact
|
id: artifact
|
||||||
|
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf
|
||||||
|
with:
|
||||||
|
name: image-${{ matrix.name }}
|
||||||
|
path: image.json
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
- name: Write the job result
|
- name: Write the job result
|
||||||
if: always()
|
if: always()
|
||||||
env:
|
env:
|
||||||
SUMMARY_CHECK: Image build and release artifact
|
SUMMARY_CHECK: Image (${{ matrix.name }})
|
||||||
SUMMARY_RESULT: ${{ job.status }}
|
SUMMARY_RESULT: ${{ job.status }}
|
||||||
SUMMARY_FAILED_STEP:
|
SUMMARY_FAILED_STEP: >-
|
||||||
${{ steps.check.conclusion == 'failure' && 'Check or image build' || steps.artifact.conclusion == 'failure'
|
${{ steps.check.conclusion == 'failure' && 'Build or tag images' ||
|
||||||
&& 'Release artifact upload' || steps.source.conclusion == 'failure' && 'Source checkout' || '' }}
|
steps.artifact.conclusion == 'failure' && 'Artifact upload' ||
|
||||||
|
steps.inputs.conclusion == 'failure' && 'Artifact download' ||
|
||||||
|
steps.source.conclusion == 'failure' && 'Source checkout' || '' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ -f .gitea/workflows/release.py ]; then
|
||||||
|
python3 .gitea/workflows/release.py check-summary
|
||||||
|
elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||||
|
printf '## %s\n\n- Result: **%s**\n- Failed step: %s\n' "$SUMMARY_CHECK" "$SUMMARY_RESULT" "$SUMMARY_FAILED_STEP" >>"$GITHUB_STEP_SUMMARY" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Retain the build job name required by the immutable release deployment gate.
|
||||||
|
build:
|
||||||
|
needs: [image-plan, images]
|
||||||
|
runs-on: homelab
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
id: source
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||||
|
- name: Download all image results
|
||||||
|
id: inputs
|
||||||
|
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
- name: Pin SHA tags and write the complete release
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: >-
|
||||||
|
python3 .gitea/workflows/release.py finalize
|
||||||
|
--plan artifacts/build-plan/build-plan.json
|
||||||
|
- name: Store commit release
|
||||||
|
id: artifact
|
||||||
|
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf
|
||||||
|
with:
|
||||||
|
name: release-${{ github.sha }}
|
||||||
|
path: release.json
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
- name: Write the job result
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
SUMMARY_CHECK: Image release and SHA tags
|
||||||
|
SUMMARY_RESULT: ${{ job.status }}
|
||||||
|
SUMMARY_FAILED_STEP: >-
|
||||||
|
${{ steps.check.conclusion == 'failure' && 'Build or tag images' ||
|
||||||
|
steps.artifact.conclusion == 'failure' && 'Artifact upload' ||
|
||||||
|
steps.inputs.conclusion == 'failure' && 'Artifact download' ||
|
||||||
|
steps.source.conclusion == 'failure' && 'Source checkout' || '' }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [ -f .gitea/workflows/release.py ]; then
|
if [ -f .gitea/workflows/release.py ]; then
|
||||||
|
|||||||
+121
-20
@@ -26,9 +26,6 @@ IMAGES = {
|
|||||||
'xdfnx-homepage': ('homepages', 'homepages/Dockerfile.xdfnx'),
|
'xdfnx-homepage': ('homepages', 'homepages/Dockerfile.xdfnx'),
|
||||||
}
|
}
|
||||||
|
|
||||||
# These images are released by the EDU application repository.
|
|
||||||
EXTERNAL_IMAGES = {'gcr.forust.xyz/forust/session-keeper', 'gcr.forust.xyz/forust/webinar-checker'}
|
|
||||||
|
|
||||||
|
|
||||||
def command(*args, **kwargs):
|
def command(*args, **kwargs):
|
||||||
"""Arguments are passed directly to the executable, never to a shell."""
|
"""Arguments are passed directly to the executable, never to a shell."""
|
||||||
@@ -163,11 +160,10 @@ def gate(output, requested_ref, event_sha):
|
|||||||
print(f'CI gate accepted {sha}')
|
print(f'CI gate accepted {sha}')
|
||||||
|
|
||||||
|
|
||||||
def build_images(output, report):
|
def prepare_images(output):
|
||||||
sha = command('git', 'rev-parse', 'HEAD')
|
sha = command('git', 'rev-parse', 'HEAD')
|
||||||
if sha != os.environ['GITHUB_SHA'] or not SHA.fullmatch(sha):
|
if sha != os.environ['GITHUB_SHA'] or not SHA.fullmatch(sha):
|
||||||
raise ValueError('Build checkout does not match GITHUB_SHA')
|
raise ValueError('Build checkout does not match GITHUB_SHA')
|
||||||
report['phase'] = 'Find a successful CI release'
|
|
||||||
api = Gitea()
|
api = Gitea()
|
||||||
previous = None
|
previous = None
|
||||||
for run in sorted(itertools.islice(api.successful_runs(), 50), key=lambda item: item['id'], reverse=True):
|
for run in sorted(itertools.islice(api.successful_runs(), 50), key=lambda item: item['id'], reverse=True):
|
||||||
@@ -179,6 +175,55 @@ def build_images(output, report):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
# Expired artifacts only cost a rebuild; mutable tags are never a fallback.
|
# Expired artifacts only cost a rebuild; mutable tags are never a fallback.
|
||||||
continue
|
continue
|
||||||
|
targets = []
|
||||||
|
for name, (context, dockerfile) in IMAGES.items():
|
||||||
|
image = f'gcr.forust.xyz/forust/{name}'
|
||||||
|
inputs = fingerprint(context, dockerfile)
|
||||||
|
old_digest = (previous or {}).get('images', {}).get(image)
|
||||||
|
targets.append(
|
||||||
|
{
|
||||||
|
'name': name,
|
||||||
|
'image': image,
|
||||||
|
'context': context,
|
||||||
|
'dockerfile': dockerfile,
|
||||||
|
'inputs': inputs,
|
||||||
|
'reuse_digest': old_digest if (previous or {}).get('inputs', {}).get(image) == inputs else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
output.write_text(json.dumps({'sha': sha, 'targets': targets}, indent=2) + '\n')
|
||||||
|
if os.environ.get('GITHUB_OUTPUT'):
|
||||||
|
with Path(os.environ['GITHUB_OUTPUT']).open('a') as stream:
|
||||||
|
stream.write('matrix=' + json.dumps({'include': targets}, separators=(',', ':')) + '\n')
|
||||||
|
print(f'Prepared {len(targets)} image jobs; {sum(t["reuse_digest"] is None for t in targets)} require builds')
|
||||||
|
|
||||||
|
|
||||||
|
def checked_plan(path):
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
sha = command('git', 'rev-parse', 'HEAD')
|
||||||
|
if data.get('sha') != sha or sha != os.environ['GITHUB_SHA'] or not SHA.fullmatch(sha):
|
||||||
|
raise ValueError('Image plan does not match the checked source commit')
|
||||||
|
targets = data.get('targets', [])
|
||||||
|
if sorted(t['name'] for t in targets) != sorted(IMAGES):
|
||||||
|
raise ValueError('Image plan must contain each owned image once')
|
||||||
|
for target in targets:
|
||||||
|
name = target['name']
|
||||||
|
context, dockerfile = IMAGES[name]
|
||||||
|
if (target['context'], target['dockerfile'], target['image']) != (
|
||||||
|
context,
|
||||||
|
dockerfile,
|
||||||
|
f'gcr.forust.xyz/forust/{name}',
|
||||||
|
) or target['inputs'] != fingerprint(context, dockerfile):
|
||||||
|
raise ValueError('Image plan has invalid build inputs')
|
||||||
|
if target['reuse_digest'] is not None and not DIGEST.fullmatch(target['reuse_digest']):
|
||||||
|
raise ValueError('Image plan has an invalid reuse digest')
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def build_images(output, report, name, plan):
|
||||||
|
data = checked_plan(plan)
|
||||||
|
sha = data['sha']
|
||||||
|
target = next(t for t in data['targets'] if t['name'] == name)
|
||||||
|
context, dockerfile = IMAGES[name]
|
||||||
docker_config = tempfile.mkdtemp(prefix='homelab-registry-')
|
docker_config = tempfile.mkdtemp(prefix='homelab-registry-')
|
||||||
builder_config = Path.home() / '.cache/homelab-ci/buildx'
|
builder_config = Path.home() / '.cache/homelab-ci/buildx'
|
||||||
builder_config.mkdir(parents=True, exist_ok=True)
|
builder_config.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -235,14 +280,13 @@ def build_images(output, report):
|
|||||||
signature.write_text(image + '\n')
|
signature.write_text(image + '\n')
|
||||||
release = {'version': 1, 'sha': sha, 'images': {}, 'inputs': {}}
|
release = {'version': 1, 'sha': sha, 'images': {}, 'inputs': {}}
|
||||||
report['images'] = release['images']
|
report['images'] = release['images']
|
||||||
for name, (context, dockerfile) in IMAGES.items():
|
|
||||||
report['phase'] = f'Build or reuse {name}'
|
report['phase'] = f'Build or reuse {name}'
|
||||||
report['current'] = name
|
report['current'] = name
|
||||||
image = f'gcr.forust.xyz/forust/{name}'
|
image = f'gcr.forust.xyz/forust/{name}'
|
||||||
inputs = fingerprint(context, dockerfile)
|
inputs = target['inputs']
|
||||||
old_digest = (previous or {}).get('images', {}).get(image)
|
old_digest = target['reuse_digest']
|
||||||
exists = False
|
exists = False
|
||||||
if old_digest and previous['inputs'].get(image) == inputs:
|
if old_digest:
|
||||||
exists = (
|
exists = (
|
||||||
subprocess.run( # noqa: S603, S607
|
subprocess.run( # noqa: S603, S607
|
||||||
[
|
[
|
||||||
@@ -271,7 +315,6 @@ def build_images(output, report):
|
|||||||
'build',
|
'build',
|
||||||
'--builder',
|
'--builder',
|
||||||
builder,
|
builder,
|
||||||
'--push',
|
|
||||||
'--platform',
|
'--platform',
|
||||||
'linux/amd64',
|
'linux/amd64',
|
||||||
'--provenance=false',
|
'--provenance=false',
|
||||||
@@ -279,8 +322,8 @@ def build_images(output, report):
|
|||||||
f'type=registry,ref={image}:buildcache',
|
f'type=registry,ref={image}:buildcache',
|
||||||
'--cache-to',
|
'--cache-to',
|
||||||
f'type=registry,ref={image}:buildcache,mode=max',
|
f'type=registry,ref={image}:buildcache,mode=max',
|
||||||
'--tag',
|
'--output',
|
||||||
f'{image}:sha-{sha}',
|
f'type=image,name={image},push-by-digest=true,name-canonical=true,push=true',
|
||||||
'--metadata-file',
|
'--metadata-file',
|
||||||
str(metadata),
|
str(metadata),
|
||||||
'--file',
|
'--file',
|
||||||
@@ -292,7 +335,8 @@ def build_images(output, report):
|
|||||||
report['built'].append(name)
|
report['built'].append(name)
|
||||||
release['images'][image] = digest
|
release['images'][image] = digest
|
||||||
release['inputs'][image] = inputs
|
release['inputs'][image] = inputs
|
||||||
validate_release(release, sha)
|
if not DIGEST.fullmatch(digest):
|
||||||
|
raise ValueError('Image job returned an invalid digest')
|
||||||
output.write_text(json.dumps(release, indent=2) + '\n')
|
output.write_text(json.dumps(release, indent=2) + '\n')
|
||||||
report['current'] = None
|
report['current'] = None
|
||||||
report['phase'] = 'Release file saved'
|
report['phase'] = 'Release file saved'
|
||||||
@@ -343,11 +387,11 @@ def check_summary():
|
|||||||
write_summary(lines)
|
write_summary(lines)
|
||||||
|
|
||||||
|
|
||||||
def build(output):
|
def build(output, name, plan):
|
||||||
report = {'phase': 'Check the source commit', 'current': None, 'built': [], 'reused': [], 'images': {}}
|
report = {'phase': 'Check the source commit', 'current': None, 'built': [], 'reused': [], 'images': {}}
|
||||||
result = 'failure'
|
result = 'failure'
|
||||||
try:
|
try:
|
||||||
build_images(output, report)
|
build_images(output, report, name, plan)
|
||||||
result = 'success'
|
result = 'success'
|
||||||
finally:
|
finally:
|
||||||
lines = [
|
lines = [
|
||||||
@@ -382,9 +426,6 @@ def render(stream, destination):
|
|||||||
match = image_line.fullmatch(line.rstrip('\n'))
|
match = image_line.fullmatch(line.rstrip('\n'))
|
||||||
if match:
|
if match:
|
||||||
prefix, quote, image, tail = match.groups()
|
prefix, quote, image, tail = match.groups()
|
||||||
if image in EXTERNAL_IMAGES and f'{image}@sha256:' in line:
|
|
||||||
rendered.append(line)
|
|
||||||
continue
|
|
||||||
if image not in release['images']:
|
if image not in release['images']:
|
||||||
raise ValueError(f'Owned image missing from checked release: {image}')
|
raise ValueError(f'Owned image missing from checked release: {image}')
|
||||||
line = f'{prefix}{quote}{image}@{release["images"][image]}{quote}{tail}\n'
|
line = f'{prefix}{quote}{image}@{release["images"][image]}{quote}{tail}\n'
|
||||||
@@ -394,12 +435,66 @@ def render(stream, destination):
|
|||||||
destination.writelines(rendered)
|
destination.writelines(rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_images(output, fragments, plan):
|
||||||
|
data = checked_plan(plan)
|
||||||
|
sha = data['sha']
|
||||||
|
release = {'version': 1, 'sha': sha, 'images': {}, 'inputs': {}}
|
||||||
|
for name in IMAGES:
|
||||||
|
fragment = json.loads((fragments / f'image-{name}' / 'image.json').read_text())
|
||||||
|
image = f'gcr.forust.xyz/forust/{name}'
|
||||||
|
if fragment.get('sha') != sha or fragment.get('version') != 1 or set(fragment.get('images', {})) != {image}:
|
||||||
|
raise ValueError('Image job artifact is missing or belongs to another commit')
|
||||||
|
target = next(t for t in data['targets'] if t['name'] == name)
|
||||||
|
if fragment.get('inputs') != {image: target['inputs']}:
|
||||||
|
raise ValueError('Image artifact does not match the build plan')
|
||||||
|
release['images'].update(fragment['images'])
|
||||||
|
release['inputs'].update(fragment['inputs'])
|
||||||
|
validate_release(release, sha)
|
||||||
|
# Only a complete set of successful image jobs can publish the release tags.
|
||||||
|
docker_config = tempfile.mkdtemp(prefix='homelab-registry-')
|
||||||
|
env = {**os.environ, 'DOCKER_CONFIG': docker_config}
|
||||||
|
try:
|
||||||
|
subprocess.run( # noqa: S603, S607
|
||||||
|
[
|
||||||
|
shutil.which('docker') or '/usr/bin/docker',
|
||||||
|
'login',
|
||||||
|
'gcr.forust.xyz',
|
||||||
|
'-u',
|
||||||
|
os.environ['REGISTRY_USERNAME'],
|
||||||
|
'--password-stdin',
|
||||||
|
],
|
||||||
|
input=os.environ['REGISTRY_PASSWORD'],
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
for image, digest in release['images'].items():
|
||||||
|
command(
|
||||||
|
'docker',
|
||||||
|
'buildx',
|
||||||
|
'imagetools',
|
||||||
|
'create',
|
||||||
|
'--prefer-index=false',
|
||||||
|
'--tag',
|
||||||
|
f'{image}:sha-{sha}',
|
||||||
|
f'{image}@{digest}',
|
||||||
|
env=env,
|
||||||
|
timeout=90,
|
||||||
|
)
|
||||||
|
output.write_text(json.dumps(release, indent=2) + '\n')
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(docker_config)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument('action', choices=('build', 'gate', 'render', 'check-summary'))
|
parser.add_argument('action', choices=('prepare', 'image', 'finalize', 'gate', 'render', 'check-summary'))
|
||||||
parser.add_argument('--output', type=Path, default=Path('release.json'))
|
parser.add_argument('--output', type=Path, default=Path('release.json'))
|
||||||
parser.add_argument('--ref', default='main')
|
parser.add_argument('--ref', default='main')
|
||||||
parser.add_argument('--event-sha', default='')
|
parser.add_argument('--event-sha', default='')
|
||||||
|
parser.add_argument('--image', choices=IMAGES)
|
||||||
|
parser.add_argument('--plan', type=Path, default=Path('build-plan.json'))
|
||||||
|
parser.add_argument('--fragments', type=Path, default=Path('artifacts'))
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.action == 'check-summary':
|
if args.action == 'check-summary':
|
||||||
check_summary()
|
check_summary()
|
||||||
@@ -407,8 +502,14 @@ def main():
|
|||||||
render(sys.stdin, sys.stdout)
|
render(sys.stdin, sys.stdout)
|
||||||
elif args.action == 'gate':
|
elif args.action == 'gate':
|
||||||
gate(args.output, args.ref, args.event_sha)
|
gate(args.output, args.ref, args.event_sha)
|
||||||
|
elif args.action == 'prepare':
|
||||||
|
prepare_images(args.output)
|
||||||
|
elif args.action == 'image':
|
||||||
|
if not args.image:
|
||||||
|
parser.error('--image is required')
|
||||||
|
build(args.output, args.image, args.plan)
|
||||||
else:
|
else:
|
||||||
build(args.output)
|
finalize_images(args.output, args.fragments, args.plan)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -40,19 +40,6 @@ class ArtifactTests(unittest.TestCase):
|
|||||||
release_module.render(io.StringIO(f' image: "{image}:prod" # note\n'), result)
|
release_module.render(io.StringIO(f' image: "{image}:prod" # note\n'), result)
|
||||||
self.assertEqual(result.getvalue(), f' image: "{image}@sha256:{"b" * 64}" # note\n')
|
self.assertEqual(result.getvalue(), f' image: "{image}@sha256:{"b" * 64}" # note\n')
|
||||||
|
|
||||||
def test_edu_release_digest_is_preserved(self):
|
|
||||||
with tempfile.TemporaryDirectory() as scratch:
|
|
||||||
path = Path(scratch) / 'release.json'
|
|
||||||
path.write_text(json.dumps(release()))
|
|
||||||
image = 'gcr.forust.xyz/forust/session-keeper'
|
|
||||||
line = f'image: {image}@sha256:{"e" * 64}\n'
|
|
||||||
result = io.StringIO()
|
|
||||||
with patch.dict(os.environ, {'RELEASE_FILE': str(path), 'DEPLOY_SHA': 'a' * 40}):
|
|
||||||
release_module.render(io.StringIO(line), result)
|
|
||||||
self.assertEqual(result.getvalue(), line)
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
release_module.render(io.StringIO(f'image: {image}:prod\n'), io.StringIO())
|
|
||||||
|
|
||||||
def test_unknown_image_cannot_emit_partial_manifest(self):
|
def test_unknown_image_cannot_emit_partial_manifest(self):
|
||||||
with tempfile.TemporaryDirectory() as scratch:
|
with tempfile.TemporaryDirectory() as scratch:
|
||||||
path = Path(scratch) / 'release.json'
|
path = Path(scratch) / 'release.json'
|
||||||
@@ -107,10 +94,13 @@ class ArtifactTests(unittest.TestCase):
|
|||||||
patch.object(release_module, 'command', side_effect=fake_command),
|
patch.object(release_module, 'command', side_effect=fake_command),
|
||||||
patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 0)),
|
patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 0)),
|
||||||
):
|
):
|
||||||
release_module.build(root / 'release.json')
|
plan = root / 'plan.json'
|
||||||
|
release_module.prepare_images(plan)
|
||||||
|
for name in release_module.IMAGES:
|
||||||
|
release_module.build(root / f'{name}.json', name, plan)
|
||||||
self.assertEqual(built, ['errorpages/Dockerfile'])
|
self.assertEqual(built, ['errorpages/Dockerfile'])
|
||||||
self.assertTrue(all(not directory.exists() for directory in auth_directories))
|
self.assertTrue(all(not directory.exists() for directory in auth_directories))
|
||||||
self.assertEqual(json.loads((root / 'release.json').read_text())['sha'], 'e' * 40)
|
self.assertEqual(json.loads((root / 'error-pages.json').read_text())['sha'], 'e' * 40)
|
||||||
|
|
||||||
|
|
||||||
class DurableRunTests(unittest.TestCase):
|
class DurableRunTests(unittest.TestCase):
|
||||||
@@ -218,7 +208,7 @@ class FailureSummaryTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as scratch:
|
with tempfile.TemporaryDirectory() as scratch:
|
||||||
summary = Path(scratch) / 'summary.md'
|
summary = Path(scratch) / 'summary.md'
|
||||||
|
|
||||||
def failed_build(_output, report):
|
def failed_build(_output, report, _name, _plan):
|
||||||
report.update(phase='Build or reuse xdfnx-homepage', current='xdfnx-homepage', built=['error-pages'])
|
report.update(phase='Build or reuse xdfnx-homepage', current='xdfnx-homepage', built=['error-pages'])
|
||||||
report['images']['gcr.forust.xyz/forust/error-pages'] = 'sha256:' + 'b' * 64
|
report['images']['gcr.forust.xyz/forust/error-pages'] = 'sha256:' + 'b' * 64
|
||||||
raise RuntimeError('private value must not appear in the summary')
|
raise RuntimeError('private value must not appear in the summary')
|
||||||
@@ -228,7 +218,7 @@ class FailureSummaryTests(unittest.TestCase):
|
|||||||
patch.object(release_module, 'build_images', side_effect=failed_build),
|
patch.object(release_module, 'build_images', side_effect=failed_build),
|
||||||
self.assertRaises(RuntimeError),
|
self.assertRaises(RuntimeError),
|
||||||
):
|
):
|
||||||
release_module.build(Path(scratch) / 'release.json')
|
release_module.build(Path(scratch) / 'release.json', 'xdfnx-homepage', Path('plan.json'))
|
||||||
content = summary.read_text()
|
content = summary.read_text()
|
||||||
self.assertIn('**failure**', content)
|
self.assertIn('**failure**', content)
|
||||||
self.assertIn('error-pages', content)
|
self.assertIn('error-pages', content)
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Matrix release contracts and failure gates without a registry."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, call, patch
|
||||||
|
|
||||||
|
from test_cicd import release, release_module
|
||||||
|
|
||||||
|
|
||||||
|
def plan_data(changed):
|
||||||
|
targets = []
|
||||||
|
for name, (context, dockerfile) in release_module.IMAGES.items():
|
||||||
|
targets.append(
|
||||||
|
{
|
||||||
|
'name': name,
|
||||||
|
'image': f'gcr.forust.xyz/forust/{name}',
|
||||||
|
'context': context,
|
||||||
|
'dockerfile': dockerfile,
|
||||||
|
'inputs': ('d' if name in changed else 'c') * 64,
|
||||||
|
'reuse_digest': None if name in changed else 'sha256:' + 'b' * 64,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {'sha': 'a' * 40, 'targets': targets}
|
||||||
|
|
||||||
|
|
||||||
|
class MatrixTests(unittest.TestCase):
|
||||||
|
def test_no_change_one_image_all_images_and_missing_baseline(self):
|
||||||
|
for changed in (set(), {'error-pages'}, set(release_module.IMAGES)):
|
||||||
|
with self.subTest(changed=changed), tempfile.TemporaryDirectory() as scratch:
|
||||||
|
api = Mock()
|
||||||
|
api.successful_runs.return_value = iter([{'id': 1}])
|
||||||
|
api.release.return_value = release()
|
||||||
|
expected = plan_data(changed)
|
||||||
|
fingerprints = {t['dockerfile']: t['inputs'] for t in expected['targets']}
|
||||||
|
output = Path(scratch) / 'plan.json'
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {'GITHUB_SHA': 'a' * 40, 'GITHUB_RUN_ID': '2'}),
|
||||||
|
patch.object(release_module, 'command', return_value='a' * 40),
|
||||||
|
patch.object(release_module, 'Gitea', return_value=api),
|
||||||
|
patch.object(
|
||||||
|
release_module, 'fingerprint', side_effect=lambda _c, f, mapping=fingerprints: mapping[f]
|
||||||
|
),
|
||||||
|
):
|
||||||
|
release_module.prepare_images(output)
|
||||||
|
self.assertEqual(json.loads(output.read_text()), expected)
|
||||||
|
api.successful_runs.return_value = iter([])
|
||||||
|
with (
|
||||||
|
tempfile.TemporaryDirectory() as scratch,
|
||||||
|
patch.dict(os.environ, {'GITHUB_SHA': 'a' * 40}),
|
||||||
|
patch.object(release_module, 'command', return_value='a' * 40),
|
||||||
|
patch.object(release_module, 'Gitea', return_value=api),
|
||||||
|
patch.object(release_module, 'fingerprint', return_value='c' * 64),
|
||||||
|
):
|
||||||
|
output = Path(scratch) / 'plan.json'
|
||||||
|
release_module.prepare_images(output)
|
||||||
|
self.assertTrue(all(t['reuse_digest'] is None for t in json.loads(output.read_text())['targets']))
|
||||||
|
|
||||||
|
def test_incomplete_or_wrong_sha_fragments_cannot_publish_tags(self):
|
||||||
|
for wrong_sha in (False, True):
|
||||||
|
with self.subTest(wrong_sha=wrong_sha), tempfile.TemporaryDirectory() as scratch:
|
||||||
|
root = Path(scratch)
|
||||||
|
plan = root / 'plan.json'
|
||||||
|
plan.write_text(json.dumps(plan_data(set())))
|
||||||
|
for name in release_module.IMAGES:
|
||||||
|
if name == 'xdfnx-homepage' and not wrong_sha:
|
||||||
|
continue
|
||||||
|
folder = root / f'image-{name}'
|
||||||
|
folder.mkdir()
|
||||||
|
image = f'gcr.forust.xyz/forust/{name}'
|
||||||
|
folder.joinpath('image.json').write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
'version': 1,
|
||||||
|
'sha': ('e' if wrong_sha else 'a') * 40,
|
||||||
|
'images': {image: 'sha256:' + 'b' * 64},
|
||||||
|
'inputs': {image: 'c' * 64},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(release_module, 'checked_plan', return_value=plan_data(set())),
|
||||||
|
patch.object(release_module.subprocess, 'run') as execute,
|
||||||
|
self.assertRaises((ValueError, FileNotFoundError)),
|
||||||
|
):
|
||||||
|
release_module.finalize_images(root / 'release.json', root, plan)
|
||||||
|
execute.assert_not_called()
|
||||||
|
self.assertFalse((root / 'release.json').exists())
|
||||||
|
|
||||||
|
def test_manifest_only_release_pins_each_successful_digest(self):
|
||||||
|
with tempfile.TemporaryDirectory() as scratch:
|
||||||
|
root = Path(scratch)
|
||||||
|
data = plan_data(set())
|
||||||
|
for target in data['targets']:
|
||||||
|
folder = root / f'image-{target["name"]}'
|
||||||
|
folder.mkdir()
|
||||||
|
image = target['image']
|
||||||
|
folder.joinpath('image.json').write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
'version': 1,
|
||||||
|
'sha': data['sha'],
|
||||||
|
'images': {image: target['reuse_digest']},
|
||||||
|
'inputs': {image: target['inputs']},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(release_module, 'checked_plan', return_value=data),
|
||||||
|
patch.dict(os.environ, {'REGISTRY_USERNAME': 'test', 'REGISTRY_PASSWORD': 'placeholder'}),
|
||||||
|
patch.object(release_module.subprocess, 'run'),
|
||||||
|
patch.object(release_module, 'command') as execute,
|
||||||
|
):
|
||||||
|
release_module.finalize_images(root / 'release.json', root, root / 'plan.json')
|
||||||
|
self.assertEqual(
|
||||||
|
[entry.args for entry in execute.call_args_list],
|
||||||
|
[
|
||||||
|
call(
|
||||||
|
'docker',
|
||||||
|
'buildx',
|
||||||
|
'imagetools',
|
||||||
|
'create',
|
||||||
|
'--prefer-index=false',
|
||||||
|
'--tag',
|
||||||
|
f'{t["image"]}:sha-{data["sha"]}',
|
||||||
|
f'{t["image"]}@{t["reuse_digest"]}',
|
||||||
|
).args
|
||||||
|
for t in data['targets']
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(json.loads((root / 'release.json').read_text()), release())
|
||||||
|
|
||||||
|
def test_checkout_mismatch_cannot_build(self):
|
||||||
|
with tempfile.TemporaryDirectory() as scratch:
|
||||||
|
plan = Path(scratch) / 'plan.json'
|
||||||
|
plan.write_text(json.dumps(plan_data(set())))
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {'GITHUB_SHA': 'e' * 40}),
|
||||||
|
patch.object(release_module, 'command', return_value='a' * 40),
|
||||||
|
self.assertRaisesRegex(ValueError, 'source commit'),
|
||||||
|
):
|
||||||
|
release_module.checked_plan(plan)
|
||||||
Reference in new issue
Block a user