mirror of
https://github.com/kerberos-io/agent.git
synced 2026-11-07 20:30:35 +00:00
Align scratch runtime and releases with image hardening baseline
Move container HTTP to8080, remove capabilities/curl, preserve demo UI without mutating assets, patch vulnerable Go dependencies and document migration/runtime contracts. Gate releases on source/final-image security evidence and unresolved policy exceptions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot App
parent
9553b1b4b9
commit
e3acd64a57
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate BuildKit provenance and retain resolved base-image materials."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def materials(value):
|
||||
if isinstance(value, dict):
|
||||
if "uri" in value and value["uri"].startswith(("pkg:docker/", "docker-image://")):
|
||||
digest = value.get("digest", {}).get("sha256", "")
|
||||
if not re.fullmatch(r"[a-f0-9]{64}", digest):
|
||||
raise ValueError(f"Missing base-image digest: {value['uri']}")
|
||||
yield value
|
||||
for child in value.values():
|
||||
yield from materials(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
yield from materials(child)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
directory = Path(sys.argv[1])
|
||||
provenance = json.loads((directory / "provenance.json").read_text())
|
||||
bases = list(materials(provenance))
|
||||
if not bases:
|
||||
raise SystemExit("Missing provenance/base-image materials")
|
||||
(directory / "base-image-digests.json").write_text(json.dumps(bases, indent=2) + "\n")
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed source/application and image scans with redacted evidence."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def check_report(report, image=False, source=False):
|
||||
if report.get("SchemaVersion") != 2 or not isinstance(report.get("Results"), list):
|
||||
raise ValueError("Missing or unsupported Trivy report")
|
||||
failures = []
|
||||
for result in report["Results"]:
|
||||
if result.get("Errors"):
|
||||
failures.append(f"Scan errors in {result.get('Target')}: {result['Errors']}")
|
||||
if result.get("MisconfSummary", {}).get("Exceptions", 0):
|
||||
failures.append(f"Unapproved misconfiguration exceptions in {result.get('Target')}")
|
||||
for kind in ("Vulnerabilities", "Secrets", "Misconfigurations"):
|
||||
for finding in result.get(kind, []):
|
||||
if kind == "Misconfigurations" and finding.get("Status") == "PASS":
|
||||
continue
|
||||
if kind == "Misconfigurations" and finding.get("Status") != "FAIL":
|
||||
failures.append(f"Unexpected/suppressed misconfiguration status: {finding.get('Status')}")
|
||||
severity = finding.get("Severity", "UNKNOWN")
|
||||
if severity not in ("LOW", "MEDIUM"):
|
||||
failures.append(f"{result.get('Target')}: {kind}: "
|
||||
f"{finding.get('VulnerabilityID', finding.get('ID', finding.get('RuleID')))} "
|
||||
f"{severity}")
|
||||
if image:
|
||||
# A scratch image without package metadata must not yield a false clean OS scan.
|
||||
if not report.get("Metadata", {}).get("OS", {}).get("Family"):
|
||||
failures.append("No OS detected: preserve runtime OS/package metadata")
|
||||
if not any(r.get("Class") == "os-pkgs" and r.get("Packages")
|
||||
for r in report["Results"]):
|
||||
failures.append("No OS package inventory detected")
|
||||
if not any(r.get("Type") == "gobinary" and r.get("Packages")
|
||||
for r in report["Results"]):
|
||||
failures.append("No Go application package inventory detected")
|
||||
if source and not any(
|
||||
r.get("Type") == "yarn" and r.get("Target", "").replace("\\", "/").endswith("ui/yarn.lock")
|
||||
and r.get("Packages") for r in report["Results"]
|
||||
):
|
||||
failures.append("Missing production UI yarn.lock package inventory")
|
||||
return failures
|
||||
|
||||
|
||||
def redact_report(value):
|
||||
"""Keep findings actionable without persisting matches, excerpts or image env/history."""
|
||||
if isinstance(value, list):
|
||||
return [redact_report(item) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
result = {}
|
||||
for key, child in value.items():
|
||||
if key.lower() in {"imageconfig", "buildinfo"}:
|
||||
continue
|
||||
if key.lower() in {
|
||||
"match", "code", "lines", "source", "sourcecode", "snippet", "context",
|
||||
"content",
|
||||
}:
|
||||
result[key] = "[REDACTED]"
|
||||
elif key == "Errors":
|
||||
result[key] = ["[REDACTED scanner error]"] if child else []
|
||||
elif key in ("Secrets", "Misconfigurations"):
|
||||
allowed = {
|
||||
"ID", "AVDID", "RuleID", "Type", "Category", "Title", "Severity",
|
||||
"Status", "StartLine", "EndLine", "References",
|
||||
}
|
||||
result[key] = [
|
||||
{field: finding[field] for field in allowed if field in finding}
|
||||
| {"SourceExcerpt": "[REDACTED]"}
|
||||
for finding in child
|
||||
]
|
||||
else:
|
||||
result[key] = redact_report(child)
|
||||
return result
|
||||
|
||||
|
||||
def scan(target, output, platform=None):
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
config = output / "scanner.yaml"
|
||||
ignore = output / "empty.ignore"
|
||||
secret_config = output / "secret.yaml"
|
||||
config.write_text("{}\n")
|
||||
secret_config.write_text("{}\n")
|
||||
ignore.write_text("")
|
||||
# Do not consume repository trivy.yaml/.trivyignore or TRIVY_* overrides.
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith("TRIVY_")}
|
||||
command = [
|
||||
"trivy", "image" if platform else "fs", "--config", str(config),
|
||||
"--cache-dir", ".ci-tools/trivy-cache",
|
||||
"--ignorefile", str(ignore), "--secret-config", str(secret_config),
|
||||
"--format", "json", "--exit-code", "0",
|
||||
"--scanners", "vuln,secret,misconfig",
|
||||
"--severity", "UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL", "--timeout", "20m",
|
||||
"--list-all-pkgs", "--detection-priority", "comprehensive",
|
||||
]
|
||||
if platform:
|
||||
command += ["--platform", platform, "--pkg-types", "os,library",
|
||||
"--image-config-scanners", "secret,misconfig"]
|
||||
else:
|
||||
# Include development/build dependencies too: none are silently exempted.
|
||||
# Trivy marks Dev packages using the adjacent package.json and yarn.lock.
|
||||
command += ["--pkg-types", "library", "--include-dev-deps"]
|
||||
command.append(target)
|
||||
# Never write raw scanner output, even on scanner failure/interruption. stderr
|
||||
# may contain source excerpts too; retain only a safe exit-status diagnostic.
|
||||
completed = subprocess.run(command, env=env, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, check=False)
|
||||
(output / "scanner.log").write_text(
|
||||
f"Trivy exit status: {completed.returncode}\n"
|
||||
"Raw scanner stdout/stderr omitted; scan.json is redacted before persistence.\n")
|
||||
if completed.returncode:
|
||||
raise ValueError(f"Trivy failed with exit status {completed.returncode}; raw diagnostics withheld")
|
||||
try:
|
||||
report = redact_report(json.loads(completed.stdout))
|
||||
except ValueError:
|
||||
raise ValueError("Invalid scanner JSON; raw output withheld") from None
|
||||
(output / "scan.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
failures = check_report(report, image=bool(platform), source=not platform)
|
||||
if not platform:
|
||||
inventory = [{
|
||||
"target": result.get("Target"), "type": result.get("Type"),
|
||||
"packages": [{"name": pkg.get("Name"), "version": pkg.get("Version"),
|
||||
"scope": "development" if pkg.get("Dev") else "production-or-unclassified"}
|
||||
for pkg in result.get("Packages", [])],
|
||||
} for result in report["Results"] if result.get("Packages")]
|
||||
(output / "application-inventory.json").write_text(json.dumps({
|
||||
"policy": "All production and development/build dependencies are gated; no exclusions.",
|
||||
"inventories": inventory,
|
||||
}, indent=2) + "\n")
|
||||
converted = subprocess.run([
|
||||
"trivy", "convert", "--cache-dir", ".ci-tools/trivy-cache",
|
||||
"--format", "spdx-json", "--output", str(output / "sbom.spdx.json"),
|
||||
str(output / "scan.json"),
|
||||
], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
|
||||
if converted.returncode:
|
||||
raise ValueError(f"SPDX conversion failed with exit status {converted.returncode}")
|
||||
sbom = json.loads((output / "sbom.spdx.json").read_text())
|
||||
if not sbom.get("spdxVersion") or not sbom.get("packages"):
|
||||
failures.append("Missing/empty SPDX SBOM")
|
||||
return failures
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("target", help="Repository root, or final image reference with --platform")
|
||||
parser.add_argument("output", type=Path, help="Directory for redacted reports and SPDX SBOM")
|
||||
parser.add_argument("--platform", help="Image platform, e.g. linux/amd64; omit for source/UI scanning")
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
failures = scan(args.target, args.output, args.platform)
|
||||
except (OSError, ValueError, subprocess.CalledProcessError) as error:
|
||||
failures = [f"Scanner/evidence error: {error}"]
|
||||
(args.output / "policy.json").write_text(json.dumps({
|
||||
"target": args.target, "platform": args.platform, "failures": failures,
|
||||
"approved_exceptions": [], "passed": not failures,
|
||||
}, indent=2) + "\n")
|
||||
for failure in failures:
|
||||
print(failure, file=sys.stderr)
|
||||
return int(bool(failures))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"required_exceptions": [
|
||||
{
|
||||
"id": "remote-terminal-pty",
|
||||
"status": "pending",
|
||||
"reason": "The product retains functional remote terminal/PTY support, contrary to the no-shell baseline.",
|
||||
"owner": null,
|
||||
"expires": null,
|
||||
"approved_by": null,
|
||||
"approval_url": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Release-only policy gate. Approval records require a reviewed repository change."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
def check_policy(policy, now=None):
|
||||
now = now or datetime.now(timezone.utc)
|
||||
records = policy.get("required_exceptions")
|
||||
if not isinstance(records, list) or not records:
|
||||
return ["Required exception records are missing"]
|
||||
if {record.get("id") for record in records} != {"remote-terminal-pty"}:
|
||||
return ["Required remote-terminal-pty exception record is missing or unexpected"]
|
||||
failures = []
|
||||
for record in records:
|
||||
name = record["id"]
|
||||
if record.get("status") != "approved":
|
||||
failures.append(f"{name}: required exception is PENDING, not approved")
|
||||
continue
|
||||
if not all(isinstance(record.get(key), str) and record[key].strip()
|
||||
for key in ("owner", "approved_by", "reason", "approval_url", "expires")):
|
||||
failures.append(f"{name}: missing owner/approver/reason/approval evidence/expiry")
|
||||
continue
|
||||
if not record["approval_url"].startswith("https://github.com/kerberos-io/agent/"):
|
||||
failures.append(f"{name}: approval must reference this repository's reviewed record")
|
||||
try:
|
||||
expires = datetime.fromisoformat(record["expires"].replace("Z", "+00:00"))
|
||||
if expires.tzinfo is None or expires <= now:
|
||||
failures.append(f"{name}: approval expired or lacks timezone")
|
||||
except ValueError:
|
||||
failures.append(f"{name}: invalid approval expiry")
|
||||
return failures
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
failures = check_policy(json.loads(Path(sys.argv[1]).read_text()))
|
||||
except (OSError, ValueError, TypeError, KeyError) as error:
|
||||
failures = [f"Invalid release policy: {error}"]
|
||||
for failure in failures:
|
||||
print(failure, file=sys.stderr)
|
||||
sys.exit(bool(failures))
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Release assets and these platform names were checked against v0.76.1.
|
||||
version=0.76.1
|
||||
case "$(uname -m)" in
|
||||
x86_64) platform=64bit ;;
|
||||
aarch64|arm64) platform=ARM64 ;;
|
||||
*) echo "Unsupported scanner host architecture" >&2; exit 1 ;;
|
||||
esac
|
||||
mkdir -p .ci-tools
|
||||
asset="trivy_${version}_Linux-${platform}.tar.gz"
|
||||
gh release download "v${version}" --repo aquasecurity/trivy \
|
||||
--pattern "$asset" --pattern "trivy_${version}_checksums.txt" --dir .ci-tools
|
||||
(
|
||||
cd .ci-tools
|
||||
grep " ${asset}$" "trivy_${version}_checksums.txt" | sha256sum --check --strict -
|
||||
tar -xzf "$asset" trivy
|
||||
)
|
||||
echo "$PWD/.ci-tools" >> "$GITHUB_PATH"
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Focused release guard tests; no registry, signing, or scanner network access."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import subprocess
|
||||
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
|
||||
|
||||
def load(name):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / f"{name}.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
scanner = load("image-policy")
|
||||
release = load("image-release-policy")
|
||||
evidence = load("image-evidence")
|
||||
|
||||
|
||||
class ScanPolicyTests(unittest.TestCase):
|
||||
def report(self, severity=None, kind="Vulnerabilities"):
|
||||
result = {"Class": "os-pkgs", "Packages": [{"Name": "libc6"}]}
|
||||
if severity:
|
||||
result[kind] = [{"Severity": severity, "Status": "FAIL", "ID": "fixture"}]
|
||||
return {"SchemaVersion": 2, "Metadata": {"OS": {"Family": "debian"}},
|
||||
"Results": [result, {"Class": "lang-pkgs", "Type": "gobinary",
|
||||
"Packages": [{"Name": "stdlib"}]}]}
|
||||
|
||||
def test_low_and_medium_are_not_blocking(self):
|
||||
for severity in ("LOW", "MEDIUM"):
|
||||
self.assertEqual(scanner.check_report(self.report(severity), image=True), [])
|
||||
|
||||
def test_high_critical_unknown_and_unrecognized_block(self):
|
||||
for kind in ("Vulnerabilities", "Secrets", "Misconfigurations"):
|
||||
for severity in ("HIGH", "CRITICAL", "UNKNOWN", "invalid"):
|
||||
with self.subTest(kind=kind, severity=severity):
|
||||
self.assertTrue(scanner.check_report(self.report(severity, kind), image=True))
|
||||
|
||||
def test_missing_inventory_blocks_false_clean_scratch_scan(self):
|
||||
report = self.report()
|
||||
report["Metadata"] = {}
|
||||
report["Results"] = []
|
||||
self.assertEqual(len(scanner.check_report(report, image=True)), 3)
|
||||
|
||||
def test_empty_os_inventory_blocks(self):
|
||||
report = self.report()
|
||||
report["Results"][0]["Packages"] = []
|
||||
self.assertTrue(scanner.check_report(report, image=True))
|
||||
|
||||
def test_report_errors_and_suppression_summary_block(self):
|
||||
report = self.report()
|
||||
report["Results"][0]["Errors"] = ["fixture scanner error"]
|
||||
report["Results"][0]["MisconfSummary"] = {"Exceptions": 1}
|
||||
self.assertEqual(len(scanner.check_report(report)), 2)
|
||||
|
||||
def test_missing_or_unsupported_report_blocks(self):
|
||||
for report in ({}, {"SchemaVersion": 1, "Results": []}):
|
||||
with self.assertRaises(ValueError):
|
||||
scanner.check_report(report)
|
||||
|
||||
def test_suppressed_misconfiguration_blocks(self):
|
||||
report = self.report("LOW", "Misconfigurations")
|
||||
report["Results"][0]["Misconfigurations"][0]["Status"] = "EXCEPTION"
|
||||
self.assertTrue(scanner.check_report(report))
|
||||
|
||||
def test_scanner_errors_are_not_ignored(self):
|
||||
directory = unittest.mock.MagicMock()
|
||||
with patch.object(scanner.subprocess, "run",
|
||||
side_effect=subprocess.CalledProcessError(1, ["trivy"])):
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
scanner.scan("fixture", directory, "linux/amd64")
|
||||
|
||||
def test_source_requires_ui_lockfile_inventory(self):
|
||||
report = self.report()
|
||||
self.assertTrue(scanner.check_report(report, source=True))
|
||||
report["Results"].append({"Type": "yarn", "Target": "ui/yarn.lock",
|
||||
"Packages": [{"Name": "react", "Version": "17.0.2"}]})
|
||||
self.assertEqual(scanner.check_report(report, source=True), [])
|
||||
report["Results"][-1]["Vulnerabilities"] = [{"Severity": "HIGH", "VulnerabilityID": "fixture"}]
|
||||
self.assertTrue(scanner.check_report(report, source=True))
|
||||
|
||||
def test_secret_and_source_excerpts_are_removed_without_waiving_findings(self):
|
||||
marker = "sensitive-fixture-text-not-a-real-credential"
|
||||
report = self.report("HIGH", "Secrets")
|
||||
report["Metadata"]["ImageConfig"] = {"config": {"Env": [marker]}}
|
||||
report["Metadata"]["BuildInfo"] = {"attrs": {"secret": marker}}
|
||||
report["Results"][0]["Secrets"][0].update(
|
||||
Match=marker, Code={"Lines": [{"Content": marker}]}, Message=marker,
|
||||
UnexpectedFutureExcerptField=marker, RuleID="private-key-fixture")
|
||||
report["Results"][0]["Misconfigurations"] = [{
|
||||
"ID": "fixture", "Severity": "HIGH", "Status": "FAIL",
|
||||
"Message": marker, "CauseMetadata": {"Code": {"Lines": [{"Content": marker}]}}
|
||||
}]
|
||||
report["Results"][0]["Errors"] = [marker]
|
||||
redacted = scanner.redact_report(report)
|
||||
self.assertNotIn(marker, json.dumps(redacted))
|
||||
self.assertNotIn("ImageConfig", redacted["Metadata"])
|
||||
self.assertTrue(scanner.check_report(redacted, image=True))
|
||||
self.assertEqual(redacted["Results"][0]["Secrets"][0]["RuleID"], "private-key-fixture")
|
||||
|
||||
def test_source_scan_captures_output_in_memory_and_generates_sbom(self):
|
||||
report = self.report()
|
||||
report["Results"].append({"Type": "yarn", "Target": "ui/yarn.lock",
|
||||
"Packages": [{"Name": "fixture", "Version": "1.0", "Dev": True}]})
|
||||
directory = unittest.mock.MagicMock()
|
||||
directory.__truediv__.return_value.read_text.return_value = json.dumps({
|
||||
"spdxVersion": "SPDX-2.3", "packages": [{"name": "fixture"}]})
|
||||
completed = subprocess.CompletedProcess(["trivy"], 0, stdout=json.dumps(report), stderr="")
|
||||
with patch.object(scanner.subprocess, "run", return_value=completed) as run:
|
||||
self.assertEqual(scanner.scan("fixture", directory), [])
|
||||
command = run.call_args_list[0].args[0]
|
||||
self.assertIn("--include-dev-deps", command)
|
||||
self.assertIn("vuln,secret,misconfig", command)
|
||||
self.assertNotIn("--output", command)
|
||||
self.assertEqual(run.call_args_list[0].kwargs["stdout"], subprocess.PIPE)
|
||||
self.assertEqual(run.call_args_list[1].args[0][1], "convert")
|
||||
|
||||
def test_raw_scanner_error_diagnostics_are_not_persisted(self):
|
||||
directory = unittest.mock.MagicMock()
|
||||
marker = "sensitive-fixture-text-not-a-real-credential"
|
||||
completed = subprocess.CompletedProcess(["trivy"], 1, stdout=marker, stderr=marker)
|
||||
with patch.object(scanner.subprocess, "run", return_value=completed):
|
||||
with self.assertRaisesRegex(ValueError, "exit status 1"):
|
||||
scanner.scan("fixture", directory)
|
||||
self.assertNotIn(marker, str(directory.mock_calls))
|
||||
|
||||
|
||||
class ReleasePolicyTests(unittest.TestCase):
|
||||
NOW = datetime(2026, 11, 4, tzinfo=timezone.utc)
|
||||
|
||||
def test_checked_in_state_blocks_publication(self):
|
||||
state = json.loads((ROOT / "image-release-policy.json").read_text())
|
||||
self.assertTrue(release.check_policy(state, self.NOW))
|
||||
|
||||
def test_missing_records_block(self):
|
||||
for state in ({}, {"required_exceptions": []},
|
||||
{"required_exceptions": [{"id": "unrelated"}]}):
|
||||
self.assertTrue(release.check_policy(state, self.NOW))
|
||||
|
||||
def test_expired_or_incomplete_approval_blocks(self):
|
||||
state = json.loads((ROOT / "image-release-policy.json").read_text())
|
||||
record = state["required_exceptions"][0]
|
||||
record["status"] = "approved"
|
||||
self.assertTrue(release.check_policy(state, self.NOW))
|
||||
record.update(owner="test-fixture", approved_by="test-fixture",
|
||||
approval_url="https://github.com/kerberos-io/agent/issues/1",
|
||||
expires="2020-01-01T00:00:00Z")
|
||||
self.assertTrue(release.check_policy(state, self.NOW))
|
||||
record["expires"] = "invalid"
|
||||
self.assertTrue(release.check_policy(state, self.NOW))
|
||||
record["expires"] = "2027-01-01"
|
||||
self.assertTrue(release.check_policy(state, self.NOW))
|
||||
|
||||
|
||||
class ProvenanceTests(unittest.TestCase):
|
||||
def test_base_digest_retained(self):
|
||||
material = {"uri": "pkg:docker/debian@trixie", "digest": {"sha256": "a" * 64}}
|
||||
self.assertEqual(list(evidence.materials({"SLSA": {"materials": [material]}})), [material])
|
||||
|
||||
def test_missing_digest_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
list(evidence.materials({"uri": "pkg:docker/debian@trixie"}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,326 @@
|
||||
name: Verified image publication
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
type: string
|
||||
default: ""
|
||||
source_ref:
|
||||
type: string
|
||||
required: true
|
||||
nightly:
|
||||
type: boolean
|
||||
default: false
|
||||
publish_latest:
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
DOCKER_PASSWORD:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# No parallel run can overwrite a customer tag while another run verifies it.
|
||||
concurrency:
|
||||
group: image-publication-${{ inputs.nightly && 'nightly' || 'release' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
sha: ${{ steps.source.outputs.sha }}
|
||||
tag: ${{ steps.source.outputs.tag }}
|
||||
created: ${{ steps.source.outputs.created }}
|
||||
matrix: ${{ steps.source.outputs.matrix }}
|
||||
repository: ${{ steps.source.outputs.repository }}
|
||||
staging_repository: ${{ steps.source.outputs.staging_repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Validate requested tag
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
NIGHTLY: ${{ inputs.nightly }}
|
||||
run: |
|
||||
if [[ "$NIGHTLY" != true && ! "$TAG" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ ]]; then
|
||||
echo "Invalid Docker tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
path: source
|
||||
persist-credentials: false
|
||||
- name: Resolve once for every architecture
|
||||
id: source
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
NIGHTLY: ${{ inputs.nightly }}
|
||||
run: |
|
||||
sha=$(git -C source rev-parse HEAD)
|
||||
[[ "$sha" =~ ^[a-f0-9]{40}$ ]]
|
||||
created=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
repository=kerberos/agent
|
||||
staging_repository=kerberos/agent-arch
|
||||
matrix='{"include":[{"architecture":"amd64","runner":"ubuntu-24.04"},{"architecture":"arm64","runner":"ubuntu-24.04-arm"}]}'
|
||||
if [[ "$NIGHTLY" == true ]]; then
|
||||
TAG="${sha:0:7}"
|
||||
repository=kerberos/agent-nightly
|
||||
staging_repository="$repository"
|
||||
# Keep arm/v6: the pinned Go trixie base currently lacks this platform.
|
||||
# Its build must fail rather than publishing an incomplete nightly index.
|
||||
matrix='{"include":[{"architecture":"amd64","runner":"ubuntu-24.04"},{"architecture":"arm64","runner":"ubuntu-24.04-arm"},{"architecture":"arm/v7","runner":"ubuntu-24.04"},{"architecture":"arm/v6","runner":"ubuntu-24.04"}]}'
|
||||
fi
|
||||
{
|
||||
echo "sha=$sha"
|
||||
echo "tag=$TAG"
|
||||
echo "created=$created"
|
||||
echo "repository=$repository"
|
||||
echo "staging_repository=$staging_repository"
|
||||
echo "matrix=$matrix"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
mkdir -p evidence
|
||||
printf '%s\n' "$sha" > evidence/source-revision.txt
|
||||
printf '%s\n' "$created" > evidence/created.txt
|
||||
printf '%s\n' "$TAG" > evidence/version.txt
|
||||
cp .github/scripts/image-release-policy.json evidence/runtime-policy.json
|
||||
- name: Validate fail-closed policy helpers
|
||||
run: python3 .github/scripts/test-image-policy.py
|
||||
- name: Keep existing GitHub release unpublished until gates pass
|
||||
if: ${{ !inputs.nightly }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.source.outputs.tag }}
|
||||
run: |
|
||||
# A release:created event may refer to an already public release.
|
||||
# Return it to draft immediately; do not imply this run has verified it.
|
||||
gh api --paginate "repos/$GITHUB_REPOSITORY/releases" --jq '.[]' | jq -s . > evidence/releases.json
|
||||
release_id=$(jq -r --arg tag "$TAG" '.[] | select(.tag_name == $tag) | .id' evidence/releases.json)
|
||||
if [[ -n "$release_id" ]]; then
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$release_id" -F draft=true > evidence/draft-release.json
|
||||
fi
|
||||
- name: Install verified scanner release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bash .github/scripts/install-trivy.sh
|
||||
- name: Source/UI dependency vulnerability, secret and misconfiguration gate with SPDX SBOM
|
||||
run: python3 .github/scripts/image-policy.py source evidence/source
|
||||
- name: Retain source policy evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: image-source-evidence
|
||||
path: evidence/
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
build:
|
||||
needs: resolve
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.resolve.outputs.matrix) }}
|
||||
env:
|
||||
ARCH: ${{ matrix.architecture }}
|
||||
VERSION: ${{ needs.resolve.outputs.tag }}
|
||||
REVISION: ${{ needs.resolve.outputs.sha }}
|
||||
CREATED: ${{ needs.resolve.outputs.created }}
|
||||
STAGING_REPOSITORY: ${{ needs.resolve.outputs.staging_repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
path: source
|
||||
persist-credentials: false
|
||||
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
if: ${{ startsWith(matrix.architecture, 'arm/') }}
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build immutable staging image with maximal provenance
|
||||
id: build
|
||||
run: |
|
||||
mkdir -p evidence
|
||||
arch="${ARCH//\//-}"
|
||||
echo "arch=$arch" >> "$GITHUB_OUTPUT"
|
||||
staging="$STAGING_REPOSITORY:ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$arch"
|
||||
printf '%s\n' "$REVISION" > evidence/source-revision.txt
|
||||
docker buildx build --platform "linux/$ARCH" --provenance=mode=max \
|
||||
--build-arg "VERSION=$VERSION" --build-arg "REVISION=$REVISION" --build-arg "CREATED=$CREATED" \
|
||||
--label "org.opencontainers.image.version=$VERSION" \
|
||||
--label "org.opencontainers.image.revision=$REVISION" \
|
||||
--label "org.opencontainers.image.created=$CREATED" \
|
||||
--label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \
|
||||
--metadata-file evidence/build-metadata.json --tag "$staging" --push source
|
||||
digest=$(jq -er '."containerimage.digest"' evidence/build-metadata.json)
|
||||
[[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]
|
||||
image="$STAGING_REPOSITORY@$digest"
|
||||
printf '%s\n' "$image" > evidence/image.txt
|
||||
docker buildx imagetools inspect "$image" --format '{{json .Provenance}}' > evidence/provenance.json
|
||||
python3 .github/scripts/image-evidence.py evidence
|
||||
- name: Extract release payload and smoke test the exact staged image
|
||||
run: |
|
||||
image=$(cat evidence/image.txt)
|
||||
docker pull --platform "linux/$ARCH" "$image"
|
||||
cid=$(docker create --platform "linux/$ARCH" "$image")
|
||||
trap 'docker rm -f "$cid" >/dev/null' EXIT
|
||||
docker cp "$cid:/home/agent" output
|
||||
# Run the existing smoke interface, including emulated nightly architectures.
|
||||
DOCKER_DEFAULT_PLATFORM="linux/$ARCH" sh source/deployments/docker/test-image.sh "$image"
|
||||
arch="${ARCH//\//-}"
|
||||
tar -cf "agent-$arch.tar" -C output .
|
||||
rm -rf output
|
||||
- name: Retain build evidence even on failure
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: image-build-${{ steps.build.outputs.arch || strategy.job-index }}
|
||||
path: evidence/
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
- name: Retain existing binary artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: agent-${{ steps.build.outputs.arch }}.tar
|
||||
path: agent-*.tar
|
||||
if-no-files-found: error
|
||||
|
||||
verify-publish:
|
||||
needs: [resolve, build]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
env:
|
||||
REPOSITORY: ${{ needs.resolve.outputs.repository }}
|
||||
STAGING_REPOSITORY: ${{ needs.resolve.outputs.staging_repository }}
|
||||
VERSION: ${{ needs.resolve.outputs.tag }}
|
||||
REVISION: ${{ needs.resolve.outputs.sha }}
|
||||
PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||
MATRIX: ${{ needs.resolve.outputs.matrix }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
path: artifacts
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Install scanner
|
||||
run: bash .github/scripts/install-trivy.sh
|
||||
- uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: v3.1.4
|
||||
- name: Assemble candidate index without publishing version/latest
|
||||
run: |
|
||||
mkdir -p evidence
|
||||
images=()
|
||||
while IFS= read -r arch; do
|
||||
directory="artifacts/image-build-${arch//\//-}"
|
||||
[[ "$(cat "$directory/source-revision.txt")" == "$REVISION" ]]
|
||||
image=$(cat "$directory/image.txt")
|
||||
[[ "$image" =~ ^${STAGING_REPOSITORY}@sha256:[a-f0-9]{64}$ ]]
|
||||
images+=("$image")
|
||||
done < <(jq -r '.include[].architecture' <<< "$MATRIX")
|
||||
candidate="$REPOSITORY:ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
|
||||
docker buildx imagetools create --tag "$candidate" "${images[@]}"
|
||||
digest=$(docker buildx imagetools inspect "$candidate" --format '{{.Manifest.Digest}}')
|
||||
[[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]
|
||||
image="$REPOSITORY@$digest"
|
||||
printf '%s\n' "$image" > evidence/final-image.txt
|
||||
printf '%s\n' "$REVISION" > evidence/source-revision.txt
|
||||
docker buildx imagetools inspect "$image" --raw > evidence/index.json
|
||||
docker buildx imagetools inspect "$image" --format '{{json .Provenance}}' > evidence/provenance.json
|
||||
python3 .github/scripts/image-evidence.py evidence
|
||||
# Every requested runnable platform must be present, with no silent omissions.
|
||||
jq -r '.manifests[] | select(.platform.os == "linux") |
|
||||
.platform | .architecture + (if .variant and .architecture == "arm" then "/" + .variant else "" end)' \
|
||||
evidence/index.json | sort > evidence/actual-platforms.txt
|
||||
jq -r '.include[].architecture' <<< "$MATRIX" | sort > evidence/expected-platforms.txt
|
||||
diff -u evidence/expected-platforms.txt evidence/actual-platforms.txt
|
||||
- name: Scan every final-index platform and enforce findings/errors
|
||||
run: |
|
||||
image=$(cat evidence/final-image.txt)
|
||||
failed=0
|
||||
while IFS= read -r arch; do
|
||||
# Generation uses exit-code 0 only for findings; the helper explicitly
|
||||
# blocks HIGH/CRITICAL/UNKNOWN and any scanner/inventory/SBOM error.
|
||||
if ! python3 .github/scripts/image-policy.py "$image" "evidence/${arch//\//-}" --platform "linux/$arch"; then
|
||||
failed=1
|
||||
fi
|
||||
done < <(jq -r '.include[].architecture' <<< "$MATRIX")
|
||||
exit "$failed"
|
||||
- name: Required release policy exception gate
|
||||
run: |
|
||||
# The product deliberately retains remote terminal/PTY support.
|
||||
# No approval exists. Keep this release-only gate separate from scans;
|
||||
# there is no dispatch input/environment variable that can waive it.
|
||||
cp .github/scripts/image-release-policy.json evidence/runtime-policy.json
|
||||
python3 .github/scripts/image-release-policy.py evidence/runtime-policy.json
|
||||
- name: Sign and verify the FINAL multi-platform digest using GitHub OIDC
|
||||
run: |
|
||||
image=$(cat evidence/final-image.txt)
|
||||
# Fulcio's SAN is job_workflow_ref for this local reusable workflow,
|
||||
# not the caller's filename. Bind the exact repository and executing ref.
|
||||
# For example: https://github.com/kerberos-io/agent/.github/workflows/image-release.yml@refs/heads/master
|
||||
identity="https://github.com/$GITHUB_REPOSITORY/.github/workflows/image-release.yml@$GITHUB_REF"
|
||||
printf '%s\n' "$identity" > evidence/signing-identity.txt
|
||||
cosign sign --yes --bundle evidence/signature.sigstore.json "$image"
|
||||
cosign verify --certificate-identity "$identity" \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
"$image" > evidence/signature-verification.json
|
||||
- name: Publish only the already verified and signed digest
|
||||
run: |
|
||||
image=$(cat evidence/final-image.txt)
|
||||
while IFS= read -r arch; do
|
||||
arch="${arch//\//-}"
|
||||
staged=$(cat "artifacts/image-build-$arch/image.txt")
|
||||
# Preserve the existing architecture tag names, after all gates.
|
||||
docker buildx imagetools create --tag "$STAGING_REPOSITORY:arch-$arch-$VERSION" "$staged"
|
||||
done < <(jq -r '.include[].architecture' <<< "$MATRIX")
|
||||
docker buildx imagetools create --tag "$REPOSITORY:$VERSION" "$image"
|
||||
[[ "$(docker buildx imagetools inspect "$REPOSITORY:$VERSION" --format '{{.Manifest.Digest}}')" == "${image##*@}" ]]
|
||||
if [[ "$PUBLISH_LATEST" == true ]]; then
|
||||
docker buildx imagetools create --tag "$REPOSITORY:latest" "$image"
|
||||
[[ "$(docker buildx imagetools inspect "$REPOSITORY:latest" --format '{{.Manifest.Digest}}')" == "${image##*@}" ]]
|
||||
fi
|
||||
- name: Retain final reports/SBOM/provenance/signature even on failure
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: image-final-evidence
|
||||
path: evidence/
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
- name: Attach binaries and all evidence, then publish GitHub release
|
||||
if: ${{ !inputs.nightly }}
|
||||
run: |
|
||||
tar -czf image-security-evidence.tar.gz evidence artifacts/image-*
|
||||
# Create a draft only if absent. API errors must not masquerade as absence.
|
||||
gh api --paginate "repos/$GITHUB_REPOSITORY/releases" --jq '.[]' | jq -s . > releases.json
|
||||
if ! jq -e --arg tag "$VERSION" '.[] | select(.tag_name == $tag)' releases.json >/dev/null; then
|
||||
gh release create "$VERSION" --target "$REVISION" --title "$VERSION" --notes "" --draft
|
||||
fi
|
||||
gh release upload "$VERSION" artifacts/agent-*.tar/agent-*.tar image-security-evidence.tar.gz --clobber
|
||||
gh release edit "$VERSION" --draft=false --latest --title "$VERSION"
|
||||
@@ -1,62 +1,19 @@
|
||||
name: Nightly build
|
||||
|
||||
on:
|
||||
# Triggers the workflow every day at 9PM (CET).
|
||||
schedule:
|
||||
- cron: "0 22 * * *"
|
||||
# Allows manual triggering from the Actions tab.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
nightly-build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [amd64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: master
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
- name: Run Buildx
|
||||
run: docker buildx build --build-arg VERSION=$(echo $GITHUB_SHA | cut -c1-7) --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
|
||||
- name: Create new and append to manifest
|
||||
run: docker buildx imagetools create -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
|
||||
nightly-build-other:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [arm64, arm/v7, arm/v6]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: master
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
- name: Run Buildx
|
||||
run: docker buildx build --build-arg VERSION=$(echo $GITHUB_SHA | cut -c1-7) --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
|
||||
- name: Create new and append to manifest
|
||||
run: docker buildx imagetools create --append -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
|
||||
nightly:
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/image-release.yml
|
||||
with:
|
||||
source_ref: master
|
||||
nightly: true
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,38 +1,97 @@
|
||||
name: Build pull request
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
env:
|
||||
REPO: kerberos/agent
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- architecture: amd64
|
||||
runner: ubuntu-24.04
|
||||
- architecture: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
env:
|
||||
ARCH: ${{ matrix.architecture }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --build-arg VERSION=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7) -t ${{ matrix.architecture }} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
- name: Test production image
|
||||
run: sh deployments/docker/test-image.sh ${{ matrix.architecture }}
|
||||
- name: Strip binary
|
||||
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
name: agent-${{matrix.architecture}}.tar
|
||||
path: agent-${{matrix.architecture}}.tar
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: source
|
||||
persist-credentials: false
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- name: Validate fail-closed policy helpers
|
||||
run: python3 .github/scripts/test-image-policy.py
|
||||
- name: Install scanner
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bash .github/scripts/install-trivy.sh
|
||||
- name: Build once, populating OCI metadata from CI
|
||||
env:
|
||||
BUILDX_METADATA_PROVENANCE: max
|
||||
run: |
|
||||
mkdir -p evidence/build .ci-tools
|
||||
revision=$(git -C source rev-parse HEAD)
|
||||
version="${revision:0:7}"
|
||||
created=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
printf '%s\n' "$revision" > evidence/build/source-revision.txt
|
||||
# A local Docker load cannot retain attestations. The second exporter
|
||||
# retains attestations without registry writes. Never upload the full
|
||||
# image as scan evidence: it may contain a secret that correctly failed scans.
|
||||
docker buildx build --platform "linux/$ARCH" --provenance=mode=max \
|
||||
--build-arg "VERSION=$version" --build-arg "REVISION=$revision" --build-arg "CREATED=$created" \
|
||||
--label "org.opencontainers.image.version=$version" \
|
||||
--label "org.opencontainers.image.revision=$revision" \
|
||||
--label "org.opencontainers.image.created=$created" \
|
||||
--label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \
|
||||
--metadata-file evidence/build/build-metadata.json --tag "$ARCH" \
|
||||
--output type=docker --output type=oci,dest=.ci-tools/pr-image.oci.tar source
|
||||
jq -e '."buildx.build.provenance" | select(type == "object" and length > 0)' \
|
||||
evidence/build/build-metadata.json > evidence/build/provenance.json
|
||||
python3 .github/scripts/image-evidence.py evidence/build
|
||||
- name: Extract release payload
|
||||
run: |
|
||||
cid=$(docker create "$ARCH")
|
||||
trap 'docker rm -f "$cid" >/dev/null' EXIT
|
||||
docker cp "$cid:/home/agent" "output-$ARCH"
|
||||
- name: Test production image
|
||||
run: sh source/deployments/docker/test-image.sh "$ARCH"
|
||||
- name: Strip binary
|
||||
run: tar -cf "agent-$ARCH.tar" -C "output-$ARCH" . && rm -rf "output-$ARCH"
|
||||
- name: Source/UI dependency and final image gates with SPDX SBOMs
|
||||
run: |
|
||||
failed=0
|
||||
if ! python3 .github/scripts/image-policy.py source evidence/source; then
|
||||
failed=1
|
||||
fi
|
||||
if ! python3 .github/scripts/image-policy.py "$ARCH" "evidence/$ARCH" --platform "linux/$ARCH"; then
|
||||
failed=1
|
||||
fi
|
||||
exit "$failed"
|
||||
- name: Upload artifact only after secret and vulnerability gates pass
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: agent-${{ matrix.architecture }}.tar
|
||||
path: agent-${{ matrix.architecture }}.tar
|
||||
if-no-files-found: error
|
||||
- name: Retain reports on failure
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: image-pr-evidence-${{ matrix.architecture }}
|
||||
path: evidence/
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -1,160 +1,36 @@
|
||||
name: Bump release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: "Which part of the version to bump"
|
||||
description: Which part of the version to bump
|
||||
required: true
|
||||
default: patch
|
||||
type: choice
|
||||
options:
|
||||
- major
|
||||
- minor
|
||||
- patch
|
||||
options: [major, minor, patch]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
REPO: kerberos/agent
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Determine the next version, create the GitHub release and expose the tag.
|
||||
bump-release:
|
||||
uses: uug-ai/workflows/.github/workflows/release-bump.yml@main
|
||||
permissions:
|
||||
contents: write
|
||||
# Verified revision supports reserving a tag with an unpublished draft.
|
||||
uses: uug-ai/workflows/.github/workflows/release-bump.yml@f31d4260a833e82228ca0e9b9163231a970feeb8
|
||||
with:
|
||||
bump: ${{ github.event.inputs.bump }}
|
||||
bump: ${{ inputs.bump }}
|
||||
draft: true
|
||||
secrets: inherit
|
||||
|
||||
# Publish the platform image to the uug-ai GitHub Container Registry
|
||||
# (ghcr.io/uug-ai/agent-platform).
|
||||
#release:
|
||||
# needs: bump-release
|
||||
# uses: uug-ai/workflows/.github/workflows/release-create.yml@main
|
||||
# with:
|
||||
# organization: uug-ai
|
||||
# project: ${{ github.event.repository.name }}
|
||||
# tag: ${{ needs.bump-release.outputs.tag }}
|
||||
# docker_context: "."
|
||||
# create_gitops_pr: false
|
||||
# runner_matrix: >-
|
||||
# [
|
||||
# {"architecture":"amd64","runner":"ubuntu-24.04"},
|
||||
# {"architecture":"arm64","runner":"ubuntu-24.04-arm"}
|
||||
# ]
|
||||
# secrets: inherit
|
||||
|
||||
# Everything below mirrors the agent's own release-create.yml pipeline and
|
||||
# publishes the multi-arch image to the kerberos/agent Docker Hub repo, driven
|
||||
# by the freshly bumped tag instead of a `release: created` event.
|
||||
build-amd64:
|
||||
release:
|
||||
needs: bump-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [amd64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
- name: Strip binary
|
||||
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
docker push $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-${{matrix.architecture}}.tar
|
||||
path: agent-${{matrix.architecture}}.tar
|
||||
|
||||
build-arm64:
|
||||
needs: bump-release
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [arm64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
- name: Strip binary
|
||||
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
docker push $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-${{matrix.architecture}}.tar
|
||||
path: agent-${{matrix.architecture}}.tar
|
||||
|
||||
create-manifest:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [bump-release, build-amd64, build-arm64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Create and push multi-arch manifest
|
||||
run: |
|
||||
docker manifest create $REPO:${{ needs.bump-release.outputs.tag }} \
|
||||
$REPO-arch:arch-amd64-${{ needs.bump-release.outputs.tag }} \
|
||||
$REPO-arch:arch-arm64-${{ needs.bump-release.outputs.tag }}
|
||||
docker manifest push $REPO:${{ needs.bump-release.outputs.tag }}
|
||||
- name: Create and push latest manifest
|
||||
run: |
|
||||
docker manifest create $REPO:latest \
|
||||
$REPO-arch:arch-amd64-${{ needs.bump-release.outputs.tag }} \
|
||||
$REPO-arch:arch-arm64-${{ needs.bump-release.outputs.tag }}
|
||||
docker manifest push $REPO:latest
|
||||
|
||||
create-release:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [bump-release, build-amd64, build-arm64]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Create a release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
latest: true
|
||||
allowUpdates: true
|
||||
name: ${{ needs.bump-release.outputs.tag }}
|
||||
tag: ${{ needs.bump-release.outputs.tag }}
|
||||
generateReleaseNotes: false
|
||||
omitBodyDuringUpdate: true
|
||||
artifacts: "agent-*.tar/agent-*.tar"
|
||||
|
||||
|
||||
id-token: write
|
||||
uses: ./.github/workflows/image-release.yml
|
||||
with:
|
||||
tag: ${{ needs.bump-release.outputs.tag }}
|
||||
source_ref: ${{ format('refs/tags/{0}', needs.bump-release.outputs.tag) }}
|
||||
publish_latest: true
|
||||
secrets: inherit
|
||||
|
||||
@@ -5,118 +5,21 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag for the Docker image"
|
||||
description: Tag for the Docker image
|
||||
required: true
|
||||
default: "test"
|
||||
default: test
|
||||
|
||||
env:
|
||||
REPO: kerberos/agent
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-amd64:
|
||||
runs-on: ubuntu-24.04
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [amd64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --provenance=false --build-arg VERSION=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
- name: Strip binary
|
||||
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{github.event.inputs.tag || github.ref_name}}
|
||||
docker push $REPO-arch:arch-${{matrix.architecture}}-${{github.event.inputs.tag || github.ref_name}}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-${{matrix.architecture}}.tar
|
||||
path: agent-${{matrix.architecture}}.tar
|
||||
|
||||
build-arm64:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [arm64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --provenance=false --build-arg VERSION=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
- name: Strip binary
|
||||
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{github.event.inputs.tag || github.ref_name}}
|
||||
docker push $REPO-arch:arch-${{matrix.architecture}}-${{github.event.inputs.tag || github.ref_name}}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-${{matrix.architecture}}.tar
|
||||
path: agent-${{matrix.architecture}}.tar
|
||||
|
||||
create-manifest:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [build-amd64, build-arm64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Create and push multi-arch manifest
|
||||
run: |
|
||||
docker manifest create $REPO:${{ github.event.inputs.tag || github.ref_name }} \
|
||||
$REPO-arch:arch-amd64-${{github.event.inputs.tag || github.ref_name}} \
|
||||
$REPO-arch:arch-arm64-${{github.event.inputs.tag || github.ref_name}}
|
||||
docker manifest push $REPO:${{ github.event.inputs.tag || github.ref_name }}
|
||||
- name: Create and push latest manifest
|
||||
run: |
|
||||
docker manifest create $REPO:latest \
|
||||
$REPO-arch:arch-amd64-${{github.event.inputs.tag || github.ref_name}} \
|
||||
$REPO-arch:arch-arm64-${{github.event.inputs.tag || github.ref_name}}
|
||||
docker manifest push $REPO:latest
|
||||
if: github.event.inputs.tag == 'test'
|
||||
|
||||
create-release:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [build-amd64, build-arm64]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Create a release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
latest: true
|
||||
allowUpdates: true
|
||||
name: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
tag: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
generateReleaseNotes: false
|
||||
omitBodyDuringUpdate: true
|
||||
artifacts: "agent-*.tar/agent-*.tar"
|
||||
id-token: write
|
||||
uses: ./.github/workflows/image-release.yml
|
||||
with:
|
||||
tag: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
source_ref: ${{ github.event_name == 'release' && format('refs/tags/{0}', github.event.release.tag_name) || github.sha }}
|
||||
publish_latest: ${{ github.event_name == 'workflow_dispatch' && inputs.tag == 'test' }}
|
||||
secrets: inherit
|
||||
|
||||
Reference in New Issue
Block a user