Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ concurrency:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
# An explicit dispatch ref may select a tag independently of the workflow ref.
CI_USE_CACHE: ${{ github.ref_type != 'tag' && !(github.event_name == 'workflow_dispatch' && inputs.ref != '') }}

jobs:
# Fast, compile-free gate: formatting and script-based source proofs.
Expand Down Expand Up @@ -59,6 +61,7 @@ jobs:
node scripts/people-discovery-smoke.mjs
- name: release artifact source proofs
run: |
python3 scripts/ci-release-policy-test.py
python3 scripts/components-release-integrity-check.py --self-test
python3 scripts/publish-platform-artifacts-test.py
python3 scripts/release-platform-input-test.py
Expand All @@ -81,6 +84,7 @@ jobs:
with:
components: clippy
- uses: Swatinem/rust-cache@v2
if: env.CI_USE_CACHE == 'true'
with:
workspaces: elastos
prefix-key: ${{ runner.os }}-elastos-bd
Expand All @@ -103,6 +107,7 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
- uses: dtolnay/rust-toolchain@1.91.0
- uses: Swatinem/rust-cache@v2
if: env.CI_USE_CACHE == 'true'
with:
workspaces: elastos
prefix-key: ${{ runner.os }}-elastos-test-bd
Expand All @@ -112,6 +117,7 @@ jobs:
# run otherwise; with the cache restored, cargo only re-fingerprints
# the workspace members and reuses every dependency artifact.
- name: cache provider build artifacts
if: env.CI_USE_CACHE == 'true'
uses: actions/cache@v4
with:
path: |
Expand Down Expand Up @@ -149,6 +155,7 @@ jobs:
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
if: env.CI_USE_CACHE == 'true'
with:
# Registry/index cache only: this job never builds the elastos
# workspace, and the capsule build artifacts live in the shared
Expand All @@ -162,6 +169,7 @@ jobs:
# The key rotates with any capsule lockfile or toolchain change, which
# bounds stale-artifact growth under the restore-keys fallback.
- name: cache capsule build artifacts
if: env.CI_USE_CACHE == 'true'
uses: actions/cache@v4
with:
# The shared intermediates (root .cargo/config.toml build-dir);
Expand Down Expand Up @@ -251,7 +259,7 @@ jobs:
# Builds the host `elastos` binary the smoke script itself invokes
# (identity show, node peer add, node status) -- same cache shape as
# the test-elastos/lint jobs above.
- if: steps.should-run.outputs.run == 'true'
- if: steps.should-run.outputs.run == 'true' && env.CI_USE_CACHE == 'true'
uses: Swatinem/rust-cache@v2
with:
workspaces: elastos
Expand All @@ -266,11 +274,14 @@ jobs:
- name: pre-build custody-host image (GHA layer cache)
if: steps.should-run.outputs.run == 'true'
run: |
cache_args=()
if [[ "${CI_USE_CACHE}" == "true" ]]; then
cache_args+=(--cache-from type=gha --cache-to type=gha,mode=max)
fi
docker buildx build \
-f deploy/custody-host/Dockerfile \
-t elastos-custody-host:latest \
--cache-from type=gha \
--cache-to type=gha,mode=max \
"${cache_args[@]}" \
--load \
.
- name: custody harness smoke
Expand Down Expand Up @@ -312,6 +323,7 @@ jobs:
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
if: env.CI_USE_CACHE == 'true'
with:
workspaces: elastos
prefix-key: ${{ runner.os }}-${{ runner.arch }}-elastos-bd
Expand Down Expand Up @@ -393,6 +405,7 @@ jobs:
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
if: env.CI_USE_CACHE == 'true'
with:
workspaces: elastos
prefix-key: ${{ runner.os }}-elastos-bd
Expand All @@ -403,6 +416,7 @@ jobs:
# Cache the verified release tarball and pre-seed bin/kubo so setup
# skips the download entirely (ELACITY-2308).
- name: cache kubo release
if: env.CI_USE_CACHE == 'true'
uses: actions/cache@v4
with:
path: ${{ runner.temp }}/kubo-cache
Expand Down Expand Up @@ -473,7 +487,7 @@ jobs:
name: publish-github-release
runs-on: ubuntu-24.04
timeout-minutes: 15
if: startsWith(github.ref, 'refs/tags/v')
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs:
- lint
- test-elastos
Expand Down
178 changes: 178 additions & 0 deletions scripts/ci-release-policy-test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Check CI release/cache decisions without builds, Docker, or publication."""
import os
from pathlib import Path
import re
import subprocess
import textwrap
import unittest


WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/ci.yml"
SOURCE = WORKFLOW.read_text()
# Read the fixed job/step indentation used here; actionlint checks YAML syntax.
def jobs(source):
return dict(re.findall(r"(?ms)^ ([\w-]+):\n(.*?)(?=^ [\w-]+:\n|\Z)",
source.split("\njobs:\n", 1)[1]))


JOBS = jobs(SOURCE)
CACHE_RE = re.compile(
r"uses: (?:Swatinem/rust-cache|actions/cache(?:/restore|/save)?)@"
r"|^\s+cache(?:-from|-to)?:|type=gha", re.M)


def field(block, name):
match = re.search(rf"(?m)^\s*{re.escape(name)}: ([^\n]+)$", block)
if not match:
raise AssertionError(f"missing {name}")
return match[1]


def steps(job, workflow_jobs=JOBS):
return re.split(r"(?m)^ - ", workflow_jobs[job].split(" steps:\n", 1)[1])[1:]


def evaluate(expression, context):
# eval accepts only this repository's own ci.yml expressions, never external input.
expression = expression.removeprefix("${{").removesuffix("}}").strip()
expression = expression.replace("&&", " and ").replace("||", " or ")
expression = re.sub(r"!(?!=)", " not ", expression)
expression = re.sub(r"(?:github|inputs|env|steps)\.[\w.-]+",
lambda match: repr(context[match[0]]), expression)
return bool(eval(expression, {"__builtins__": {}}, {"startsWith": str.startswith}))


# event, workflow ref, ref type, checkout override, caches, publication
CASES = [
("push", "refs/heads/main", "branch", "", True, False),
("push", "refs/heads/v-work", "branch", "", True, False),
("push", "refs/tags/v0.7.1", "tag", "", False, True),
("push", "refs/tags/candidate", "tag", "", False, False),
("pull_request", "refs/pull/1/merge", "branch", "", True, False),
("workflow_dispatch", "refs/heads/main", "branch", "", True, False),
("workflow_dispatch", "refs/tags/v0.7.1", "tag", "", False, False),
("workflow_dispatch", "refs/heads/main", "branch", "v0.7.1", False, False),
("workflow_dispatch", "refs/heads/main", "branch", "refs/tags/v0.7.1", False, False),
("workflow_dispatch", "refs/heads/main", "branch", "refs/heads/work", False, False),
("workflow_dispatch", "refs/heads/main", "branch", "a" * 40, False, False),
("workflow_dispatch", "refs/tags/v0.7.1", "tag", "main", False, False),
]


def validate_cache_guards(source):
workflow_jobs = jobs(source)
for job in workflow_jobs:
for step in steps(job, workflow_jobs):
matches = list(CACHE_RE.finditer(step))
if not matches:
continue
# Buildx shell cache arguments live inside this explicit cache-only branch.
shell_guards = list(re.finditer(
r'(?ms)^\s*if \[\[ "\$\{CI_USE_CACHE\}" == "true" \]\]; then\n'
r'(.*?)^\s*fi\s*$', step))
shell_guards = [guard for guard in shell_guards
if not re.search(r"(?m)^\s*(?:else|elif)\b", guard[1])]
remaining = [match for match in matches
if not (match[0] == "type=gha" and any(
guard.start(1) <= match.start() < guard.end(1)
for guard in shell_guards))]
if not remaining:
continue
guard = field(step, "if")
if "env.CI_USE_CACHE == 'true'" not in guard:
raise AssertionError(f"unguarded cache in {job}")
for event, ref, ref_type, override, cached, _ in CASES:
if cached:
continue
context = {"github.event_name": event, "github.ref": ref,
"github.ref_type": ref_type, "inputs.ref": override,
"env.CI_USE_CACHE": "false",
"steps.should-run.outputs.run": "true"}
if evaluate(guard, context):
raise AssertionError(f"cache guard permits uncached build in {job}")


class ReleasePolicyTests(unittest.TestCase):
def test_event_ref_matrix_controls_publication_and_every_cache_action(self):
validate_cache_guards(SOURCE)
caches = [(job, step) for job in JOBS for step in steps(job)
if CACHE_RE.search(step) and "type=gha" not in step]
self.assertEqual(len(caches), 9)
for event, ref, ref_type, override, cached, publish in CASES:
with self.subTest(event=event, ref=ref, override=override):
context = {"github.event_name": event, "github.ref": ref,
"github.ref_type": ref_type, "inputs.ref": override}
use_cache = evaluate(field(SOURCE, "CI_USE_CACHE"), context)
self.assertEqual(use_cache, cached)
self.assertEqual(evaluate(field(JOBS["release"], "if"), context), publish)
context["env.CI_USE_CACHE"] = str(use_cache).lower()
for should_run in (True, False):
context["steps.should-run.outputs.run"] = str(should_run).lower()
for job, step in caches:
self.assertEqual(evaluate(field(step, "if"), context),
cached and (job != "custody-harness-smoke" or should_run),
f"cache guard in {job}")

def test_unguarded_cache_paths_are_rejected(self):
additions = [
" - uses: actions/cache/restore@v4\n",
" - uses: actions/cache/save@v4\n",
" - uses: actions/setup-node@v4\n with:\n cache: npm\n",
" - uses: docker/build-push-action@v6\n with:\n cache-from: type=gha\n",
" - uses: docker/build-push-action@v6\n with:\n cache-to: type=gha,mode=max\n",
" - run: docker buildx build --cache-from type=gha .\n",
' - run: |\n if [[ "${CI_USE_CACHE}" == "true" ]]; then\n'
' echo cached\n else\n'
' docker buildx build --cache-from type=gha .\n fi\n',
" - if: env.CI_USE_CACHE == 'true' || startsWith(github.ref, 'refs/tags/')\n"
" uses: actions/cache/restore@v4\n",
]
for addition in additions:
with self.subTest(step=addition):
injected = SOURCE.replace(" steps:\n", " steps:\n" + addition, 1)
with self.assertRaises(AssertionError):
validate_cache_guards(injected)

def test_buildx_imports_and_exports_layers_only_when_cache_is_enabled(self):
step, = [step for step in steps("custody-harness-smoke")
if "docker buildx build" in step]
script = textwrap.dedent(step.split(" run: |\n", 1)[1])
# Execute the actual workflow shell with a recording function, not Docker.
stub = 'docker() { printf "%s\\0" "$@"; }\n'
base = ["buildx", "build", "-f", "deploy/custody-host/Dockerfile",
"-t", "elastos-custody-host:latest"]
for enabled in (True, False):
with self.subTest(enabled=enabled):
result = subprocess.run(["bash", "-eo", "pipefail", "-c", stub + script],
env={**os.environ, "CI_USE_CACHE": str(enabled).lower()},
capture_output=True, check=True)
args = result.stdout.decode().split("\0")[:-1]
cache = ["--cache-from", "type=gha", "--cache-to", "type=gha,mode=max"] if enabled else []
self.assertEqual(args, base + cache + ["--load", "."])

def test_github_runners_check_names_and_release_dependencies_stay_fixed(self):
expected = {
"source-gate": ("source-gate", "ubuntu-24.04"),
"lint": ("lint", "ubuntu-24.04"),
"test-elastos": ("test-elastos", "ubuntu-24.04"),
"test-capsules": ("test-capsules", "ubuntu-24.04"),
"custody-harness-smoke": ("custody-harness-smoke", "ubuntu-24.04"),
"source-home-linux": ("source-home-linux (${{ matrix.os }})", "${{ matrix.os }}"),
"source-home-macos": ("source-home-macos", "macos-14"),
"release": ("publish-github-release", "ubuntu-24.04"),
}
self.assertEqual(set(JOBS), set(expected))
for job, (name, runner) in expected.items():
self.assertEqual(field(JOBS[job], "name"), name)
self.assertEqual(field(JOBS[job], "runs-on"), runner)
self.assertEqual(field(JOBS["source-home-linux"], "os"),
"[ubuntu-24.04, ubuntu-24.04-arm]")
needs = JOBS["release"].split(" needs:\n", 1)[1].split(" permissions:\n", 1)[0]
self.assertEqual(re.findall(r"- ([\w-]+)", needs),
["lint", "test-elastos", "test-capsules", "source-home-linux", "source-home-macos"])
self.assertIn("python3 scripts/ci-release-policy-test.py", JOBS["source-gate"])


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