Skip to content
Open
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
5 changes: 4 additions & 1 deletion .github/workflows/acknowledg-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
# Host, paths, and credentials come from secrets in the `acknowledg`
# environment (see dev_tools/services/acknowledg/README.md). The SSH setup
# writes them into a `Host acknowledg` alias so they never appear on a command
# line. The public API URL is the ACKNOWLEDG_API_HOST repository variable.
# line. The public API URL is the ACKNOWLEDG_API_HOST repository variable,
# shared with acknowledg-forge-deploy.yml.

name: AcknowlEDG Deploy

Expand All @@ -27,6 +28,8 @@ on:
paths:
- dev_tools/services/acknowledg/**
- dev_tools/pylibs/edgacknowledg/**
# acknowledg-forge is a separate service.
- '!dev_tools/pylibs/edgacknowledg/forge/**'
- .github/workflows/acknowledg-deploy.yml
workflow_dispatch:

Expand Down
138 changes: 138 additions & 0 deletions .github/workflows/acknowledg-forge-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM
# Exceptions.
# See https://edgcpp.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#
# Publish the acknowledg-forge container image and deploy it:
#
# test the acknowledg-forge typecheck and tests (acknowledg-forge.yml)
# publish build the image (build-edg-env acknowledg-forge) and push it to
# ghcr.io/edgcpp/acknowledg-forge:latest (deployments use the
# pushed digest)
# deploy over SSH, run the host's deploy helper (an administrator-
# installed copy of
# dev_tools/services/acknowledg-forge/deploy-remote.py, the only
# thing the deploy user may run as root) to point the service at
# the pushed digest, set its AcknowlEDG API and UI URLs, and
# restart it (only if any of those changed)
#
# The image is public and holds no credentials or configuration: the publish
# job has no access to the deployment's secrets. Everything else about the
# service (its unit, tokens, API key) is configured on the host.
#
# Host, user, and SSH credentials come from secrets in the
# `acknowledg-forge` environment; the public AcknowlEDG URLs come from
# repository variables shared with acknowledg-deploy.yml (see
# dev_tools/services/acknowledg-forge/README.md).

name: acknowledg-forge Deploy

on:
push:
branches:
- main
paths:
# The image freezes all of dev_tools/bin and dev_tools/pylibs.
- dev_tools/bin/**
- dev_tools/pylibs/**
- dev_tools/services/acknowledg-forge/**
- dev_tools/docker/acknowledg-forge/**
- .github/workflows/acknowledg-forge.yml
- .github/workflows/acknowledg-forge-deploy.yml
workflow_dispatch:

concurrency:
group: acknowledg-forge-deploy
cancel-in-progress: false

permissions:
contents: read

jobs:
test:
name: Test
if: github.ref == 'refs/heads/main'
uses: ./.github/workflows/acknowledg-forge.yml

publish:
name: Publish image
needs: test
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.push.outputs.image }}
steps:
- name: Check out repository
uses: actions/checkout@v7

- name: Set up uv
uses: astral-sh/[email protected]

- name: Build image
env:
PYTHONPATH: ${{ github.workspace }}/dev_tools/pylibs
run: dev_tools/bin/build-edg-env --quiet acknowledg-forge

- name: Push image
id: push
env:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
image=ghcr.io/edgcpp/acknowledg-forge
printf '%s' "${REGISTRY_TOKEN}" \
| docker login ghcr.io --username "${GITHUB_ACTOR}" \
--password-stdin
docker push --quiet "${image}:latest"
docker logout ghcr.io
digest="$(docker inspect \
--format '{{range .RepoDigests}}{{println .}}{{end}}' \
"${image}:latest" | grep "^${image}@sha256:" | head -n 1)"
echo "Pushed ${digest}"
echo "image=${digest}" >> "${GITHUB_OUTPUT}"

deploy:
name: Deploy
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 15
environment: acknowledg-forge
steps:
- name: Configure SSH
env:
SSH_HOST: ${{ secrets.ACKNOWLEDG_FORGE_SSH_HOST }}
SSH_USER: ${{ secrets.ACKNOWLEDG_FORGE_SSH_USER }}
SSH_PORT: ${{ secrets.ACKNOWLEDG_FORGE_SSH_PORT || '22' }}
SSH_PRIVATE_KEY: ${{ secrets.ACKNOWLEDG_FORGE_SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.ACKNOWLEDG_FORGE_SSH_KNOWN_HOSTS }}
run: |
install -d -m 700 ~/.ssh
install -m 600 /dev/null ~/.ssh/deploy_key
printf '%s\n' "${SSH_PRIVATE_KEY}" > ~/.ssh/deploy_key
printf '%s\n' "${SSH_KNOWN_HOSTS}" > ~/.ssh/known_hosts
cat > ~/.ssh/config <<EOF
Host acknowledg-forge
HostName ${SSH_HOST}
User ${SSH_USER}
Port ${SSH_PORT}
IdentityFile ~/.ssh/deploy_key
IdentitiesOnly yes
StrictHostKeyChecking yes
BatchMode yes
EOF
chmod 600 ~/.ssh/config ~/.ssh/known_hosts

- name: Update and restart the service
env:
SERVICE: ${{ secrets.ACKNOWLEDG_FORGE_SERVICE || 'acknowledg-forge-github' }}
IMAGE: ${{ needs.publish.outputs.image }}
API_URL: ${{ vars.ACKNOWLEDG_API_HOST }}
UI_URL: ${{ vars.ACKNOWLEDG_UI_HOST }}
run: |
# The arguments are deliberately expanded (and quoted) here.
# shellcheck disable=SC2029
ssh acknowledg-forge "sudo -n /usr/local/libexec/acknowledg-forge-deploy \
$(printf '%q ' "${SERVICE}" "${IMAGE}" "${API_URL}" \
"${UI_URL}")"
86 changes: 86 additions & 0 deletions .github/workflows/acknowledg-forge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM
# Exceptions.
# See https://edgcpp.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#
# Typecheck and test acknowledg-forge: the shared edgacknowledg.forge
# package, the GitHub and GitLab services, and their integration with the
# AcknowlEDG client library and server.

name: acknowledg-forge

on:
push:
branches:
- main
paths:
- dev_tools/pylibs/edgacknowledg/**
- dev_tools/pylibs/edgtest/**
- dev_tools/pylibs/edgutil/**
- dev_tools/services/acknowledg/server/**
- dev_tools/services/acknowledg-forge/**
- dev_tools/docker/acknowledg-forge/**
- .github/workflows/acknowledg-forge.yml
pull_request:
paths:
- dev_tools/pylibs/edgacknowledg/**
- dev_tools/pylibs/edgtest/**
- dev_tools/pylibs/edgutil/**
- dev_tools/services/acknowledg/server/**
- dev_tools/services/acknowledg-forge/**
- dev_tools/docker/acknowledg-forge/**
- .github/workflows/acknowledg-forge.yml
# Also run by acknowledg-forge-deploy before publishing an image.
workflow_call:

permissions:
contents: read

jobs:
test:
name: Typecheck and test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v7

- name: Set up uv
uses: astral-sh/[email protected]

- name: Typecheck
run: dev_tools/services/acknowledg-forge/typecheck.sh

- name: Test
run: dev_tools/services/acknowledg-forge/tests/run-tests.sh -v

containers:
# The deployment itself (compose.yaml): what each container sees and
# can reach, on the runners' stock Docker and AppArmor (nothing to set
# up on the host, which is the point), on the current LTS and the
# previous one.
name: Containers (${{ matrix.runner }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
runner: [ubuntu-24.04, ubuntu-26.04]
steps:
- name: Check out repository
uses: actions/checkout@v7

- name: Set up uv
uses: astral-sh/[email protected]

- name: Build image
env:
PYTHONPATH: ${{ github.workspace }}/dev_tools/pylibs
run: dev_tools/bin/build-edg-env --quiet acknowledg-forge

- name: Test
env:
ACKNOWLEDG_FORGE_TEST_IMAGE: ghcr.io/edgcpp/acknowledg-forge:latest
run: |
dev_tools/services/acknowledg-forge/tests/run-tests.sh -v -rs \
test_containers.py
61 changes: 50 additions & 11 deletions dev_tools/bin/edg-rematerialize-test-run
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ def collect_affected_tests(test_home: Path,

tests_dir = test_home / 'tests'
expectations_dir = test_home / 'expectations'
# The test files themselves and the default recordings are what the default
# configuration uses.
default_config = edgtest.runtest.get_default_config_name()

for diff_file in diff_files:
file_path = test_home.parent.joinpath(PurePosixPath(diff_file.file_name))
Expand All @@ -130,10 +133,12 @@ def collect_affected_tests(test_home: Path,
# A file in a test suite.
orig_file_path = file_path

file_extensions = ''.join(file_path.suffixes)
# The extensions are listed without their leading '.'.
file_extensions = ''.join(file_path.suffixes)[1:]
if file_extensions in edgtest.DEFAULT_TEST_EXTENSIONS:
# A test file itself.
result.tests.add(file_path)
result.configs.add(default_config)
continue

test_file_found = False
Expand All @@ -154,8 +159,11 @@ def collect_affected_tests(test_home: Path,
)
if candidate_test_file.exists():
result.tests.add(candidate_test_file)
if orig_file_path.stem != 'default':
result.configs.add(orig_file_path.stem)
# Recordings are named <config>.<case>.<command>.txt.
recording_config = orig_file_path.name.split('.')[0]
if recording_config == 'default':
recording_config = default_config
result.configs.add(recording_config)
break
test_file_found = True
else:
Expand Down Expand Up @@ -195,12 +203,16 @@ async def patch_file(output_dir: Path,
input_dir: Path,
relative_path: PurePosixPath,
diff_file_path: Path) -> bool:
output_path = output_dir / relative_path
# A new file (e.g. a new test's recording) has no copied directory to be
# written into.
output_path.parent.mkdir(parents = True, exist_ok = True)
patch_proc = await asyncio.create_subprocess_exec(
str(TOOL_PATH_PATCH),
str(input_dir.joinpath(relative_path)),
str(input_dir / relative_path),
str(diff_file_path),
'-o',
str(output_dir.joinpath(relative_path)),
str(output_path),
stdout = asyncio.subprocess.DEVNULL
)
await patch_proc.wait()
Expand Down Expand Up @@ -236,6 +248,9 @@ async def materialize_tests(loop: asyncio.AbstractEventLoop,

# First create a copy of all relevant tests.
for test in test_change_state.tests:
if not test.exists():
# A new test; the patch creates all of its files.
continue
relative_dir = test.parent.relative_to(tests_dir)
edgtest.copy_raw_test_files(test, output_dir.joinpath(relative_dir))

Expand Down Expand Up @@ -375,7 +390,7 @@ async def simulate_test(test_home: Path, config_run_dir: Path,

command_metadata = []
matches_metadata = None
any_mismatch = False
comparisons: List[edgtest.exec.OutputComparison] = []

for command_number in range(1, num_commands + 1):
cmd_dir = case_dir / str(command_number)
Expand Down Expand Up @@ -427,8 +442,7 @@ async def simulate_test(test_home: Path, config_run_dir: Path,
'change.diff'
)

if output_cmp_result != edgtest.exec.OutputComparison.EQUAL:
any_mismatch = True
comparisons.append(output_cmp_result)

command_metadata.append({
'command': command_str,
Expand Down Expand Up @@ -471,7 +485,14 @@ async def simulate_test(test_home: Path, config_run_dir: Path,
'matches',
'change.diff'
)
any_mismatch = True

# As edgy compares a case's matches (process_matches_mismatch).
if old_matches is None:
comparisons.append(edgtest.exec.OutputComparison.PREV_MISSING)
elif matches_diff_hash is None:
comparisons.append(edgtest.exec.OutputComparison.EQUAL)
else:
comparisons.append(edgtest.exec.OutputComparison.MISMATCH)

matches_metadata = {'diff_hash': matches_diff_hash}

Expand All @@ -482,9 +503,13 @@ async def simulate_test(test_home: Path, config_run_dir: Path,
old_status = get_status_for_test_key(old_expectations, key_str)
new_status = get_status_for_test_key(new_expectations, key_str)

# Add the mismatch string if needed.
if any_mismatch:
# Add the output comparison string as edgy does (e.g. a new test has no
# previous output).
case_comparison = edgtest.exec.aggregate_output_comparison(comparisons)
if case_comparison == edgtest.exec.OutputComparison.MISMATCH:
new_status = f"{new_status}--OUTPUT MISMATCH"
elif case_comparison == edgtest.exec.OutputComparison.PREV_MISSING:
new_status = f"{new_status}--NO PREVIOUS OUTPUT"

case_entry = {
'status': {
Expand Down Expand Up @@ -625,6 +650,7 @@ async def simulate_config_testing(
new_parent_dir
)

tests = sorted(tests)
for test_path in tests:
rel_test_path_str = str(
test_path.relative_to(test_home / 'tests')
Expand All @@ -647,6 +673,19 @@ async def simulate_config_testing(
for status_line in test_status_lines:
file_handle.write(status_line)

# As edgy does, list the tests whose status changed (by their path relative
# to the tests directory, which is where their .rt.tar files are in the
# configuration's run directory) so edgy-review and others can find them.
changed_tests = [
test_path.relative_to(test_home / 'tests').as_posix()
for test_path, test_status_lines in zip(tests, tests_status_lines)
if len(test_status_lines) > 0
]
if len(changed_tests) > 0:
with open(config_run_dir / 'changes.elist', 'w') as file_handle:
for changed_test in changed_tests:
file_handle.write(f"{changed_test}\n")

async def simulate_testing(loop: asyncio.AbstractEventLoop,
test_home: Path, new_run_dir: Path,
patched_tests_dir: Path,
Expand Down
Loading