diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..efd8e4e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,33 @@ +name: Bug Report +description: Create a bug report to help us improve +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Do not report security vulnerabilities here. Any such issue will be deleted on sight. + Report them privately via [GitHub security advisories](https://github.com/EduIDE/.github/security/advisories/new) instead - see SECURITY.md. + - type: textarea + validations: + required: true + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + - type: textarea + validations: + required: true + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. + - type: input + attributes: + label: Cluster provider + description: The cluster provider (e.g. Minikube, AWS, GKE, Azure, Docker Desktop). Please include your OS when using a local cluster. If you are using Terraform, please include the configuration files if possible. + - type: input + attributes: + label: Version + description: The used version. Such as the helm chart version or the commit id the bug was discovered on + - type: textarea + attributes: + label: Additional information + description: Additional information to analyze the bug such as logs, screenshots, screencasts. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b413532 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: Question or discussion + url: https://github.com/orgs/EduIDE/discussions + about: Ask questions about using or running EduIDE here. + - name: Documentation + url: https://eduide.github.io/Docs/ + about: Guides for students, instructors, administrators and developers. + - name: Report a security vulnerability + url: https://github.com/EduIDE/.github/security/advisories/new + about: Report privately. Do not open a public issue for security problems. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..aac2a31 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,30 @@ +name: Feature request +description: Suggest an idea for this project +labels: enhancement +body: +- type: textarea + validations: + required: true + attributes: + label: Is your feature request related to a problem? Please describe. + description: A clear and concise description of what the problem is. E.g. I'm always frustrated when [...] +- type: textarea + validations: + required: true + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. +- type: textarea + validations: + required: true + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. +- type: input + attributes: + label: Cluster provider + description: The cluster provider (e.g. Minikube, AWS, GKE, Azure, Docker Desktop) the feature is for. Leave out if the feature is not related to (a) specific provider(s). +- type: textarea + attributes: + label: Additional information + description: Any additional information to describe the feature such as screenshots, diagrams, etc. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..2840fbb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,36 @@ + + +## What and why + + + +## How it was verified + + + +## Deployment impact + + + +- [ ] Changes a Helm chart (chart `version` bumped) +- [ ] Changes a published image +- [ ] Requires a config change in EduIDE-deployment +- [ ] Requires a cluster-level change (CRDs, Gateway, ClusterRoles) +- [ ] None of the above + +## Risk and rollback + + diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 0000000..55f5a92 --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,48 @@ +# Reusable: assign a pull request to its author. +# +# GitHub does NOT inherit workflows from the org .github repository - only +# community health files (issue templates, PR template, SECURITY.md, and so on) +# are inherited. So this cannot simply replace the copies in each repository. +# Instead each repository keeps a short caller: +# +# name: Auto Assign PR Author +# on: +# pull_request: +# types: [opened, reopened, ready_for_review] +# jobs: +# auto-assign: +# uses: EduIDE/.github/.github/workflows/auto-assign.yml@v1 +# +# That keeps the logic in one place while respecting the platform constraint. + +name: Auto Assign PR Author + +on: + workflow_call: + +jobs: + auto-assign: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Auto assign PR author + uses: actions/github-script@v7 + with: + script: | + // Dependabot and other bots do not benefit from being assigned. + const login = context.payload.pull_request.user.login; + if (context.payload.pull_request.user.type === 'Bot') { + core.info(`Skipping bot author ${login}`); + return; + } + if (context.payload.pull_request.assignees.length > 0) { + core.info('PR already has assignees; leaving them alone.'); + return; + } + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + assignees: [login], + }); diff --git a/.github/workflows/build-and-push-docker-image.yml b/.github/workflows/build-and-push-docker-image.yml new file mode 100644 index 0000000..b1c6137 --- /dev/null +++ b/.github/workflows/build-and-push-docker-image.yml @@ -0,0 +1,412 @@ +# ============================================================================== +# Reusable Workflow: Build and Push a multi-arch Docker image +# ============================================================================== +# Builds linux/amd64 and linux/arm64 on GitHub-hosted runners and publishes a +# single multi-arch manifest. Every EduIDE container image goes through here. +# +# Design notes: +# - Both architectures are ALWAYS built, including on pull requests. A PR image +# that exists only for amd64 cannot be deployed to a mixed-architecture +# cluster, which is exactly the case for our test environments. +# - Runners are GitHub-hosted only. There is deliberately no execution-mode +# input and no self-hosted/ARC path: one way to build, so what CI proves is +# what ships. +# - Each platform builds independently and pushes by digest; the merge job +# assembles the manifest list. This is the standard buildx pattern and means +# a partial failure never publishes a half-built tag. +# +# Tags published (both point at the same manifest): +# latest | pr- | | | +# - immutable, always safe to pin +# +# Usage: +# jobs: +# build: +# uses: EduIDE/.github/.github/workflows/build-and-push-docker-image.yml@v1 +# with: +# image-name: eduide/eduide-cloud/service +# docker-file: dockerfiles/service/Dockerfile +# secrets: inherit +# ============================================================================== + +name: Build and Push Docker Image + +on: + workflow_call: + inputs: + image-name: + type: string + required: true + description: "Image path WITHOUT registry, e.g. eduide/eduide-cloud/service" + docker-file: + type: string + default: Dockerfile + description: "Path to the Dockerfile" + docker-context: + type: string + default: . + description: "Docker build context" + ref: + type: string + default: '' + description: "Branch, tag or SHA to build (default: the triggering ref)" + build-args: + type: string + required: false + description: "Newline-separated build args, e.g. BASE_IDE_TAG=latest-abc1234" + labels: + type: string + required: false + description: "Additional image labels" + image-tag: + type: string + default: '' + description: "Override the derived base tag (used by the release train)" + registry: + type: string + default: ghcr.io + cache-image: + type: string + default: '' + description: "Image path to store layer cache in (default: same as image-name)" + no-cache: + type: boolean + default: false + description: "Bypass the layer cache (nightly builds use this)" + free-disk-space: + type: boolean + default: true + description: "Reclaim ~30GB of preinstalled toolchains before building. Needed for the large IDE images; harmless otherwise." + build-amd64: + type: boolean + default: true + build-arm64: + type: boolean + default: true + outputs: + image_tag: + description: "The primary tag that was published" + value: ${{ jobs.setup.outputs.base_tag }} + base_tag: + description: "Same as image_tag" + value: ${{ jobs.setup.outputs.base_tag }} + sha_tag: + description: "Immutable - tag" + value: ${{ jobs.setup.outputs.sha_tag }} + cache_tag: + description: "Layer cache tag namespace" + value: ${{ jobs.setup.outputs.cache_tag }} + image_repo: + description: "Lower-cased owner/repository of the calling repo" + value: ${{ jobs.setup.outputs.image_repo }} + secrets: + registry-user: + required: false + registry-password: + required: false + docker-secrets: + required: false + description: "Build secrets, e.g. SENTRY_AUTH_TOKEN=xxx" + +jobs: + # --------------------------------------------------------------------------- + # Derive tags once, so the build and merge jobs cannot disagree about them. + # --------------------------------------------------------------------------- + setup: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.derive.outputs.matrix }} + artifact_key: ${{ steps.derive.outputs.artifact_key }} + base_tag: ${{ steps.derive.outputs.base_tag }} + sha_tag: ${{ steps.derive.outputs.sha_tag }} + cache_tag: ${{ steps.derive.outputs.cache_tag }} + cache_image: ${{ steps.derive.outputs.cache_image }} + image_repo: ${{ steps.derive.outputs.image_repo }} + steps: + - id: derive + shell: bash + env: + OVERRIDE: ${{ inputs.image-tag }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + RELEASE_TAG: ${{ github.event.release.tag_name || '' }} + BUILD_AMD64: ${{ inputs.build-amd64 }} + BUILD_ARM64: ${{ inputs.build-arm64 }} + CACHE_IMAGE_IN: ${{ inputs.cache-image }} + IMAGE_NAME: ${{ inputs.image-name }} + run: | + set -euo pipefail + + SHORT_SHA="${GITHUB_SHA::7}" + + # NOTE: printf, not echo. `echo | tr -c 'a-z0-9_.-' '-'` converts the + # trailing newline into a hyphen, which is how tags like "1.1.0-" and + # "1.1.0--375ef32" ended up published in GHCR. Do not reintroduce echo here. + slug() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_.-' '-'; } + + SAFE_REF=$(slug "${GITHUB_REF_NAME}") + + # Release tags are normalised to X.Y.Z so that a chart's appVersion and + # the image tag it points at are literally the same string. + if [[ -n "${OVERRIDE}" ]]; then + BASE_TAG="${OVERRIDE}" + elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + if [[ -z "${PR_NUMBER}" ]]; then + echo "::error::pull_request event but no PR number available" + exit 1 + fi + BASE_TAG="pr-${PR_NUMBER}" + elif [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then + if [[ -z "${RELEASE_TAG}" ]]; then + echo "::error::release event but release.tag_name is empty" + exit 1 + fi + BASE_TAG=$(slug "${RELEASE_TAG#v}") + elif [[ "${GITHUB_REF}" == "refs/heads/main" || "${GITHUB_REF}" == "refs/heads/master" ]]; then + BASE_TAG="latest" + else + BASE_TAG="${SAFE_REF}" + fi + + # A Docker tag must start with an alphanumeric or underscore and be + # <=128 chars. Malformed git tags do exist in this org (v.1.1.1, + # v.1.0.11 on scorpio) and would derive to ".1.1.1", which the registry + # rejects only at push time, after a full multi-arch build. Fail now. + if ! [[ "${BASE_TAG}" =~ ^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$ ]]; then + echo "::error::derived tag '${BASE_TAG}' is not a valid Docker tag." + echo "::error::Tags must match ^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$ - check the git tag name (expected vX.Y.Z)." + exit 1 + fi + + if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + CACHE_TAG="build-cache-pr-${PR_NUMBER}" + elif [[ "${BASE_TAG}" == "latest" ]]; then + CACHE_TAG="build-cache" + else + CACHE_TAG="build-cache-${BASE_TAG}" + fi + + # Build the platform matrix. Both are on by default; the inputs exist + # only so a caller can debug a single architecture. + PLATFORMS=() + [[ "${BUILD_AMD64}" == "true" ]] && PLATFORMS+=('{"platform":"linux/amd64","arch":"amd64","runner":"ubuntu-24.04"}') + [[ "${BUILD_ARM64}" == "true" ]] && PLATFORMS+=('{"platform":"linux/arm64","arch":"arm64","runner":"ubuntu-24.04-arm"}') + if [[ ${#PLATFORMS[@]} -eq 0 ]]; then + echo "::error::build-amd64 and build-arm64 are both false; nothing to build" + exit 1 + fi + JOINED=$(IFS=,; echo "${PLATFORMS[*]}") + + # Several images are usually built from one caller run, and artifact + # names share a namespace across the whole run. Key them by image so + # the merge job cannot pick up another image's digests. Derived here + # rather than with hashFiles() because the merge job has no checkout. + # + # The trailing hash is load-bearing, not decoration. The merge job + # downloads by glob, so a bare slug lets one image swallow another's + # digests whenever one name is a prefix of another: pattern + # "...-eduide-eduide-c-*" also matches "...-eduide-eduide-c-templates-amd64". + # That is real here - c/c-templates, java-17/java-17-templates, + # rust/rust-no-ls - and produced a manifest containing the wrong + # image's digests. Every key now ends in "-<8 hex>", which no other + # key can prefix-match. + ARTIFACT_KEY="$(printf '%s' "${IMAGE_NAME}" | tr -c 'a-zA-Z0-9_.-' '-')-$(printf '%s' "${IMAGE_NAME}" | shasum -a 256 | cut -c1-8)" + + { + echo "matrix={\"include\":[${JOINED}]}" + echo "artifact_key=${ARTIFACT_KEY}" + echo "base_tag=${BASE_TAG}" + echo "sha_tag=${BASE_TAG}-${SHORT_SHA}" + echo "cache_tag=${CACHE_TAG}" + echo "cache_image=${CACHE_IMAGE_IN:-$IMAGE_NAME}" + echo "image_repo=$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')" + } >> "$GITHUB_OUTPUT" + + { + echo "### ${IMAGE_NAME}" + echo "" + echo "| | |" + echo "|---|---|" + echo "| Tags | \`${BASE_TAG}\`, \`${BASE_TAG}-${SHORT_SHA}\` |" + echo "| Platforms | $(echo "${JOINED}" | grep -o 'linux/[a-z0-9]*' | paste -sd', ' -) |" + } >> "$GITHUB_STEP_SUMMARY" + + # --------------------------------------------------------------------------- + # One job per architecture. Pushes by digest only; publishes no tag. + # --------------------------------------------------------------------------- + build: + needs: setup + name: ${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.setup.outputs.matrix) }} + permissions: + contents: read + packages: write + steps: + - name: Free disk space + if: ${{ inputs.free-disk-space }} + shell: bash + run: | + # The GitHub-hosted image ships ~30GB of toolchains we never use, and + # the Theia IDE images are large enough that this is the difference + # between a green build and ENOSPC. Best-effort: paths differ per arch. + echo "Before: $(df -h / | awk 'NR==2{print $4}') available" + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ + /opt/hostedtoolcache/CodeQL /usr/local/share/boost \ + /usr/share/swift 2>/dev/null || true + sudo docker image prune --all --force >/dev/null 2>&1 || true + echo "After: $(df -h / | awk 'NR==2{print $4}') available" + + - name: Checkout (explicit ref) + if: ${{ inputs.ref != '' }} + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + - name: Checkout + if: ${{ inputs.ref == '' }} + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to ${{ inputs.registry }} + uses: docker/login-action@v3 + with: + registry: ${{ inputs.registry }} + username: ${{ secrets.registry-user || github.actor }} + password: ${{ secrets.registry-password || secrets.GITHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: ${{ inputs.docker-context }} + file: ${{ inputs.docker-file }} + platforms: ${{ matrix.platform }} + build-args: ${{ inputs.build-args }} + labels: ${{ inputs.labels }} + secrets: ${{ secrets.docker-secrets }} + no-cache: ${{ inputs.no-cache }} + cache-from: ${{ inputs.no-cache && '' || format('type=registry,ref={0}/{1}:{2}-{3}', inputs.registry, needs.setup.outputs.cache_image, needs.setup.outputs.cache_tag, matrix.arch) }} + cache-to: ${{ inputs.no-cache && '' || format('type=registry,ref={0}/{1}:{2}-{3},mode=max,image-manifest=true,oci-mediatypes=true', inputs.registry, needs.setup.outputs.cache_image, needs.setup.outputs.cache_tag, matrix.arch) }} + outputs: type=image,name=${{ inputs.registry }}/${{ inputs.image-name }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + shell: bash + run: | + set -euo pipefail + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + if [[ -z "${digest}" ]]; then + echo "::error::build produced no digest" + exit 1 + fi + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ github.run_id }}-${{ github.run_attempt }}-${{ needs.setup.outputs.artifact_key }}-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + # --------------------------------------------------------------------------- + # Assemble the per-architecture digests into one multi-arch manifest. + # --------------------------------------------------------------------------- + merge: + needs: [setup, build] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-${{ github.run_id }}-${{ github.run_attempt }}-${{ needs.setup.outputs.artifact_key }}-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to ${{ inputs.registry }} + uses: docker/login-action@v3 + with: + registry: ${{ inputs.registry }} + username: ${{ secrets.registry-user || github.actor }} + password: ${{ secrets.registry-password || secrets.GITHUB_TOKEN }} + + - name: Create and push manifest list + shell: bash + working-directory: ${{ runner.temp }} + env: + IMAGE: ${{ inputs.registry }}/${{ inputs.image-name }} + BASE_TAG: ${{ needs.setup.outputs.base_tag }} + SHA_TAG: ${{ needs.setup.outputs.sha_tag }} + run: | + set -euo pipefail + shopt -s nullglob + # mkdir, not cd into a directory that download-artifact may never have + # created: without this the step dies on a missing working directory + # before the check below can explain what actually went wrong. + mkdir -p digests + cd digests + digests=(*) + if [[ ${#digests[@]} -eq 0 ]]; then + echo "::error::No digests found for ${IMAGE}. Expected artifacts named" + echo "::error:: digests-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}--" + echo "::error::Artifact names include the run attempt, so re-running ONLY the" + echo "::error::merge job cannot see artifacts uploaded by a previous attempt." + echo "::error::Re-run all jobs instead." + exit 1 + fi + echo "Assembling ${#digests[@]} digest(s) into ${IMAGE}:${BASE_TAG}" + refs=() + for d in "${digests[@]}"; do + refs+=("${IMAGE}@sha256:${d}") + done + docker buildx imagetools create \ + -t "${IMAGE}:${BASE_TAG}" \ + -t "${IMAGE}:${SHA_TAG}" \ + "${refs[@]}" + + - name: Verify published manifest + shell: bash + env: + IMAGE: ${{ inputs.registry }}/${{ inputs.image-name }} + BASE_TAG: ${{ needs.setup.outputs.base_tag }} + EXPECT_AMD64: ${{ inputs.build-amd64 }} + EXPECT_ARM64: ${{ inputs.build-arm64 }} + run: | + set -euo pipefail + out=$(docker buildx imagetools inspect "${IMAGE}:${BASE_TAG}") + echo "$out" + + # A silently single-arch image is the failure mode this workflow exists + # to prevent, so assert the manifest rather than trusting the build. + fail=0 + if [[ "${EXPECT_AMD64}" == "true" ]] && ! grep -q "linux/amd64" <<< "$out"; then + echo "::error::${IMAGE}:${BASE_TAG} is missing linux/amd64"; fail=1 + fi + if [[ "${EXPECT_ARM64}" == "true" ]] && ! grep -q "linux/arm64" <<< "$out"; then + echo "::error::${IMAGE}:${BASE_TAG} is missing linux/arm64"; fail=1 + fi + exit $fail + + - name: Summary + shell: bash + env: + IMAGE: ${{ inputs.registry }}/${{ inputs.image-name }} + BASE_TAG: ${{ needs.setup.outputs.base_tag }} + SHA_TAG: ${{ needs.setup.outputs.sha_tag }} + run: | + { + echo "" + echo "Published \`${IMAGE}:${BASE_TAG}\` and \`${IMAGE}:${SHA_TAG}\` (amd64 + arm64)." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..39ce63b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + name: actionlint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run actionlint + run: | + set -euo pipefail + bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.7/scripts/download-actionlint.bash) + ./actionlint -color + + test: + name: Tag derivation tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install yq + run: | + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + - name: Run tests + run: ./tests/test-derive-tags.sh diff --git a/README.md b/README.md index a46ae92..e181b25 100644 --- a/README.md +++ b/README.md @@ -1 +1,84 @@ -# .github \ No newline at end of file +# EduIDE `.github` + +Org-level defaults and shared reusable workflows for the [EduIDE](https://github.com/EduIDE) organisation. + +## Reusable workflows + +### `build-and-push-docker-image.yml` + +Builds a container image for **linux/amd64 and linux/arm64** on GitHub-hosted +runners and publishes one multi-arch manifest. Every EduIDE image goes through +this workflow. + +```yaml +jobs: + build: + uses: EduIDE/.github/.github/workflows/build-and-push-docker-image.yml@v1 + with: + image-name: eduide/eduide-cloud/service + docker-file: dockerfiles/service/Dockerfile + secrets: inherit +``` + +**Tags published** (both point at the same manifest): + +| Trigger | `base_tag` | also published | +|---|---|---| +| push to `main` | `latest` | `latest-` | +| pull request | `pr-` | `pr--` | +| release `v2.3.0` | `2.3.0` | `2.3.0-` | +| other branch | `` | `-` | +| `image-tag` input | that value | `-` | + +The leading `v` is stripped from release tags so that a Helm chart's +`appVersion` and the image tag it refers to are the same string. + +**Inputs** (all optional except `image-name`): `image-name`, `docker-file`, +`docker-context`, `ref`, `build-args`, `labels`, `image-tag`, `registry`, +`cache-image`, `no-cache`, `free-disk-space`, `build-amd64`, `build-arm64`. + +**Outputs:** `image_tag`, `base_tag`, `sha_tag`, `cache_tag`, `image_repo`. +Use `sha_tag` when one image must build on top of another: + +```yaml +build-args: | + BASE_IDE_TAG=${{ needs.build-base.outputs.sha_tag }} +``` + +**Secrets:** `registry-user`, `registry-password` (both default to the GitHub +token), and `docker-secrets` for build-time secrets such as `SENTRY_AUTH_TOKEN`. + +#### Design decisions worth knowing + +- **Both architectures are always built, including on pull requests.** A PR image + that exists only for amd64 cannot be scheduled onto an arm64 node, which our + test clusters have. Building one architecture is possible via `build-amd64` / + `build-arm64` but is intended for debugging only. +- **GitHub-hosted runners only.** There is deliberately no `execution-mode` input + and no self-hosted or ARC path, so what CI proves is what ships. +- **`free-disk-space` defaults to `true`.** The runner image ships ~30GB of + toolchains we never use, and the Theia IDE images are large enough that + reclaiming it is the difference between a green build and `ENOSPC`. +- **Push-by-digest, then merge.** Each architecture pushes an untagged digest; + a final job assembles the manifest list. A partial failure therefore never + publishes a half-built tag. +- **The manifest is verified after publishing.** A silently single-architecture + image is the failure this workflow exists to prevent, so the merge job asserts + the platforms are actually present rather than trusting the build. +- **`printf`, not `echo`, when slugging tags.** `echo | tr -c` turns the trailing + newline into a hyphen. That bug published `1.1.0-` and `1.1.0--375ef32` to + GHCR before this workflow existed; `tests/test-derive-tags.sh` guards it. + +## Tests + +```bash +./tests/test-derive-tags.sh # requires yq +``` + +The test extracts the tag-derivation shell directly out of the workflow YAML and +executes it, so it cannot drift from what runs in CI. + +## Versioning + +Consumers should pin the `v1` tag. Breaking changes to inputs or outputs get a +new major tag. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fb6d7fa --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Security Policy + +Org-wide default for all repositories under [EduIDE](https://github.com/EduIDE). + +## Reporting a vulnerability + +**Do not open a public issue.** Report privately through GitHub security +advisories: + + + +If the vulnerability is specific to one repository, use that repository's +"Report a vulnerability" button instead so the advisory lands with the code. + +Please include what you can: + +- which component and version (image tag or chart version) +- what an attacker can do with it +- how to reproduce +- whether it is already public anywhere + +## Scope + +EduIDE runs untrusted student code in per-session containers on a shared +Kubernetes cluster. Findings that are especially relevant: + +- escaping a session container, or reaching another user's session or workspace +- reading another user's workspace storage +- bypassing Keycloak authentication or the oauth2-proxy layer in front of a session +- reaching the operator, the REST service, or their service accounts from inside a session +- privilege escalation via the admin API token + +Denial of service caused by a session consuming its own quota is expected +behaviour, not a vulnerability. Resource limits are enforced per AppDefinition. + +## What to expect + +We will acknowledge the report, tell you whether we consider it in scope, and +keep you informed while we work on a fix. We will credit you in the advisory +unless you would rather stay anonymous. diff --git a/profile/README.md b/profile/README.md new file mode 100644 index 0000000..438dc8f --- /dev/null +++ b/profile/README.md @@ -0,0 +1,45 @@ +# EduIDE + +Browser-based programming environments for teaching, running on Kubernetes and +integrated with [Artemis](https://github.com/ls1intum/Artemis). + +Students open an exercise and get a full IDE in the browser - no local setup, +no toolchain installation, the same environment for everyone. + +📖 **[Documentation](https://eduide.github.io/Docs/)** - for students, +instructors, administrators and developers. + +## The main repositories + +| Repository | What it is | +|---|---| +| [EduIDE](https://github.com/EduIDE/EduIDE) | The IDE itself. Builds the per-language Theia images students work in. | +| [EduIDE-Cloud](https://github.com/EduIDE/EduIDE-Cloud) | The control plane. Kubernetes operator and REST service that create and manage sessions. | +| [EduIDE-Landing-Page](https://github.com/EduIDE/EduIDE-Landing-Page) | Where users pick an environment and launch a session. | +| [EduIDE-Helm](https://github.com/EduIDE/EduIDE-Helm) | The Helm charts. How you install EduIDE on a cluster. | +| [EduIDE-deployment](https://github.com/EduIDE/EduIDE-deployment) | The TUM installations: environment configuration and deployment workflows. | +| [Docs](https://github.com/EduIDE/Docs) | The documentation site. | +| [theia-scale-tests](https://github.com/EduIDE/theia-scale-tests) | End-to-end and scalability tests. | + +Supporting components: [scorpio](https://github.com/EduIDE/scorpio) (Artemis +integration extension), [EduIDE-data-bridge](https://github.com/EduIDE/EduIDE-data-bridge) +(runtime data injection), [EduIDE-shared-cache](https://github.com/EduIDE/EduIDE-shared-cache) +(shared Gradle and Bazel build cache), +[workspace-garbage-collector](https://github.com/EduIDE/workspace-garbage-collector) +(reclaims abandoned workspace storage). + +## Running it yourself + +```bash +helm install eduide oci://ghcr.io/eduide/charts/eduide --version +``` + +See the [administrator documentation](https://eduide.github.io/Docs/admins/) +for cluster prerequisites and configuration. + +--- + +EduIDE is developed at the +[Applied Education Technologies](https://ase.cit.tum.de/) group, TUM. +It began as a fork of [Eclipse Theia Cloud](https://github.com/eclipse-theia/theia-cloud) +and [Theia IDE](https://github.com/eclipse-theia/theia-ide). diff --git a/tests/test-derive-tags.sh b/tests/test-derive-tags.sh new file mode 100755 index 0000000..9e24215 --- /dev/null +++ b/tests/test-derive-tags.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Regression tests for tag derivation in build-and-push-docker-image.yml. +# +# This does NOT reimplement the logic. It extracts the `derive` step's shell +# body straight out of the workflow YAML and executes it, so the test can never +# drift from what actually runs in CI. +# +# Requires: yq, bash 4+ +# +# Run: ./tests/test-derive-tags.sh + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKFLOW="${ROOT}/.github/workflows/build-and-push-docker-image.yml" + +command -v yq >/dev/null || { echo "yq is required"; exit 2; } +[[ -f "$WORKFLOW" ]] || { echo "not found: $WORKFLOW"; exit 2; } + +SCRIPT="$(mktemp)" +yq -r '.jobs.setup.steps[] | select(.id == "derive") | .run' "$WORKFLOW" > "$SCRIPT" +[[ -s "$SCRIPT" ]] || { echo "could not extract the derive step from the workflow"; exit 2; } + +if grep -q '\${{' "$SCRIPT"; then + echo "FAIL: the derive step contains \${{ }} expressions, so it cannot be tested in isolation." + echo " Move them into the step's env: block." + exit 1 +fi + +FAILED=0 + +# Runs the real extracted step and prints "base|sha|cache|image" from the +# GITHUB_OUTPUT it produces, or "ERROR " if it exits non-zero. +derive() { + local out; out="$(mktemp)" + local summary; summary="$(mktemp)" + local stderr; stderr="$(mktemp)" + if ( export GITHUB_OUTPUT="$out" GITHUB_STEP_SUMMARY="$summary"; bash "$SCRIPT" ) >"$stderr" 2>&1; then + printf '%s|%s|%s|%s' \ + "$(grep '^base_tag=' "$out" | cut -d= -f2-)" \ + "$(grep '^sha_tag=' "$out" | cut -d= -f2-)" \ + "$(grep '^cache_tag=' "$out" | cut -d= -f2-)" \ + "$(grep '^cache_image=' "$out" | cut -d= -f2-)" + else + printf 'ERROR %s' "$(grep -o '::error::.*' "$stderr" | head -1 | sed 's/::error:://')" + fi + rm -f "$out" "$summary" "$stderr" +} + +expect() { # [ENV=val ...] + local name="$1" want="$2"; shift 2 + local got + got="$( export GITHUB_SHA=abc1234567890 GITHUB_REF_NAME=main GITHUB_REF=refs/heads/main \ + GITHUB_EVENT_NAME=push GITHUB_REPOSITORY=EduIDE/Example \ + OVERRIDE="" PR_NUMBER="" RELEASE_TAG="" \ + BUILD_AMD64=true BUILD_ARM64=true \ + CACHE_IMAGE_IN="" IMAGE_NAME="eduide/example" + for kv in "$@"; do export "$kv"; done + derive )" + if [[ "$got" == "$want" ]]; then + printf ' PASS %-32s %s\n' "$name" "$got" + else + printf ' FAIL %-32s\n got: %s\n want: %s\n' "$name" "$got" "$want" + FAILED=1 + fi +} + +IMG="eduide/example" + +echo "=== tag derivation (base|sha|cache|cache_image) ===" +expect "push to main" "latest|latest-abc1234|build-cache|$IMG" +expect "push to master" "latest|latest-abc1234|build-cache|$IMG" GITHUB_REF=refs/heads/master GITHUB_REF_NAME=master +expect "pull request" "pr-451|pr-451-abc1234|build-cache-pr-451|$IMG" GITHUB_EVENT_NAME=pull_request PR_NUMBER=451 +expect "release v2.3.0" "2.3.0|2.3.0-abc1234|build-cache-2.3.0|$IMG" GITHUB_EVENT_NAME=release RELEASE_TAG=v2.3.0 +expect "release without v" "1.1.0|1.1.0-abc1234|build-cache-1.1.0|$IMG" GITHUB_EVENT_NAME=release RELEASE_TAG=1.1.0 +expect "release candidate" "2.3.0-rc.1|2.3.0-rc.1-abc1234|build-cache-2.3.0-rc.1|$IMG" GITHUB_EVENT_NAME=release RELEASE_TAG=v2.3.0-rc.1 +expect "release-train override" "2.3.0|2.3.0-abc1234|build-cache-2.3.0|$IMG" OVERRIDE=2.3.0 +expect "feature branch" "feat-foo_bar|feat-foo_bar-abc1234|build-cache-feat-foo_bar|$IMG" GITHUB_REF=refs/heads/feat/foo_bar GITHUB_REF_NAME=feat/foo_bar +expect "uppercase branch" "feat-abc|feat-abc-abc1234|build-cache-feat-abc|$IMG" GITHUB_REF=refs/heads/Feat/ABC GITHUB_REF_NAME=Feat/ABC +expect "custom cache image" "latest|latest-abc1234|build-cache|eduide/shared-cache" CACHE_IMAGE_IN=eduide/shared-cache + +echo +echo "=== no trailing hyphen (regression: 1.1.0- and 1.1.0--375ef32 reached GHCR) ===" +expect "release tag is exact" "2.3.0|2.3.0-abc1234|build-cache-2.3.0|$IMG" GITHUB_EVENT_NAME=release RELEASE_TAG=v2.3.0 +expect "branch tag is exact" "my-branch|my-branch-abc1234|build-cache-my-branch|$IMG" GITHUB_REF=refs/heads/my-branch GITHUB_REF_NAME=my-branch + +echo +echo "=== guards ===" +expect "PR without number" "ERROR pull_request event but no PR number available" GITHUB_EVENT_NAME=pull_request PR_NUMBER= +expect "release without tag" "ERROR release event but release.tag_name is empty" GITHUB_EVENT_NAME=release RELEASE_TAG= +expect "no platform selected" "ERROR build-amd64 and build-arm64 are both false; nothing to build" BUILD_AMD64=false BUILD_ARM64=false +expect "malformed tag v.1.1.1" "ERROR derived tag '.1.1.1' is not a valid Docker tag." GITHUB_EVENT_NAME=release RELEASE_TAG=v.1.1.1 +expect "tag starting with -" "ERROR derived tag '-nope' is not a valid Docker tag." OVERRIDE=-nope + +echo +echo "=== artifact key: no image name may prefix-match another ===" +# The merge job downloads digests by glob. A bare slug lets one image swallow +# another's digests when one name is a prefix of another - "...-c-*" also +# matches "...-c-templates-amd64". That shipped once and produced manifests +# containing the wrong image's digests for c, java-17 and rust. +key_for() { + local out; out="$(mktemp)" + ( export GITHUB_SHA=abc1234567890 GITHUB_REF_NAME=main GITHUB_REF=refs/heads/main \ + GITHUB_EVENT_NAME=push GITHUB_REPOSITORY=EduIDE/Example \ + OVERRIDE="" PR_NUMBER="" RELEASE_TAG="" BUILD_AMD64=true BUILD_ARM64=true \ + CACHE_IMAGE_IN="" IMAGE_NAME="$1" GITHUB_OUTPUT="$out" \ + GITHUB_STEP_SUMMARY=/dev/null + bash "$SCRIPT" >/dev/null 2>&1 ) + grep '^artifact_key=' "$out" | cut -d= -f2- + rm -f "$out" +} + +# Every colliding pair that actually exists in the EduIDE image set. +for pair in \ + "eduide/eduide/c:eduide/eduide/c-templates" \ + "eduide/eduide/java-17:eduide/eduide/java-17-templates" \ + "eduide/eduide/java-17:eduide/eduide/java-17-no-ls" \ + "eduide/eduide/rust:eduide/eduide/rust-no-ls" \ + "eduide/eduide/base:eduide/eduide/base-extra" +do + short="${pair%%:*}"; long="${pair##*:}" + ks="$(key_for "$short")"; kl="$(key_for "$long")" + if [[ "$kl" == "$ks"* ]]; then + printf ' FAIL %-42s glob "%s-*" would also match "%s"\n' "$short vs $long" "$ks" "$kl" + FAILED=1 + else + printf ' PASS %-42s %s vs %s\n' "$short vs $long" "$ks" "$kl" + fi +done + +rm -f "$SCRIPT" +echo +if [[ $FAILED -eq 0 ]]; then echo "ALL PASS"; else echo "SOME FAILED"; fi +exit $FAILED