ci: migrate workflows and automation to Gitea Actions
This commit is contained in:
1 parent
22a9351de7
commit
3893281fa1
56 files changed
+17076
-3726
No files matched your search
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE="${1:-}"
|
||||
TARGET_COMMIT="${2:-}"
|
||||
ENDPOINT="${3:-https://${SERVICE}.ermnvldmr.com/version.json}"
|
||||
|
||||
if [ -z "$SERVICE" ] || [ -z "$TARGET_COMMIT" ]; then
|
||||
echo "Usage: $0 <service: www|docs> <target_commit> [endpoint_url]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "========================================================"
|
||||
echo "Verifying production deployment on $ENDPOINT"
|
||||
echo "Expected commit: $TARGET_COMMIT"
|
||||
echo "========================================================"
|
||||
|
||||
MAX_ATTEMPTS=45
|
||||
ATTEMPT=1
|
||||
CF_BLOCKED_COUNT=0
|
||||
|
||||
while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
|
||||
echo "Attempt $ATTEMPT/$MAX_ATTEMPTS: Polling $ENDPOINT..."
|
||||
|
||||
RESP_FILE=$(mktemp)
|
||||
HTTP_CODE=$(curl -sL --max-time 10 \
|
||||
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" \
|
||||
-H "Accept: application/json" \
|
||||
-o "$RESP_FILE" \
|
||||
-w "%{http_code}" \
|
||||
"$ENDPOINT" || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
LIVE_COMMIT=$(jq -r '.commit // empty' "$RESP_FILE" 2>/dev/null || true)
|
||||
echo " Live commit: ${LIVE_COMMIT:-<none>}"
|
||||
|
||||
if [ "$LIVE_COMMIT" = "$TARGET_COMMIT" ]; then
|
||||
echo "✅ Success! Production $SERVICE has successfully rolled out $TARGET_COMMIT."
|
||||
rm -f "$RESP_FILE"
|
||||
exit 0
|
||||
fi
|
||||
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "503" ]; then
|
||||
echo " HTTP $HTTP_CODE: Cloudflare WAF/challenge encountered from runner IP."
|
||||
CF_BLOCKED_COUNT=$((CF_BLOCKED_COUNT + 1))
|
||||
else
|
||||
echo " HTTP $HTTP_CODE: Endpoint returned unexpected status code or timed out."
|
||||
fi
|
||||
|
||||
rm -f "$RESP_FILE"
|
||||
|
||||
if [ $CF_BLOCKED_COUNT -ge 5 ]; then
|
||||
echo "⚠️ Cloudflare WAF consistently blocked external probe requests from this runner IP (HTTP $HTTP_CODE)."
|
||||
echo "The GitOps release commit was already pushed to the repository; FluxCD will reconcile the cluster."
|
||||
echo "Skipping further external HTTP verification to avoid false negative."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "❌ Deployment verification timed out after 7.5 minutes."
|
||||
exit 1
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p output-qemu
|
||||
|
||||
echo "Downloading official Debian 12 cloud image..."
|
||||
curl -fsSL -o base.qcow2 https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
|
||||
|
||||
echo "Expanding disk image to 10G..."
|
||||
qemu-img resize base.qcow2 10G
|
||||
|
||||
echo "Connecting disk image via NBD..."
|
||||
sudo modprobe nbd max_part=8
|
||||
sudo qemu-nbd --connect=/dev/nbd0 base.qcow2
|
||||
sleep 1
|
||||
sudo partx -u /dev/nbd0 || true
|
||||
|
||||
echo "Mounting disk filesystem..."
|
||||
sudo mkdir -p /mnt/target
|
||||
sudo mount /dev/nbd0p1 /mnt/target
|
||||
|
||||
echo "Mounting host virtual filesystems and configuring DNS..."
|
||||
sudo rm -f /mnt/target/etc/resolv.conf
|
||||
echo "nameserver 1.1.1.1" | sudo tee /mnt/target/etc/resolv.conf
|
||||
echo "nameserver 8.8.8.8" | sudo tee -a /mnt/target/etc/resolv.conf
|
||||
sudo mount --bind /dev /mnt/target/dev
|
||||
sudo mount --bind /dev/pts /mnt/target/dev/pts
|
||||
sudo mount --bind /proc /mnt/target/proc
|
||||
sudo mount --bind /sys /mnt/target/sys
|
||||
|
||||
echo "Copying provisioner scripts..."
|
||||
sudo mkdir -p /mnt/target/tmp/scripts
|
||||
sudo cp ci/packer/scripts/*.sh /mnt/target/tmp/scripts/
|
||||
sudo cp /opt/ci/assets/k3s /opt/ci/assets/k3s-install.sh /mnt/target/tmp/scripts/
|
||||
sudo cp /opt/ci/assets/flux-install-local.yaml /mnt/target/tmp/scripts/flux-install.yaml
|
||||
sudo chmod +x /mnt/target/tmp/scripts/*.sh
|
||||
|
||||
echo "Running provisioner scripts in chroot..."
|
||||
sudo chroot /mnt/target /bin/bash /tmp/scripts/01-base-system.sh
|
||||
sudo chroot /mnt/target /bin/bash /tmp/scripts/02-install-k3s.sh
|
||||
sudo chroot /mnt/target /bin/bash /tmp/scripts/03-cleanup.sh
|
||||
sudo rm -rf /mnt/target/tmp/scripts
|
||||
|
||||
echo "Unmounting filesystems..."
|
||||
sudo umount /mnt/target/sys
|
||||
sudo umount /mnt/target/proc
|
||||
sudo umount /mnt/target/dev/pts
|
||||
sudo umount /mnt/target/dev
|
||||
sudo umount /mnt/target
|
||||
sudo qemu-nbd --disconnect /dev/nbd0
|
||||
|
||||
echo "Compressing and optimizing qcow2 with 2M cluster size..."
|
||||
qemu-img convert -f qcow2 -O qcow2 -o cluster_size=2M -c base.qcow2 output-qemu/com-ermnvldmr-debian-12-k3s-amd64.qcow2
|
||||
echo "Golden image built successfully."
|
||||
@@ -0,0 +1,87 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
SERVER='https://git.ermnvldmr.com'
|
||||
|
||||
|
||||
def eligible(run, repository, current_run):
|
||||
if run.get('repository',{}).get('full_name')!=repository:
|
||||
return False
|
||||
if str(run.get('id'))==current_run:
|
||||
return True
|
||||
return run.get('head_branch')=='next' and run.get('event') in ('push','workflow_dispatch') and run.get('status')=='completed' and run.get('conclusion')=='success'
|
||||
|
||||
|
||||
def request(url):
|
||||
if not url.startswith(SERVER+'/'):
|
||||
raise ValueError('Unexpected cache artifact host')
|
||||
class LocalRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if not newurl.startswith(SERVER+'/'):
|
||||
raise ValueError('Unexpected cache storage host')
|
||||
return super().redirect_request(req,fp,code,msg,headers,newurl)
|
||||
req=urllib.request.Request(url,headers={'Authorization':'token '+os.environ['CACHE_TOKEN']})
|
||||
return urllib.request.build_opener(LocalRedirect).open(req,timeout=60)
|
||||
|
||||
|
||||
def api(path):
|
||||
with request(SERVER+'/api/v1/repos/'+os.environ['GITHUB_REPOSITORY']+path) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def main():
|
||||
store=Path(subprocess.check_output(['pnpm','store','path','--silent'],text=True).strip())
|
||||
archive=Path('.ci/pnpm-store.tar')
|
||||
if sys.argv[1]=='pack':
|
||||
archive.parent.mkdir(exist_ok=True)
|
||||
store.mkdir(parents=True,exist_ok=True)
|
||||
with tarfile.open(archive,'w') as target:target.add(store,arcname='.')
|
||||
return
|
||||
versions=subprocess.check_output(['node','--version'],text=True).strip()+'-'+subprocess.check_output(['pnpm','--version'],text=True).strip()
|
||||
key='pnpm-'+platform.system().lower()+'-'+platform.machine()+'-'+versions+'-'+hashlib.sha256(Path('pnpm-lock.yaml').read_bytes()).hexdigest()
|
||||
with open(os.environ['GITHUB_OUTPUT'],'a') as output:output.write('key='+key+'\n')
|
||||
try:
|
||||
artifacts=api('/actions/artifacts?name='+urllib.parse.quote(key))['artifacts']
|
||||
artifacts.sort(key=lambda a:a['id'],reverse=True)
|
||||
for artifact in artifacts:
|
||||
if artifact.get('expired',True) or artifact.get('name')!=key:
|
||||
continue
|
||||
run_id=artifact.get('workflow_run',{}).get('id')
|
||||
if not run_id:
|
||||
continue
|
||||
run=api('/actions/runs/'+str(run_id))
|
||||
if not eligible(run,os.environ['GITHUB_REPOSITORY'],os.environ['GITHUB_RUN_ID']):
|
||||
continue
|
||||
with tempfile.TemporaryDirectory(prefix='pnpm-artifact-cache-') as directory:
|
||||
downloaded=Path(directory)/'cache.zip'
|
||||
with request(SERVER+'/api/v1/repos/'+os.environ['GITHUB_REPOSITORY']+'/actions/artifacts/'+str(artifact['id'])+'/zip') as response,downloaded.open('wb') as target:
|
||||
while chunk:=response.read(1048576):target.write(chunk)
|
||||
with zipfile.ZipFile(downloaded) as zipped:
|
||||
members=[name for name in zipped.namelist() if name.rsplit('/',1)[-1]=='pnpm-store.tar']
|
||||
if len(members)!=1:
|
||||
raise ValueError('Invalid cache archive')
|
||||
packed=Path(directory)/'store.tar'
|
||||
with zipped.open(members[0]) as source,packed.open('wb') as target:
|
||||
while chunk:=source.read(1048576):target.write(chunk)
|
||||
store.mkdir(parents=True,exist_ok=True)
|
||||
with tarfile.open(packed) as tar:tar.extractall(store,filter='data')
|
||||
print('Restored repository-scoped pnpm cache artifact')
|
||||
return
|
||||
except (urllib.error.HTTPError,urllib.error.URLError,ValueError,tarfile.TarError,zipfile.BadZipFile):
|
||||
print('Cache unavailable; dependencies will be fetched normally')
|
||||
return
|
||||
print('No eligible cache artifact; dependencies will be fetched normally')
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -19,17 +19,25 @@ fi
|
||||
|
||||
echo "Updating Kustomize image tag for '$SERVICE' to '$TAG'..."
|
||||
cd "$APP_DIR"
|
||||
kustomize edit set image "ghcr.io/deytenit/ermnvldmr.com/${SERVICE}:${TAG}"
|
||||
IMAGE="git.ermnvldmr.com/deytenit/ermnvldmr.com/${SERVICE}"
|
||||
if [[ "$TAG" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
kustomize edit set image "${IMAGE}=${IMAGE}@${TAG}"
|
||||
elif [[ "$TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then
|
||||
kustomize edit set image "${IMAGE}=${IMAGE}:${TAG}"
|
||||
else
|
||||
echo "Invalid image digest or tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
git config user.name "adam-at-deytenit"
|
||||
git config user.email "221188529+adam-at-deytenit@users.noreply.github.com"
|
||||
git config user.name "APEX CI"
|
||||
git config user.email "adam@ermnvldmr.com"
|
||||
git add "ci/k8s/apps/${SERVICE}/kustomization.yaml"
|
||||
|
||||
if git diff --staged --quiet; then
|
||||
echo "No manifest changes to commit for $SERVICE."
|
||||
else
|
||||
git commit -m "chore(release): deploy ${SERVICE} ${TAG} [skip ci]"
|
||||
git push origin next
|
||||
git commit --no-gpg-sign -m "chore(release): deploy ${SERVICE} ${TAG} [skip ci]"
|
||||
python3 "$ROOT_DIR/ci/scripts/git_write.py" HEAD:next
|
||||
echo "Successfully deployed $SERVICE ($TAG) via GitOps."
|
||||
fi
|
||||
@@ -31,11 +31,21 @@ fi
|
||||
export AWS_DEFAULT_REGION="$AWS_REGION"
|
||||
ENDPOINT_URL="${S3_ENDPOINT_URL%/}"
|
||||
|
||||
# Upload all static files
|
||||
echo "Uploading static files..."
|
||||
aws s3 cp "$DIST_DIR/" "s3://$S3_BUCKET/" \
|
||||
--recursive \
|
||||
--endpoint-url="$ENDPOINT_URL" \
|
||||
--cache-control "max-age=300"
|
||||
# Set S3 max_concurrent_requests to 1 to prevent race conditions on S3-compatible endpoints
|
||||
aws configure set default.s3.max_concurrent_requests 1
|
||||
|
||||
echo "Static files deployed successfully!"
|
||||
# Upload all static files with retry
|
||||
echo "Uploading static files..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Attempt $attempt/3: syncing static files..."
|
||||
if aws s3 sync "$DIST_DIR/" "s3://$S3_BUCKET/" \
|
||||
--endpoint-url="$ENDPOINT_URL" \
|
||||
--cache-control "max-age=300"; then
|
||||
echo "Static files deployed successfully!"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Error: Failed to upload static files after 3 attempts."
|
||||
exit 1
|
||||
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def lines(value):
|
||||
return [line.strip() for line in value.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def output(name, value):
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as stream:
|
||||
stream.write(f'{name}<<GITEA_VALUE\n{value}\nGITEA_VALUE\n')
|
||||
|
||||
|
||||
def metadata(images, rules, sha):
|
||||
result = []
|
||||
for rule in lines(rules):
|
||||
fields = dict(part.split('=', 1) for part in rule.split(',') if '=' in part)
|
||||
if fields.get('enable', 'true').lower() == 'false':
|
||||
continue
|
||||
kind = fields.get('type')
|
||||
if kind == 'sha':
|
||||
tag = 'sha-' + sha[:7]
|
||||
elif kind == 'raw':
|
||||
tag = fields['value']
|
||||
else:
|
||||
raise ValueError('Unsupported metadata rule')
|
||||
if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}', tag):
|
||||
raise ValueError('Invalid image tag')
|
||||
result.extend(image.lower() + ':' + tag for image in lines(images))
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
mode = sys.argv[1]
|
||||
if mode == 'login':
|
||||
subprocess.run(['docker', 'login', os.environ['REGISTRY'], '--username', os.environ['REGISTRY_USER'], '--password-stdin'], input=os.environ['REGISTRY_TOKEN'], text=True, check=True)
|
||||
elif mode == 'metadata':
|
||||
output('tags', '\n'.join(metadata(os.environ['IMAGES'], os.environ['TAG_RULES'], os.environ['SOURCE_SHA'])))
|
||||
output('labels', 'org.opencontainers.image.revision=' + os.environ['SOURCE_SHA'])
|
||||
elif mode == 'build':
|
||||
command = ['docker', 'buildx', 'build', '--metadata-file', '/tmp/image-metadata.json']
|
||||
for env, flag in [('TAGS', '--tag'), ('LABELS', '--label'), ('BUILD_ARGS', '--build-arg')]:
|
||||
for value in lines(os.environ.get(env, '')):
|
||||
command.extend([flag, value])
|
||||
command += ['--label', 'org.opencontainers.image.revision=' + os.environ['SOURCE_SHA'], '--label', 'com.ermnvldmr.ci.run=' + os.environ['RUN_ID']]
|
||||
if os.environ.get('PLATFORMS'):
|
||||
command += ['--platform', os.environ['PLATFORMS']]
|
||||
if os.environ.get('DOCKERFILE'):
|
||||
command += ['--file', os.environ['DOCKERFILE']]
|
||||
if os.environ.get('PUSH', 'false').lower() == 'true':
|
||||
command.append('--push')
|
||||
command.append(os.environ.get('CONTEXT', '.'))
|
||||
subprocess.run(command, check=True)
|
||||
output('digest', json.loads(Path('/tmp/image-metadata.json').read_text())['containerimage.digest'])
|
||||
else:
|
||||
raise ValueError('Unknown docker operation')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
ref=sys.argv[1]
|
||||
if ref not in ('HEAD:next','trunk') and not re.fullmatch(r'refs/tags/[A-Za-z0-9][A-Za-z0-9._-]*',ref):
|
||||
raise ValueError('Unexpected publication ref')
|
||||
repository=os.environ['GITHUB_REPOSITORY']
|
||||
if not re.fullmatch(r'deytenit/[A-Za-z0-9_.-]+',repository):
|
||||
raise ValueError('Unexpected repository owner')
|
||||
with tempfile.TemporaryDirectory(prefix='gitea-write-key-') as directory:
|
||||
key = Path(directory) / 'key'
|
||||
key.write_text(os.environ['GIT_WRITE_KEY'])
|
||||
key.chmod(0o600)
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
candidates = [
|
||||
repo_root / '.gitea' / 'known_hosts',
|
||||
repo_root / 'ci' / 'known_hosts',
|
||||
Path(__file__).resolve().parent.parent / '.gitea' / 'known_hosts',
|
||||
Path(__file__).resolve().parent.parent / 'known_hosts',
|
||||
]
|
||||
known = next((c for c in candidates if c.exists()), None)
|
||||
if not known:
|
||||
raise FileNotFoundError(f'Could not find known_hosts in {[str(c) for c in candidates]}')
|
||||
env = dict(os.environ)
|
||||
env.pop('GIT_WRITE_KEY', None)
|
||||
env['GIT_SSH_COMMAND'] = shlex.join([
|
||||
'ssh', '-T', '-i', str(key),
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'ConnectTimeout=15',
|
||||
'-o', 'HostName=45.150.33.239',
|
||||
'-o', 'IdentitiesOnly=yes',
|
||||
'-o', 'StrictHostKeyChecking=yes',
|
||||
'-o', 'HostKeyAlgorithms=ssh-ed25519',
|
||||
'-o', f'UserKnownHostsFile={known}',
|
||||
])
|
||||
subprocess.run(['git', 'push', f'ssh://git@git.ermnvldmr.com:22222/{repository}.git', ref], env=env, check=True)
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
import base64
|
||||
import io
|
||||
import zipfile
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.request
|
||||
|
||||
SERVER = 'https://git.ermnvldmr.com'
|
||||
REPO = 'deytenit/ermnvldmr.com'
|
||||
IMAGE = SERVER.removeprefix('https://') + '/' + REPO + '/www'
|
||||
|
||||
|
||||
def validate_input(sha, digest, run_id):
|
||||
if not re.fullmatch(r'[0-9a-f]{40}', sha):
|
||||
raise ValueError('Full source SHA required')
|
||||
if not re.fullmatch(r'sha256:[0-9a-f]{64}', digest):
|
||||
raise ValueError('OCI sha256 digest required')
|
||||
if not re.fullmatch(r'[1-9][0-9]*', run_id):
|
||||
raise ValueError('Numeric build run ID required')
|
||||
|
||||
|
||||
def validate_run(run, sha):
|
||||
expected = {'head_sha': sha, 'head_branch': 'next', 'status': 'completed', 'conclusion': 'success'}
|
||||
if any(run.get(key) != value for key, value in expected.items()):
|
||||
raise ValueError('Build did not succeed for this next commit')
|
||||
if run.get('repository', {}).get('full_name') != REPO or (run.get('head_repository') and run['head_repository'].get('full_name') != REPO):
|
||||
raise ValueError('Build source repository mismatch')
|
||||
if run.get('path') != 'services-www-next-required.yml@refs/heads/next':
|
||||
raise ValueError('Wrong build workflow')
|
||||
if run.get('event') not in ('push', 'workflow_dispatch'):
|
||||
raise ValueError('Untrusted build event')
|
||||
|
||||
|
||||
def api(path):
|
||||
req = urllib.request.Request(SERVER + '/api/v1/repos/' + REPO + path, headers={'Authorization': 'token ' + os.environ['PROVENANCE_TOKEN']})
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def validate_labels(labels, sha, run_id):
|
||||
if labels.get('org.opencontainers.image.revision') != sha or labels.get('com.ermnvldmr.ci.run') != run_id:
|
||||
raise ValueError('Immutable image provenance mismatch')
|
||||
|
||||
|
||||
def validate_manifest(manifest, sha, digest, run_id):
|
||||
expected = {'sha': sha, 'digest': digest, 'run_id': run_id, 'repository': REPO}
|
||||
if manifest != expected:
|
||||
raise ValueError('Build artifact does not attest requested image digest')
|
||||
|
||||
|
||||
def artifact_manifest(run_id, name='www-provenance', filename='www-provenance.json'):
|
||||
artifacts = api('/actions/runs/' + run_id + '/artifacts')['artifacts']
|
||||
matching = [a for a in artifacts if a.get('name') == name and not a.get('expired', True)]
|
||||
if len(matching) != 1:
|
||||
raise ValueError('Exactly one unexpired build provenance artifact required')
|
||||
class SameHostRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if not newurl.startswith(SERVER + '/'):
|
||||
raise ValueError('Unexpected artifact storage host')
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
request = urllib.request.Request(SERVER + '/api/v1/repos/' + REPO + '/actions/artifacts/' + str(matching[0]['id']) + '/zip', headers={'Authorization': 'token ' + os.environ['PROVENANCE_TOKEN']})
|
||||
with urllib.request.build_opener(SameHostRedirect).open(request, timeout=30) as response:
|
||||
payload = response.read(1024 * 1024 + 1)
|
||||
if len(payload) > 1024 * 1024:
|
||||
raise ValueError('Unexpected provenance artifact size')
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
||||
members = [name for name in archive.namelist() if name.rsplit('/', 1)[-1] == filename]
|
||||
if len(members) != 1:
|
||||
raise ValueError('Missing or ambiguous provenance manifest')
|
||||
if archive.getinfo(members[0]).file_size > 65536:
|
||||
raise ValueError('Unexpected provenance document size')
|
||||
return json.loads(archive.read(members[0]))
|
||||
|
||||
|
||||
def main():
|
||||
sha, digest, run_id = os.environ['TESTED_SHA'], os.environ['TESTED_DIGEST'], os.environ['BUILD_RUN_ID']
|
||||
validate_input(sha, digest, run_id)
|
||||
current = api('/actions/runs/' + os.environ['GITHUB_RUN_ID'])
|
||||
if current.get('event') != 'workflow_dispatch' or current.get('head_branch') != 'next':
|
||||
raise ValueError('Approval must be manually dispatched on protected next')
|
||||
actor = current.get('trigger_actor') or current.get('actor') or {}
|
||||
if actor.get('login') != 'deytenit':
|
||||
raise ValueError('Only the repository owner may approve promotion')
|
||||
if not api('/branches/next').get('protected'):
|
||||
raise ValueError('The next branch must be protected before promotion')
|
||||
validate_run(api('/actions/runs/' + run_id), sha)
|
||||
validate_manifest(artifact_manifest(run_id), sha, digest, run_id)
|
||||
if os.environ.get('RELEASE_RUN_ID'):
|
||||
release_id = os.environ['RELEASE_RUN_ID']
|
||||
if not re.fullmatch(r'[1-9][0-9]*', release_id):
|
||||
raise ValueError('Numeric release run ID required')
|
||||
release = api('/actions/runs/' + release_id)
|
||||
actor = release.get('trigger_actor') or release.get('actor') or {}
|
||||
if release.get('conclusion') != 'success' or release.get('status') != 'completed' or release.get('event') != 'workflow_dispatch' or release.get('head_branch') != 'next' or release.get('path') != 'services-www-release-manual.yml@refs/heads/next' or actor.get('login') != 'deytenit':
|
||||
raise ValueError('Successful independently approved release required')
|
||||
manifest = artifact_manifest(release_id, 'www-release-provenance', 'www-release.json')
|
||||
expected = {'sha': sha, 'digest': digest, 'run_id': run_id, 'repository': REPO, 'release_tag': os.environ['RELEASE_TAG']}
|
||||
if manifest != expected:
|
||||
raise ValueError('Release approval does not match requested artifact')
|
||||
auth = base64.b64encode(('deytenit:' + os.environ['PROVENANCE_TOKEN']).encode()).decode()
|
||||
subprocess.run(['git', '-c', 'http.https://git.ermnvldmr.com/.extraheader=AUTHORIZATION: basic ' + auth, 'fetch', 'origin', 'next'], check=True)
|
||||
subprocess.run(['git', 'merge-base', '--is-ancestor', sha, 'origin/next'], check=True)
|
||||
subprocess.run(['docker', 'pull', IMAGE + '@' + digest], check=True)
|
||||
data = json.loads(subprocess.check_output(['docker', 'image', 'inspect', IMAGE + '@' + digest], text=True))
|
||||
validate_labels(data[0]['Config'].get('Labels') or {}, sha, run_id)
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as stream:
|
||||
stream.write('short_sha=sha-' + sha[:7] + '\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PNPM_VERSION="${1:-9.12.3}"
|
||||
NODE_VERSION="${2:-20}"
|
||||
|
||||
# Configure pnpm
|
||||
case "$PNPM_VERSION" in
|
||||
8|9|9.12.3) ;;
|
||||
*) echo "Unsupported pnpm version: $PNPM_VERSION" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
PNPM_BIN="/opt/ci/pnpm/$PNPM_VERSION/bin"
|
||||
if [ -d "$PNPM_BIN" ]; then
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
echo "$PNPM_BIN" >> "$GITHUB_PATH"
|
||||
fi
|
||||
export PATH="$PNPM_BIN:$PATH"
|
||||
fi
|
||||
|
||||
# Configure Node.js
|
||||
case "$NODE_VERSION" in
|
||||
18*) major=18 ;;
|
||||
20*) major=20 ;;
|
||||
22*) major=22 ;;
|
||||
*) echo "Unsupported node version: $NODE_VERSION" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
NODE_BIN="/opt/ci/node/$major/bin"
|
||||
if [ -d "$NODE_BIN" ]; then
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
echo "$NODE_BIN" >> "$GITHUB_PATH"
|
||||
fi
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
fi
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_NAME="${1:-com-ermnvldmr-debian-12-k3s-amd64.qcow2}"
|
||||
mv output-qemu/com-ermnvldmr-debian-12-k3s-amd64.qcow2 "output-qemu/${IMAGE_NAME}"
|
||||
|
||||
if [ -n "${S3_BUCKET:-}" ] && [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then
|
||||
ENDPOINT_FLAG=""
|
||||
[ -n "${S3_ENDPOINT:-}" ] && ENDPOINT_FLAG="--endpoint-url=${S3_ENDPOINT}"
|
||||
echo "Cleaning up older images in dev/images/ to free bucket storage..."
|
||||
aws ${ENDPOINT_FLAG} s3 rm "s3://${S3_BUCKET}/dev/images/" --recursive --exclude "*" --include "com-ermnvldmr-debian-12-k3s-*.qcow2" || true
|
||||
aws ${ENDPOINT_FLAG} s3 cp "output-qemu/${IMAGE_NAME}" "s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}"
|
||||
echo "Golden image uploaded to s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}"
|
||||
else
|
||||
echo "S3 storage secrets not provided; skipping remote S3 upload. Image artifact is preserved in workflow run artifacts."
|
||||
fi
|
||||
Reference in new issue
Block a user