diff --git a/.devcontainer/cpp/devcontainer-metadata-vscode.json b/.devcontainer/cpp/devcontainer-metadata.json similarity index 100% rename from .devcontainer/cpp/devcontainer-metadata-vscode.json rename to .devcontainer/cpp/devcontainer-metadata.json diff --git a/.devcontainer/rust/devcontainer-metadata-vscode.json b/.devcontainer/rust/devcontainer-metadata.json similarity index 100% rename from .devcontainer/rust/devcontainer-metadata-vscode.json rename to .devcontainer/rust/devcontainer-metadata.json diff --git a/.github/instructions/workflows.instructions.md b/.github/instructions/workflows.instructions.md new file mode 100644 index 00000000..2c254630 --- /dev/null +++ b/.github/instructions/workflows.instructions.md @@ -0,0 +1,13 @@ +--- +applyTo: ".github/workflows/*.yml" +--- + +# GitHub Workflows Guidelines + +When writing GitHub Action workflows, ensure that: + +- Workflows that have a workflow_call trigger have their filename prefixed with `wc-`. +- For all re-usable workflows, only the top-level workflow (workflows that are not called themselves by other workflows with workflow_call) has defaults and descriptions for inputs to avoid duplication. +- All workflows and action definitions have a name that is descriptive and concise, using emoji where appropriate. +- The sorting order for inputs, secrets, and outputs is alphabetical. +- The sorting order of other keys is consistent across the repository. diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 3de63165..99d13f86 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -1,5 +1,5 @@ --- -name: Continuous Integration +name: CI on: merge_group: @@ -14,6 +14,10 @@ permissions: {} jobs: build-push-test: + name: ๐Ÿ› ๏ธ Build โ†’ Push โ†’ Test (๐Ÿจ ${{ matrix.flavor }}) + strategy: + matrix: + flavor: [cpp, rust] uses: ./.github/workflows/wc-build-push-test.yml secrets: TEST_GITHUB_TOKEN: ${{ secrets.TEST_GITHUB_TOKEN }} @@ -28,3 +32,45 @@ jobs: id-token: write packages: write pull-requests: write + with: + devcontainer-metadata-file: .devcontainer/${{ matrix.flavor }}/devcontainer-metadata.json + dockerfile: .devcontainer/${{ matrix.flavor }}/Dockerfile + image-name: ${{ github.repository }}-${{ matrix.flavor }} + integration-test-file: test/${{ matrix.flavor }}/integration-tests.bats + acceptance-test-path: ${{ matrix.flavor == 'cpp' && 'test/cpp/features' || '' }} + test-devcontainer-file: ${{ matrix.flavor == 'cpp' && '.devcontainer/cpp-test/devcontainer.json' || '' }} + + dependency-review: + name: ๐Ÿ” Dependency Review + needs: build-push-test + uses: ./.github/workflows/wc-dependency-review.yml + permissions: + contents: read + pull-requests: write + + publish-test-results: + name: ๐Ÿ“Š Publish Test Results + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + needs: build-push-test + if: ${{ !cancelled() }} + steps: + - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + disable-sudo: true + egress-policy: audit + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + merge-multiple: true + pattern: test-results-* + - uses: EnricoMi/publish-unit-test-result-action@3a74b2957438d0b6e2e61d67b05318aa25c9e6c6 # v2.20.0 + with: + files: test-report-*.xml + + generate-documents: + name: ๐Ÿ“„ Documentation + uses: ./.github/workflows/wc-document-generation.yml + permissions: + contents: read diff --git a/.github/workflows/image-cleanup.yml b/.github/workflows/image-cleanup.yml index 8c519166..81ecbe23 100644 --- a/.github/workflows/image-cleanup.yml +++ b/.github/workflows/image-cleanup.yml @@ -9,7 +9,8 @@ on: permissions: {} jobs: - delete-images: + cleanup-images: + name: ๐Ÿงน Clean Images runs-on: ubuntu-latest permissions: # dataaxiom/ghcr-cleanup-action needs packages write permission @@ -19,7 +20,6 @@ jobs: - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 with: disable-sudo: true - egress-policy: block allowed-endpoints: > api.github.com:443 ghcr.io:443 diff --git a/.github/workflows/issue-cleanup.yml b/.github/workflows/issue-cleanup.yml index eb938768..dbebf1c6 100644 --- a/.github/workflows/issue-cleanup.yml +++ b/.github/workflows/issue-cleanup.yml @@ -9,6 +9,7 @@ permissions: {} jobs: close-issues: + name: โ™ป๏ธ Close Stale Issues & PRs runs-on: ubuntu-latest permissions: issues: write diff --git a/.github/workflows/linting-formatting.yml b/.github/workflows/linting-formatting.yml index 2202898d..314de526 100644 --- a/.github/workflows/linting-formatting.yml +++ b/.github/workflows/linting-formatting.yml @@ -19,6 +19,7 @@ permissions: jobs: linter: + name: ๐Ÿงน Lint & Format runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml index 46c598be..467f2d8f 100644 --- a/.github/workflows/ossf-scorecard.yml +++ b/.github/workflows/ossf-scorecard.yml @@ -13,6 +13,7 @@ permissions: read-all jobs: ossf-scorecard: + name: ๐Ÿ›ก๏ธ OpenSSF Scorecard runs-on: ubuntu-latest permissions: security-events: write diff --git a/.github/workflows/pr-conventional-title.yml b/.github/workflows/pr-conventional-title.yml index 22ad16ab..21a24e54 100644 --- a/.github/workflows/pr-conventional-title.yml +++ b/.github/workflows/pr-conventional-title.yml @@ -12,6 +12,7 @@ permissions: {} jobs: validate-pr-title: + name: โœ… Validate PR Title runs-on: ubuntu-latest permissions: pull-requests: write @@ -19,7 +20,6 @@ jobs: - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 with: disable-sudo-and-containers: true - egress-policy: block allowed-endpoints: > api.github.com:443 - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 @@ -33,7 +33,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 - if: always() && steps.pr-title.outputs.error_message != null + if: ${{ !cancelled() && steps.pr-title.outputs.error_message != null }} with: header: pr-title-lint-error message: | diff --git a/.github/workflows/pr-image-cleanup.yml b/.github/workflows/pr-image-cleanup.yml index 1c770cb4..822618b1 100644 --- a/.github/workflows/pr-image-cleanup.yml +++ b/.github/workflows/pr-image-cleanup.yml @@ -9,6 +9,7 @@ permissions: {} jobs: delete-images: + name: ๐Ÿ—‘๏ธ Delete PR Images runs-on: ubuntu-latest permissions: packages: write @@ -22,6 +23,7 @@ jobs: delete-tags: pr-${{ github.event.pull_request.number }} packages: amp-devcontainer,amp-devcontainer-cpp,amp-devcontainer-rust cleanup-cache: + name: ๐Ÿงน Cleanup Cache runs-on: ubuntu-latest permissions: # actions: write permission is required to delete the cache diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index 839b6b53..966b17fc 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -9,6 +9,7 @@ permissions: {} jobs: add-pr-report: + name: ๐Ÿ“Š Add PR Report permissions: contents: read checks: read diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 1bdc5de6..c6e83388 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -16,6 +16,10 @@ permissions: {} jobs: build-push-test: + name: Build, Push and Test (๐Ÿจ ${{ matrix.flavor }}) + strategy: + matrix: + flavor: [cpp, rust] uses: ./.github/workflows/wc-build-push-test.yml secrets: TEST_GITHUB_TOKEN: ${{ secrets.TEST_GITHUB_TOKEN }} @@ -30,7 +34,15 @@ jobs: id-token: write packages: write pull-requests: write + with: + devcontainer-metadata-file: .devcontainer/${{ matrix.flavor }}/devcontainer-metadata.json + dockerfile: .devcontainer/${{ matrix.flavor }}/Dockerfile + image-name: ${{ github.repository }}-${{ matrix.flavor }} + integration-test-file: test/${{ matrix.flavor }}/integration-tests.bats + acceptance-test-path: ${{ matrix.flavor == 'cpp' && 'test/cpp/features' || '' }} + test-devcontainer-file: ${{ matrix.flavor == 'cpp' && '.devcontainer/cpp-test/devcontainer.json' || '' }} apply-release-notes-template: + name: ๐Ÿ“ Apply Release Template runs-on: ubuntu-latest permissions: # `contents: write` is needed to modify a release. @@ -57,6 +69,7 @@ jobs: GH_TOKEN: ${{ github.token }} REF_NAME: ${{ github.ref_name }} update-release-notes: + name: Update Release Notes (๐Ÿจ ${{ matrix.flavor }}) strategy: matrix: flavor: [cpp, rust] @@ -108,6 +121,7 @@ jobs: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} upload-documents: + name: ๐Ÿ“„ Upload Documents runs-on: ubuntu-latest permissions: # `contents: write` is needed to modify a release. @@ -126,3 +140,4 @@ jobs: env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} + REF_NAME: ${{ github.ref_name }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 30c23ec8..7417d318 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -14,6 +14,7 @@ permissions: jobs: create-release: + name: ๐Ÿš€ Create Release runs-on: ubuntu-latest steps: - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 diff --git a/.github/workflows/social-interaction.yml b/.github/workflows/social-interaction.yml index 64e07ffb..992de656 100644 --- a/.github/workflows/social-interaction.yml +++ b/.github/workflows/social-interaction.yml @@ -11,6 +11,7 @@ permissions: {} jobs: greeting: + name: ๐Ÿ‘‹ First Interaction Greeting runs-on: ubuntu-latest permissions: issues: write @@ -20,7 +21,6 @@ jobs: - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 with: disable-sudo-and-containers: true - egress-policy: block allowed-endpoints: > api.github.com:443 - uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0 diff --git a/.github/workflows/update-dependencies.yml b/.github/workflows/update-dependencies.yml index 82078c3c..75b36424 100644 --- a/.github/workflows/update-dependencies.yml +++ b/.github/workflows/update-dependencies.yml @@ -10,6 +10,7 @@ permissions: {} jobs: update-apt-dependencies: + name: Update APT Dependencies (๐Ÿจ ${{ matrix.flavor }}) runs-on: ubuntu-latest strategy: matrix: @@ -45,11 +46,12 @@ jobs: token: ${{ steps.token.outputs.token }} sign-commits: true update-vscode-extensions: + name: Update VS Code Extensions (๐Ÿจ ${{ matrix.flavor }}, ${{ matrix.file }}) runs-on: ubuntu-latest strategy: matrix: flavor: ["cpp", "rust"] - file: ["devcontainer-metadata-vscode.json", "devcontainer.json"] + file: ["devcontainer-metadata.json", "devcontainer.json"] permissions: contents: write pull-requests: write diff --git a/.github/workflows/vulnerability-scan.yml b/.github/workflows/vulnerability-scan.yml index 47ac40b9..1223eabf 100644 --- a/.github/workflows/vulnerability-scan.yml +++ b/.github/workflows/vulnerability-scan.yml @@ -10,6 +10,7 @@ permissions: {} jobs: vulnerability-scan: + name: ๐Ÿ›ก๏ธ Vulnerability Scan (๐Ÿจ ${{ matrix.flavor }}) runs-on: ubuntu-latest strategy: matrix: diff --git a/.github/workflows/wc-acceptance-test.yml b/.github/workflows/wc-acceptance-test.yml index 6390a304..ceabf080 100644 --- a/.github/workflows/wc-acceptance-test.yml +++ b/.github/workflows/wc-acceptance-test.yml @@ -4,7 +4,13 @@ name: Acceptance Test on: workflow_call: inputs: - flavor: + image-basename: + required: true + type: string + devcontainer-file: + required: true + type: string + acceptance-test-path: required: true type: string secrets: @@ -26,11 +32,12 @@ permissions: jobs: test: + name: Acceptance Test runs-on: ubuntu-latest steps: - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 with: - # Playwright requires root privileges to install browsers + disable-sudo: false # Playwright requires root privileges to install browsers egress-policy: audit - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -49,9 +56,9 @@ jobs: gh secret set -a codespaces IMAGE_VERSION --body "edge" fi - echo CODESPACE_NAME="$(gh codespace create -R "${{ github.repository }}" -b "$HEAD_REF" -m basicLinux32gb --devcontainer-path ".devcontainer/${CONTAINER_FLAVOR}-test/devcontainer.json" --idle-timeout 10m --retention-period 1h)" >> "$GITHUB_ENV" + echo CODESPACE_NAME="$(gh codespace create -R "${{ github.repository }}" -b "$HEAD_REF" -m basicLinux32gb --devcontainer-path "${DEVCONTAINER_FILE}" --idle-timeout 10m --retention-period 1h)" >> "$GITHUB_ENV" env: - CONTAINER_FLAVOR: ${{ inputs.flavor }} + DEVCONTAINER_FILE: ${{ inputs.devcontainer-file }} GH_TOKEN: ${{ secrets.TEST_GITHUB_TOKEN }} HEAD_REF: ${{ github.head_ref }} - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 @@ -82,17 +89,17 @@ jobs: done env: GH_TOKEN: ${{ secrets.TEST_GITHUB_TOKEN }} - - run: cd "test/${CONTAINER_FLAVOR}/features" && npm test + - run: cd "${ACCEPTANCE_TEST_PATH}" && npm test env: - CONTAINER_FLAVOR: ${{ inputs.flavor }} + ACCEPTANCE_TEST_PATH: ${{ inputs.acceptance-test-path }} GITHUB_USER: ${{ secrets.TEST_GITHUB_USER }} GITHUB_PASSWORD: ${{ secrets.TEST_GITHUB_PASSWORD }} GITHUB_TOTP_SECRET: ${{ secrets.TEST_GITHUB_TOTP_SECRET }} - PLAYWRIGHT_JUNIT_OUTPUT_NAME: ${{ github.workspace }}/test-report-acceptance-${{ inputs.flavor }}.xml + PLAYWRIGHT_JUNIT_OUTPUT_NAME: ${{ github.workspace }}/test-report-acceptance-${{ inputs.image-basename }}.xml - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - if: always() + if: ${{ !cancelled() }} with: - name: test-results-acceptance-${{ inputs.flavor }} + name: test-results-acceptance-${{ inputs.image-basename }} path: | test-report-*.xml test-results/ diff --git a/.github/workflows/wc-build-push-test.yml b/.github/workflows/wc-build-push-test.yml index 1e09d69d..170a930c 100644 --- a/.github/workflows/wc-build-push-test.yml +++ b/.github/workflows/wc-build-push-test.yml @@ -3,24 +3,75 @@ name: Build, Push & Test on: workflow_call: + inputs: + dockerfile: + description: "Path to the Dockerfile to build" + required: true + type: string + image-name: + description: "Name of the Docker image to build, without registry or tag. E.g. 'my-image' or 'my-org/my-image'" + required: true + type: string + devcontainer-metadata-file: + description: "Path to a JSON file containing devcontainer metadata to add as a label to the built image" + required: false + type: string + registry: + description: "Docker registry to push built containers to, DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_PASSWORD secrets must be set if not using GitHub Container Registry" + required: false + type: string + default: "ghcr.io" + build-test-runner-labels: + description: >- + JSON object passed to fromJson to become the build matrix. Example: + '["ubuntu-latest", "ubuntu-24.04-arm"]' + required: false + type: string + default: '["ubuntu-latest", "ubuntu-24.04-arm"]' + runner-labels: + description: >- + Single runner label OR JSON array of runner labels for non-build jobs. + Examples: + ubuntu-latest + '["ubuntu-latest"]' + '["self-hosted", "linux", "x86_64"]' + Provide a valid JSON array (starting with '[') to use multiple labels; any other value is treated as a single label string. + required: false + type: string + default: ubuntu-latest + integration-test-file: + description: "Path to the BATS test file to run for integration tests" + required: false + type: string + test-devcontainer-file: + description: "Path to the devcontainer.json file to use for acceptance tests" + required: false + type: string + acceptance-test-path: + description: "Path to the Playwright acceptance tests (directory that contains playwright.config.ts)" + required: false + type: string secrets: TEST_GITHUB_TOKEN: - required: true + required: false TEST_GITHUB_USER: - required: true + required: false TEST_GITHUB_PASSWORD: - required: true + required: false TEST_GITHUB_TOTP_SECRET: - required: true + required: false + DOCKER_REGISTRY_USERNAME: + description: "User name for Docker login, if not provided the GitHub actor will be used" + required: false + DOCKER_REGISTRY_PASSWORD: + description: "Password or token for Docker login, if not provided the GitHub token will be used" + required: false -permissions: - contents: read +permissions: {} jobs: build-push: - strategy: - matrix: - flavor: [cpp, rust] + name: ๐Ÿ› ๏ธ uses: ./.github/workflows/wc-build-push.yml permissions: actions: read @@ -29,75 +80,47 @@ jobs: id-token: write packages: write pull-requests: write + secrets: + DOCKER_REGISTRY_USERNAME: ${{ secrets.DOCKER_REGISTRY_USERNAME }} + DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} with: - flavor: ${{ matrix.flavor }} - - dependency-review: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - needs: build-push - if: github.event_name == 'pull_request' - steps: - - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 - with: - disable-sudo-and-containers: true - egress-policy: audit - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - - uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 - with: - comment-summary-in-pr: on-failure - fail-on-severity: critical + dockerfile: ${{ inputs.dockerfile }} + registry: ${{ inputs.registry }} + image-name: ${{ inputs.image-name }} + devcontainer-metadata-file: ${{ inputs.devcontainer-metadata-file }} + runner-labels: ${{ inputs.runner-labels }} + build-test-runner-labels: ${{ inputs.build-test-runner-labels }} integration-test: + name: ๐Ÿงช + if: ${{ inputs.integration-test-file }} strategy: matrix: - flavor: [cpp, rust] - runner: ["ubuntu-latest", "ubuntu-24.04-arm"] + runner: ${{ (startsWith(inputs.build-test-runner-labels, '[') && endsWith(inputs.build-test-runner-labels, ']')) && fromJson(inputs.build-test-runner-labels) || inputs.build-test-runner-labels }} needs: build-push uses: ./.github/workflows/wc-integration-test.yml + permissions: + contents: read with: - flavor: ${{ matrix.flavor }} - runner: ${{ matrix.runner }} + fully-qualified-image-name: ${{ needs.build-push.outputs.fully-qualified-image-name }} + image-basename: ${{ needs.build-push.outputs.image-basename }} + image-digest: ${{ needs.build-push.outputs.digest }} + test-file: ${{ inputs.integration-test-file }} + runner-labels: ${{ matrix.runner }} acceptance-test: - strategy: - matrix: - flavor: [cpp] + name: ๐Ÿ—๏ธ + if: ${{ inputs.test-devcontainer-file && inputs.acceptance-test-path }} needs: build-push uses: ./.github/workflows/wc-acceptance-test.yml + permissions: + contents: read secrets: TEST_GITHUB_TOKEN: ${{ secrets.TEST_GITHUB_TOKEN }} TEST_GITHUB_USER: ${{ secrets.TEST_GITHUB_USER }} TEST_GITHUB_PASSWORD: ${{ secrets.TEST_GITHUB_PASSWORD }} TEST_GITHUB_TOTP_SECRET: ${{ secrets.TEST_GITHUB_TOTP_SECRET }} with: - flavor: ${{ matrix.flavor }} - - publish-test-results: - runs-on: ubuntu-latest - permissions: - checks: write - pull-requests: write - needs: [acceptance-test, integration-test] - if: always() - steps: - - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 - with: - disable-sudo: true - egress-policy: audit - - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - with: - merge-multiple: true - pattern: test-results-* - - uses: EnricoMi/publish-unit-test-result-action@3a74b2957438d0b6e2e61d67b05318aa25c9e6c6 # v2.20.0 - with: - files: test-report-*.xml - - generate-documents: - uses: ./.github/workflows/wc-document-generation.yml - permissions: - contents: read + image-basename: ${{ needs.build-push.outputs.image-basename }} + devcontainer-file: ${{ inputs.test-devcontainer-file }} + acceptance-test-path: ${{ inputs.acceptance-test-path }} diff --git a/.github/workflows/wc-build-push.yml b/.github/workflows/wc-build-push.yml index 2f0af7c4..f03e791b 100644 --- a/.github/workflows/wc-build-push.yml +++ b/.github/workflows/wc-build-push.yml @@ -1,27 +1,70 @@ +# This is a lower-level re-usable workflow that builds and pushes +# a multi-architecture devcontainer image to a container registry. +# +# It is intended to be called by a higher-level workflow that provides +# the necessary inputs and secrets. To prevent duplication, the inputs +# and secrets don't contain any defaults or descriptions and all of them +# are required. +# +# See the top-level workflow `wc-build-push-test.yml` for an example. + --- name: Build & Push on: workflow_call: inputs: - flavor: + dockerfile: required: true type: string + image-name: + required: true + type: string + devcontainer-metadata-file: + required: true + type: string + registry: + required: true + type: string + build-test-runner-labels: + required: true + type: string + runner-labels: + required: true + type: string + outputs: + fully-qualified-image-name: + value: ${{ jobs.sanitize-image-name.outputs.fully-qualified-image-name }} + image-basename: + value: ${{ jobs.sanitize-image-name.outputs.image-basename }} + digest: + value: ${{ jobs.merge-image.outputs.digest }} + secrets: + DOCKER_REGISTRY_USERNAME: + required: true + DOCKER_REGISTRY_PASSWORD: + required: true -permissions: - contents: read - -env: - CONTAINER_FLAVOR: ${{ inputs.flavor }} - REGISTRY: ghcr.io +permissions: {} jobs: + sanitize-image-name: + name: ๐Ÿงผ + uses: ./.github/workflows/wc-sanitize-image-name.yml + with: + image-name: ${{ inputs.image-name }} + registry: ${{ inputs.registry }} + runner-labels: ${{ inputs.runner-labels }} + build-push: + name: ${{ matrix.runner }} strategy: matrix: - runner: ["ubuntu-latest", "ubuntu-24.04-arm"] + runner: ${{ (startsWith(inputs.build-test-runner-labels, '[') && endsWith(inputs.build-test-runner-labels, ']')) && fromJson(inputs.build-test-runner-labels) || inputs.build-test-runner-labels }} runs-on: ${{ matrix.runner }} + needs: sanitize-image-name permissions: + contents: read packages: write steps: - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 @@ -32,36 +75,49 @@ jobs: with: persist-credentials: false - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + with: + cache-binary: false - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + registry: ${{ inputs.registry }} + username: ${{ secrets.DOCKER_REGISTRY_USERNAME || github.actor }} + password: ${{ secrets.DOCKER_REGISTRY_PASSWORD || github.token }} - uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 env: DOCKER_METADATA_SET_OUTPUT_ENV: false id: metadata with: - images: ${{ env.REGISTRY }}/${{ github.repository }}-${{ inputs.flavor }} - # Generate image LABEL for devcontainer.metadata - # the sed expression is a workaround for quotes being eaten in arrays (e.g. ["x", "y", "z"] -> ["x",y,"z"]) - - run: echo "metadata=$(jq -cj '[.]' ".devcontainer/${CONTAINER_FLAVOR}/devcontainer-metadata-vscode.json" | sed 's/,"/, "/g')" >> "$GITHUB_OUTPUT" + images: ${{ needs.sanitize-image-name.outputs.fully-qualified-image-name }} + - name: Generate image label for devcontainer.metadata + run: | + set -Eeuo pipefail + + if [ -z "${DEVCONTAINER_METADATA_FILE:-}" ] || [ ! -f "${DEVCONTAINER_METADATA_FILE}" ]; then + echo "devcontainer-metadata-file input not set or file does not exist, skipping devcontainer.metadata label" + echo "label=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # the sed expression is a workaround for quotes being eaten in arrays (e.g. ["x", "y", "z"] -> ["x",y,"z"]) + echo "label=devcontainer.metadata=$(jq -cj '[.]' "${DEVCONTAINER_METADATA_FILE}" | sed 's/,"/, "/g')" >> "$GITHUB_OUTPUT" + env: + DEVCONTAINER_METADATA_FILE: ${{ inputs.devcontainer-metadata-file }} id: devcontainer-metadata - run: echo "git-commit-epoch=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT" id: devcontainer-epoch - - run: echo "arch=${RUNNER_ARCH@L}" >> "$GITHUB_OUTPUT" + - run: echo "arch=$(echo "${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" id: devcontainer-arch - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 id: build-and-push env: SOURCE_DATE_EPOCH: ${{ steps.devcontainer-epoch.outputs.git-commit-epoch }} with: - file: .devcontainer/${{ inputs.flavor }}/Dockerfile + file: ${{ inputs.dockerfile }} push: true - tags: ${{ env.REGISTRY }}/${{ github.repository }}-${{ inputs.flavor }} + tags: ${{ needs.sanitize-image-name.outputs.fully-qualified-image-name }} labels: | ${{ steps.metadata.outputs.labels }} - devcontainer.metadata=${{ steps.devcontainer-metadata.outputs.metadata }} + ${{ steps.devcontainer-metadata.outputs.label }} annotations: ${{ steps.metadata.outputs.annotations }} sbom: true outputs: type=image,push-by-digest=true,name-canonical=true @@ -72,17 +128,21 @@ jobs: touch "${RUNNER_TEMP}/digests/${DIGEST#sha256:}" env: DIGEST: ${{ steps.build-and-push.outputs.digest }} - RUNNER_TEMP: ${{ runner.temp }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: digests-${{ inputs.flavor }}-${{ steps.devcontainer-arch.outputs.arch }} + name: digests-${{ needs.sanitize-image-name.outputs.image-basename }}-${{ steps.devcontainer-arch.outputs.arch }} path: ${{ runner.temp }}/digests/* if-no-files-found: error retention-days: 1 merge-image: - runs-on: ubuntu-latest - needs: build-push + name: ๐Ÿ”— Merge Image + # Support either a plain single label (e.g. ubuntu-latest) OR a JSON array of labels. + # If the input starts & ends with brackets we attempt JSON parsing; otherwise we pass the raw string. + runs-on: ${{ (startsWith(inputs.runner-labels, '[') && endsWith(inputs.runner-labels, ']')) && fromJson(inputs.runner-labels) || inputs.runner-labels }} + needs: + - build-push + - sanitize-image-name permissions: actions: read attestations: write @@ -92,6 +152,8 @@ jobs: id-token: write packages: write pull-requests: write + outputs: + digest: ${{ steps.inspect-manifest.outputs.digest }} steps: - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 with: @@ -103,21 +165,22 @@ jobs: - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: path: ${{ runner.temp }}/digests - pattern: digests-${{ inputs.flavor }}-* + pattern: digests-${{ needs.sanitize-image-name.outputs.image-basename }}-* merge-multiple: true - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + with: + cache-binary: false - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + registry: ${{ inputs.registry }} + username: ${{ secrets.DOCKER_REGISTRY_USERNAME || github.actor }} + password: ${{ secrets.DOCKER_REGISTRY_PASSWORD || github.token }} - uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 id: metadata env: DOCKER_METADATA_ANNOTATIONS_LEVELS: index - DOCKER_METADATA_SET_OUTPUT_ENV: false with: - images: ${{ env.REGISTRY }}/${{ github.repository }}-${{ inputs.flavor }} + images: ${{ needs.sanitize-image-name.outputs.fully-qualified-image-name }} # Generate Docker tags based on the following events/attributes. # To prevent unnecessary image builds we simulate the `type=edge` tag # with `type=raw,value=edge,enable=...` which only enables the tag @@ -131,73 +194,69 @@ jobs: type=semver,pattern={{major}} - name: Create manifest list and push run: | - import os - import json - import subprocess - - CONTAINER = f"{os.getenv('REGISTRY')}/{os.getenv('GH_REPO')}-{os.getenv('CONTAINER_FLAVOR')}" - METADATA = json.loads(os.getenv('METADATA_JSON')) - - digests = [f for f in os.listdir('.') if f.startswith('sha256:') or len(f) == 64] + set -Eeuo pipefail - command = ['docker', 'buildx', 'imagetools', 'create', - *[annotation for annotation in METADATA.get('annotations', []) for annotation in ('--annotation', annotation)], - *[tag for tag in METADATA.get('tags', []) for tag in ('--tag', tag)], - *[f"{CONTAINER}@sha256:{digest}" for digest in digests] - ] + readarray -t lines <<< "$DOCKER_METADATA_OUTPUT_ANNOTATIONS" + annotations=() + for line in "${lines[@]}"; do + annotations+=(--annotation "$line") + done - print(' '.join(command)) - subprocess.run(command, check=True) + # shellcheck disable=SC2046 + docker buildx imagetools create \ + "${annotations[@]}" \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "${METADATA_JSON}") \ + $(printf "${CONTAINER}@sha256:%s " *) env: + CONTAINER: ${{ needs.sanitize-image-name.outputs.fully-qualified-image-name }} METADATA_JSON: ${{ steps.metadata.outputs.json }} - GH_REPO: ${{ github.repository }} - shell: python + shell: bash working-directory: ${{ runner.temp }}/digests - name: Inspect manifest and extract digest id: inspect-manifest run: | set -Eeuo pipefail - output=$(docker buildx imagetools inspect "${REGISTRY}/${GH_REPO}-${CONTAINER_FLAVOR}:${CONTAINER_VERSION}" --format '{{json .}}') + output=$(docker buildx imagetools inspect "${CONTAINER}" --format '{{json .}}') echo "digest=$(echo "$output" | jq -r '.manifest.digest // .manifests[0].digest')" >> "$GITHUB_OUTPUT" env: - CONTAINER_VERSION: ${{ steps.metadata.outputs.version }} - GH_REPO: ${{ github.repository }} + CONTAINER: ${{ needs.sanitize-image-name.outputs.fully-qualified-image-name }}:${{ steps.metadata.outputs.version }} - run: | set -Eeuo pipefail wget -O diffoci https://github.com/reproducible-containers/diffoci/releases/download/v0.1.7/diffoci-v0.1.7.linux-amd64 chmod +x diffoci ./diffoci diff --semantic --report-file=container-diff.json "${FROM_CONTAINER}" "${TO_CONTAINER}" || true env: - FROM_CONTAINER: ${{ env.REGISTRY }}/${{ github.repository }}-${{ inputs.flavor }}:edge - TO_CONTAINER: ${{ env.REGISTRY }}/${{ github.repository }}-${{ inputs.flavor }}:${{ steps.metadata.outputs.version }} + FROM_CONTAINER: ${{ needs.sanitize-image-name.outputs.fully-qualified-image-name }}:edge + TO_CONTAINER: ${{ needs.sanitize-image-name.outputs.fully-qualified-image-name }}:${{ steps.metadata.outputs.version }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: container-diff-${{ inputs.flavor }} + name: container-diff-${{ needs.sanitize-image-name.outputs.image-basename }} path: container-diff.json retention-days: 10 - - uses: ./.github/actions/container-size-diff + - uses: philips-software/amp-devcontainer/.github/actions/container-size-diff@ab0940b1e92f3ccee257d5984166c63c8cfe6a9d # v6.5.0 id: container-size-diff with: - from-container: ${{ env.REGISTRY }}/${{ github.repository }}-${{ inputs.flavor }}:edge - to-container: ${{ env.REGISTRY }}/${{ github.repository }}-${{ inputs.flavor }}:${{ steps.metadata.outputs.version }} + from-container: ${{ needs.sanitize-image-name.outputs.fully-qualified-image-name }}:edge + to-container: ${{ needs.sanitize-image-name.outputs.fully-qualified-image-name }}:${{ steps.metadata.outputs.version }} - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 with: - header: container-size-diff-${{ inputs.flavor }} + header: container-size-diff-${{ needs.sanitize-image-name.outputs.image-basename }} message: | ${{ steps.container-size-diff.outputs.size-diff-markdown }} - uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 with: - image: ${{ env.REGISTRY }}/${{ github.repository }}-${{ inputs.flavor }}@${{ steps.inspect-manifest.outputs.digest }} + image: ${{ needs.sanitize-image-name.outputs.fully-qualified-image-name }}@${{ steps.inspect-manifest.outputs.digest }} dependency-snapshot: true - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 with: - subject-name: ${{ env.REGISTRY }}/${{ github.repository }}-${{ inputs.flavor }} + subject-name: ${{ needs.sanitize-image-name.outputs.fully-qualified-image-name }} subject-digest: ${{ steps.inspect-manifest.outputs.digest }} show-summary: false push-to-registry: true - name: Verify attestation - run: gh attestation verify --repo "${GH_REPO}" "oci://${REGISTRY}/${GH_REPO}-${CONTAINER_FLAVOR}@${DIGEST}" + run: gh attestation verify --repo "${GH_REPO}" "oci://${FULLY_QUALIFIED_IMAGE_NAME}@${DIGEST}" env: DIGEST: ${{ steps.inspect-manifest.outputs.digest }} + FULLY_QUALIFIED_IMAGE_NAME: ${{ needs.sanitize-image-name.outputs.fully-qualified-image-name }} GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/wc-dependency-review.yml b/.github/workflows/wc-dependency-review.yml new file mode 100644 index 00000000..1415f8d9 --- /dev/null +++ b/.github/workflows/wc-dependency-review.yml @@ -0,0 +1,33 @@ +--- +name: Dependency Review + +on: + workflow_call: + inputs: + runner-labels: + description: "Runner to use for the job, will be passed to `runs-on`" + required: false + type: string + default: ubuntu-latest + +permissions: {} + +jobs: + dependency-review: + name: Review + runs-on: ${{ (startsWith(inputs.runner-labels, '[') && endsWith(inputs.runner-labels, ']')) && fromJson(inputs.runner-labels) || inputs.runner-labels }} + permissions: + contents: read + pull-requests: write + steps: + - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + disable-sudo-and-containers: true + egress-policy: audit + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 + with: + comment-summary-in-pr: on-failure + fail-on-severity: critical diff --git a/.github/workflows/wc-document-generation.yml b/.github/workflows/wc-document-generation.yml index 95ec7e24..290bbce5 100644 --- a/.github/workflows/wc-document-generation.yml +++ b/.github/workflows/wc-document-generation.yml @@ -4,12 +4,14 @@ name: Document Generation on: workflow_call: -permissions: - contents: read +permissions: {} jobs: generate-documents: + name: Generate Documents runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 with: diff --git a/.github/workflows/wc-integration-test.yml b/.github/workflows/wc-integration-test.yml index 26b899d8..cc7479d2 100644 --- a/.github/workflows/wc-integration-test.yml +++ b/.github/workflows/wc-integration-test.yml @@ -4,45 +4,46 @@ name: Integration Test on: workflow_call: inputs: - flavor: + fully-qualified-image-name: required: true type: string - runner: + image-basename: required: true type: string + image-digest: + required: true + type: string + test-file: + required: true + type: string + runner-labels: + description: "Runner to use for the job, will be passed to `runs-on`" + required: true + type: string + registry: + description: "Docker registry to push built containers to, DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_PASSWORD secrets must be set if not using GitHub Container Registry" + required: false + type: string + default: "ghcr.io" + secrets: + DOCKER_REGISTRY_USERNAME: + required: false + DOCKER_REGISTRY_PASSWORD: + required: false -permissions: - contents: read - -env: - CONTAINER_FLAVOR: ${{ inputs.flavor }} - RUNNER: ${{ inputs.runner }} +permissions: {} jobs: - determine-container: - runs-on: ${{ inputs.runner }} - outputs: - container: ${{ steps.set-container.outputs.container }} - steps: - - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 - with: - disable-sudo: true - egress-policy: audit - - run: echo "arch=${RUNNER_ARCH@L}" >> "$GITHUB_OUTPUT" - id: runner-arch - - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - with: - path: ${{ runner.temp }}/digests-${{ inputs.flavor }}-${{ steps.runner-arch.outputs.arch }} - pattern: digests-${{ inputs.flavor }}-${{ steps.runner-arch.outputs.arch }} - - run: echo "container=$(printf "ghcr.io/${GH_REPO}-${CONTAINER_FLAVOR}@sha256:%s " *)" >> "$GITHUB_OUTPUT" - working-directory: ${{ runner.temp }}/digests-${{ inputs.flavor }}-${{ steps.runner-arch.outputs.arch }} - env: - GH_REPO: ${{ github.repository }} - id: set-container run-test: - needs: determine-container - runs-on: ${{ inputs.runner }} - container: ${{ needs.determine-container.outputs.container }} + name: ๐Ÿงช Integration Test + runs-on: ${{ (startsWith(inputs.runner-labels, '[') && endsWith(inputs.runner-labels, ']')) && fromJson(inputs.runner-labels) || inputs.runner-labels }} + container: + image: ${{ inputs.fully-qualified-image-name }}@${{ inputs.image-digest }} + credentials: + username: ${{ secrets.DOCKER_REGISTRY_USERNAME || github.actor }} + password: ${{ secrets.DOCKER_REGISTRY_PASSWORD || github.token }} + permissions: + contents: read steps: - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 with: @@ -51,16 +52,15 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - if: inputs.flavor == 'cpp' - with: - path: test/cpp/.xwin-cache - key: xwin-cache-${{ inputs.runner }} - restore-keys: | - xwin-cache - - run: bats --formatter junit "test/${CONTAINER_FLAVOR}/integration-tests.bats" | tee "test-report-${CONTAINER_FLAVOR}-${RUNNER}.xml" + - run: echo "arch=$(echo "${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + id: runner-arch + - run: bats --formatter junit "${TEST_FILE}" | tee "test-report-${IMAGE_BASENAME}-${RUNNER_ARCH}.xml" + env: + IMAGE_BASENAME: ${{ inputs.image-basename }} + TEST_FILE: ${{ inputs.test-file }} + RUNNER_ARCH: ${{ steps.runner-arch.outputs.arch }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - if: always() + if: ${{ !cancelled() }} with: - name: test-results-integration-${{ inputs.flavor }}-${{ inputs.runner }} + name: test-results-integration-${{ inputs.image-basename }}-${{ steps.runner-arch.outputs.arch }} path: test-report-*.xml diff --git a/.github/workflows/wc-sanitize-image-name.yml b/.github/workflows/wc-sanitize-image-name.yml new file mode 100644 index 00000000..5629c4a4 --- /dev/null +++ b/.github/workflows/wc-sanitize-image-name.yml @@ -0,0 +1,67 @@ +--- +name: Sanitize Image Name + +on: + workflow_call: + inputs: + image-name: + required: true + type: string + registry: + required: true + type: string + runner-labels: + required: true + type: string + outputs: + image-basename: + description: "The sanitized base name of the image (without registry or tag)" + value: ${{ jobs.sanitize.outputs.image-basename }} + image-name: + description: "The sanitized name of the image (without registry or tag)" + value: ${{ jobs.sanitize.outputs.image-name }} + fully-qualified-image-name: + description: "The fully qualified name of the image including registry (but without tag)" + value: ${{ jobs.sanitize.outputs.fully-qualified-image-name }} + +permissions: {} + +jobs: + sanitize: + name: Sanitize Image Name + runs-on: ${{ (startsWith(inputs.runner-labels, '[') && endsWith(inputs.runner-labels, ']')) && fromJson(inputs.runner-labels) || inputs.runner-labels }} + outputs: + image-basename: ${{ steps.sanitize-image-name.outputs.sanitized-basename }} + image-name: ${{ steps.sanitize-image-name.outputs.sanitized-image-name }} + fully-qualified-image-name: ${{ inputs.registry }}/${{ steps.sanitize-image-name.outputs.sanitized-image-name }} + steps: + - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + disable-sudo-and-containers: true + allowed-endpoints: > + api.github.com:443 + - name: Sanitize image name + id: sanitize-image-name + env: + IMAGE_NAME: ${{ inputs.image-name }} + run: | + set -Eeuo pipefail + + # Split all image name components (on '/') and sanitize each component independently. + # Rules: lowercase; allowed chars a-z0-9._- ; collapse invalid sequences to single '-'; trim leading/trailing '-'. + IFS='/' read -r -a PARTS <<< "$IMAGE_NAME" + SANITIZED_PARTS=() + + for PART in "${PARTS[@]}"; do + SANITIZED_PART=$(echo "$PART" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g' | sed -E 's/^-+|-+$//g') + if [ -z "$SANITIZED_PART" ]; then + echo "Invalid or empty component after sanitization in image component: '$PART', please correct your image name: '$IMAGE_NAME'" >&2 + exit 1 + fi + SANITIZED_PARTS+=("$SANITIZED_PART") + done + + SANITIZED_IMAGE_NAME=$(IFS='/'; echo "${SANITIZED_PARTS[*]}") + SANITIZED_BASENAME=${SANITIZED_PARTS[-1]} + echo "sanitized-image-name=$SANITIZED_IMAGE_NAME" >> "$GITHUB_OUTPUT" + echo "sanitized-basename=$SANITIZED_BASENAME" >> "$GITHUB_OUTPUT" diff --git a/.mega-linter.yml b/.mega-linter.yml index b2af1df8..eea658e7 100644 --- a/.mega-linter.yml +++ b/.mega-linter.yml @@ -14,6 +14,8 @@ DISABLE_LINTERS: - REPOSITORY_SEMGREP - JSON_JSONLINT - SPELL_CSPELL +DISABLE_ERRORS_LINTERS: + - SPELL_LYCHEE SARIF_REPORTER: true PRINT_ALPACA: false SHOW_SKIPPED_LINTERS: false diff --git a/test/cpp/features/maintainability.feature b/test/cpp/features/maintainability.feature index 730ce325..aa384582 100644 --- a/test/cpp/features/maintainability.feature +++ b/test/cpp/features/maintainability.feature @@ -18,6 +18,12 @@ Feature: Maintainability This reduces the maintenance burden on users, as they do not need to manually track and apply updates. Automatic updates can also help ensure compatibility with other dependencies and tools, improving the overall stability and reliability of the development environment. + Rule: Re-usable build system + amp-devcontainer *SHOULD* provide re-usable building blocks to enable building, publishing and testing derived containers. + + Providing re-usable building blocks for building, publishing and testing derived containers reduces duplication, and ensures consistent application of practices. + Derived containers (i.e. containers using amp-devcontainer as a base for further extension) should be able to build, push and test in the same way that amp-devcontainer does, without the need to duplicate the build system. + Rule: Architectural decisions amp-devcontainer *SHOULD* document its architectural decisions.