diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index f1fc1ca..638c13e 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -8,7 +8,13 @@ permissions: contents: read jobs: - shellcheck: + # The reusable workflow runs `find "$SCRIPT_DIRS"`, quoting the value as a + # single path — so a space-separated list breaks. Pass one path per job. + shellcheck-entrypoint: uses: nullplatform/actions-nullplatform/.github/workflows/shellcheck.yml@main with: - script_dirs: entrypoint scripts + script_dirs: entrypoint + shellcheck-scripts: + uses: nullplatform/actions-nullplatform/.github/workflows/shellcheck.yml@main + with: + script_dirs: scripts diff --git a/.gitignore b/.gitignore index b99c808..5fc5a46 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .idea -.DS_Store \ No newline at end of file +.DS_Store + +.env +.claude diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd6465..8ec0e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to `application-lifecycle-manager` will be documented in thi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once it reaches a stable API. +## [Unreleased] + +### Added +- GitHub code repository provider (GitHub App auth via the `gh` CLI). + ## [0.2.0] - 2025-11-13 ### Added diff --git a/README.md b/README.md index 997fde3..f6790ea 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ When an application is created, this service coordinates two main tasks: You must configure your code repository provider through **nullplatform platform settings** or the **nullplatform Terraform provider**. -> **Note:** At the moment, this repository supports **GitLab** repositories only. +> **Note:** This repository supports **GitLab** and **GitHub** code repositories. #### Workflow @@ -59,6 +59,24 @@ The code repository workflow is composed of the following tasks: - **Trigger initial CI build** Optionally kicks off a first CI build so you can deploy your application immediately after creation. +#### Using GitHub + +To use GitHub as the code repository provider, set `CODE_REPOSITORY_PROVIDER=github` in the +agent environment along with a GitHub App's credentials: + +| Variable | Required | Description | +|---------------------------|----------|---------------------------------------------------------| +| `GITHUB_APP_ID` | yes | The GitHub App's ID. | +| `GITHUB_PRIVATE_KEY` | yes | The GitHub App's private key (PEM). | +| `GITHUB_INSTALLATION_ID` | yes | The App installation ID for the target org. | +| `GITHUB_ACCOUNT` | yes | The owner/org where repositories are created. | + +**Why a GitHub App (not a PAT):** the App is owned by the organization, is not tied to a +person, and needs no manual token rotation — an installation token is minted per run and +expires on its own. Install the App on your org and grant it repository **administration**, +**contents**, **secrets**, and **actions** permissions. The agent host must have the `gh` +CLI (installed automatically via `mise` if absent), `openssl`, and `curl`. GitHub.com only. + --- ### Creating an asset repository diff --git a/scripts/code-repo/github/add_collaborators b/scripts/code-repo/github/add_collaborators new file mode 100755 index 0000000..43cd75d --- /dev/null +++ b/scripts/code-repo/github/add_collaborators @@ -0,0 +1,27 @@ +#!/bin/bash + +# Per-collaborator failures are non-fatal (warn and continue); this loop runs in a +# pipe subshell, so it intentionally does not abort the workflow. (Contrast create_secrets.) +echo "$CODE_REPOSITORY_COLLABORATORS" | jq -c '.[]?' | while read -r collaborator; do + id=$(echo "$collaborator" | jq -r '.id') + role=$(echo "$collaborator" | jq -r '.role') + type=$(echo "$collaborator" | jq -r '.type') + + if [[ "$type" == "user" ]]; then + echo "Adding user '$id' with permission '$role'" + if ! gh api --method PUT "/repos/$GITHUB_ACCOUNT/$REPOSITORY_NAME/collaborators/$id" \ + -f permission="$role"; then + echo "Warning: failed to add user '$id'" + fi + elif [[ "$type" == "team" ]]; then + echo "Adding team '$id' with permission '$role'" + if ! gh api --method PUT "/orgs/$GITHUB_ACCOUNT/teams/$id/repos/$GITHUB_ACCOUNT/$REPOSITORY_NAME" \ + -f permission="$role"; then + echo "Warning: failed to add team '$id'" + fi + else + echo "Warning: unknown collaborator type '$type' for '$id'" + fi +done + +echo "Finished adding collaborators" diff --git a/scripts/code-repo/github/build_context b/scripts/code-repo/github/build_context new file mode 100755 index 0000000..bab887a --- /dev/null +++ b/scripts/code-repo/github/build_context @@ -0,0 +1,63 @@ +#!/bin/bash + +for var in GITHUB_APP_ID GITHUB_PRIVATE_KEY GITHUB_INSTALLATION_ID GITHUB_ACCOUNT; do + if [[ -z "${!var}" ]]; then + echo "ERROR: $var is required for the GitHub code provider but is not set." + exit 1 + fi +done + +if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI not found, installing via mise" + if ! mise use -g gh@latest; then + echo "ERROR: failed to install gh via mise" + exit 1 + fi + export PATH="$HOME/.local/share/mise/shims:$PATH" +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: gh CLI is still not available after install" + exit 1 +fi + +b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; } + +NOW=$(date +%s) +IAT=$((NOW - 60)) +EXP=$((NOW + 540)) + +JWT_HEADER=$(printf '{"alg":"RS256","typ":"JWT"}' | b64url) +JWT_PAYLOAD=$(printf '{"iat":%s,"exp":%s,"iss":"%s"}' "$IAT" "$EXP" "$GITHUB_APP_ID" | b64url) +JWT_UNSIGNED="$JWT_HEADER.$JWT_PAYLOAD" + +PRIVATE_KEY_FILE=$(mktemp) +printf '%s' "$GITHUB_PRIVATE_KEY" | sed 's/\\n/\n/g' > "$PRIVATE_KEY_FILE" + +JWT_SIGNATURE=$(printf '%s' "$JWT_UNSIGNED" | openssl dgst -sha256 -sign "$PRIVATE_KEY_FILE" | b64url) +rm -f "$PRIVATE_KEY_FILE" + +if [[ -z "$JWT_SIGNATURE" ]]; then + echo "ERROR: failed to sign the GitHub App JWT (check GITHUB_PRIVATE_KEY)." + exit 1 +fi + +JWT="$JWT_UNSIGNED.$JWT_SIGNATURE" + +TOKEN_RESPONSE=$(curl -s -X POST \ + -H "Authorization: Bearer $JWT" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/app/installations/$GITHUB_INSTALLATION_ID/access_tokens") + +GH_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.token // empty') + +if [[ -z "$GH_TOKEN" ]]; then + echo "ERROR: could not obtain a GitHub App installation token:" + echo "$TOKEN_RESPONSE" | jq . 2>/dev/null || echo "$TOKEN_RESPONSE" + exit 1 +fi + +echo "Obtained GitHub App installation token for account: $GITHUB_ACCOUNT" + +export GH_TOKEN +export GITHUB_ACCOUNT diff --git a/scripts/code-repo/github/create_repository b/scripts/code-repo/github/create_repository new file mode 100755 index 0000000..54bfa88 --- /dev/null +++ b/scripts/code-repo/github/create_repository @@ -0,0 +1,28 @@ +#!/bin/bash + +if [[ "$CODE_REPOSITORY_STRATEGY" == "import" ]]; then + echo "Strategy is set to 'import'. Skipping repo creation." + return +fi + +TEMPLATE_PATH=$(echo "$TEMPLATE_URL" | sed -E 's#https?://github\.com/##; s#\.git$##; s#/$##') +TEMPLATE_OWNER=$(echo "$TEMPLATE_PATH" | cut -d'/' -f1) +TEMPLATE_REPO=$(echo "$TEMPLATE_PATH" | cut -d'/' -f2) + +if [[ -z "$TEMPLATE_OWNER" || -z "$TEMPLATE_REPO" ]]; then + echo "Error: invalid template repository URL: $TEMPLATE_URL" + exit 1 +fi + +echo "Creating repository $GITHUB_ACCOUNT/$REPOSITORY_NAME from template $TEMPLATE_OWNER/$TEMPLATE_REPO" + +if ! gh api --method POST "/repos/$TEMPLATE_OWNER/$TEMPLATE_REPO/generate" \ + -f owner="$GITHUB_ACCOUNT" \ + -f name="$REPOSITORY_NAME" \ + -F include_all_branches=false \ + -F private=true; then + echo "Error creating repository from template" + exit 1 +fi + +echo "Repository created: $GITHUB_ACCOUNT/$REPOSITORY_NAME" diff --git a/scripts/code-repo/github/create_secrets b/scripts/code-repo/github/create_secrets new file mode 100755 index 0000000..1d198d1 --- /dev/null +++ b/scripts/code-repo/github/create_secrets @@ -0,0 +1,14 @@ +#!/bin/bash + +SECRET_ENTRIES=$(echo "$CODE_REPOSITORY_SECRETS" | jq -r 'to_entries[]? | [.key, (.value|tostring)] | @tsv') + +while IFS=$'\t' read -r key value; do + [[ -z "$key" ]] && continue + echo "Creating secret: $key" + if printf '%s' "$value" | gh secret set "$key" --repo "$GITHUB_ACCOUNT/$REPOSITORY_NAME"; then + echo "Secret $key created successfully" + else + echo "ERROR: failed to create secret $key" + exit 1 + fi +done <<< "$SECRET_ENTRIES" diff --git a/scripts/code-repo/github/run_first_build b/scripts/code-repo/github/run_first_build new file mode 100755 index 0000000..4851c96 --- /dev/null +++ b/scripts/code-repo/github/run_first_build @@ -0,0 +1,64 @@ +#!/bin/bash + +REPO="$GITHUB_ACCOUNT/$REPOSITORY_NAME" +CANCEL_STATUSES="in_progress queued requested waiting pending" + +is_cancel_status() { + local candidate="$1" + local status + for status in $CANCEL_STATUSES; do + [[ "$candidate" == "$status" ]] && return 0 + done + return 1 +} + +rerun_tolerating_non_retriable() { + local run_id="$1" + local output + if output=$(gh api --method POST "/repos/$REPO/actions/runs/$run_id/rerun" 2>&1); then + echo "Re-ran workflow run $run_id" + elif echo "$output" | grep -q "cannot be retried"; then + echo "Skipping non-retriable workflow run $run_id" + else + echo "ERROR: failed to re-run workflow run $run_id:" + echo "$output" + exit 1 + fi +} + +WORKFLOW_IDS=$(gh api "/repos/$REPO/actions/workflows" \ + --jq '.workflows[] | select((.path // "") | startswith("dynamic/") | not) | .id') + +if [[ -z "$WORKFLOW_IDS" ]]; then + echo "No workflows found for $REPO; nothing to run." + return +fi + +for workflow_id in $WORKFLOW_IDS; do + RUNS=$(gh api "/repos/$REPO/actions/workflows/$workflow_id/runs" \ + --jq '.workflow_runs[] | "\(.id) \(.status) \(.conclusion)"') + + while read -r run_id status conclusion; do + [[ -z "$run_id" ]] && continue + + if is_cancel_status "$status"; then + echo "Cancelling in-progress run $run_id (status=$status)" + gh api --method POST "/repos/$REPO/actions/runs/$run_id/cancel" >/dev/null 2>&1 || true + + attempts=0 + while (( attempts < 30 )); do + sleep 2 + current=$(gh api "/repos/$REPO/actions/runs/$run_id" --jq '.status' 2>/dev/null) + is_cancel_status "$current" || break + attempts=$((attempts + 1)) + done + + rerun_tolerating_non_retriable "$run_id" + elif { [[ "$status" == "completed" && "$conclusion" != "success" ]] || [[ "$status" == "failure" ]]; }; then + echo "Re-running finished/failed run $run_id (status=$status conclusion=$conclusion)" + rerun_tolerating_non_retriable "$run_id" + fi + done <<< "$RUNS" +done + +echo "Finished ensuring CI runs for $REPO" diff --git a/scripts/code-repo/github/validate_repository_does_not_exist b/scripts/code-repo/github/validate_repository_does_not_exist new file mode 100755 index 0000000..a2bdda1 --- /dev/null +++ b/scripts/code-repo/github/validate_repository_does_not_exist @@ -0,0 +1,16 @@ +#!/bin/bash + +echo "Checking if repository exists: $GITHUB_ACCOUNT/$REPOSITORY_NAME" + +if gh api "/repos/$GITHUB_ACCOUNT/$REPOSITORY_NAME" >/dev/null 2>&1; then + if [[ "$CODE_REPOSITORY_STRATEGY" == "create" ]]; then + echo "Error: Repository already exists but strategy is set to 'create'." + exit 1 + fi + echo "Repository found and strategy is set to 'import'. Proceeding with import configuration..." +elif [[ "$CODE_REPOSITORY_STRATEGY" == "import" ]]; then + echo "Error: Repository does not exist but strategy is set to 'import'." + exit 1 +else + echo "Repository does not exist and strategy is set to 'create'. Proceeding with repository creation..." +fi