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
10 changes: 8 additions & 2 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
.idea

.DS_Store
.DS_Store

.env
.claude
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions scripts/code-repo/github/add_collaborators
Original file line number Diff line number Diff line change
@@ -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"
63 changes: 63 additions & 0 deletions scripts/code-repo/github/build_context
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions scripts/code-repo/github/create_repository
Original file line number Diff line number Diff line change
@@ -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"
14 changes: 14 additions & 0 deletions scripts/code-repo/github/create_secrets
Original file line number Diff line number Diff line change
@@ -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"
64 changes: 64 additions & 0 deletions scripts/code-repo/github/run_first_build
Original file line number Diff line number Diff line change
@@ -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"
16 changes: 16 additions & 0 deletions scripts/code-repo/github/validate_repository_does_not_exist
Original file line number Diff line number Diff line change
@@ -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